Files
the-island/backend/app/engine.py
empty 8915a4b074 feat: implement AI Director & Narrative Voting System (Phase 9)
Add complete AI Director system that transforms the survival simulation
into a user-driven interactive story with audience voting.

Backend:
- Add DirectorService for LLM-powered plot generation with fallback templates
- Add VoteManager for dual-channel voting (Twitch + Unity)
- Integrate 4-phase game loop: Simulation → Narrative → Voting → Resolution
- Add vote command parsing (!1, !2, !A, !B) in Twitch service
- Add type-safe LLM output handling with _coerce_int() helper
- Normalize voter IDs for case-insensitive duplicate prevention

Unity Client:
- Add NarrativeUI for cinematic event cards and voting progress bars
- Add 7 new event types and data models for director/voting events
- Add delayed subscription coroutine for NetworkManager timing
- Sync client timer with server's remaining_seconds to prevent drift

Documentation:
- Update README.md with AI Director features, voting commands, and event types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 03:37:41 +08:00

2185 lines
92 KiB
Python

"""
Core Game Engine - The Island Survival Simulation.
Manages survival mechanics, agent states, weather, time, social interactions, and user commands.
"""
import asyncio
import logging
import random
import re
import time
from typing import TYPE_CHECKING, Optional
from .schemas import GameEvent, EventType
from .database import init_db, get_db_session
from .models import User, Agent, WorldState, GameConfig, AgentRelationship
from .llm import llm_service
from .memory_service import memory_service
from .director_service import DirectorService, GameMode, PlotPoint
from .vote_manager import VoteManager, VoteOption, VoteSnapshot
if TYPE_CHECKING:
from .server import ConnectionManager
logger = logging.getLogger(__name__)
# =============================================================================
# Command patterns
# =============================================================================
FEED_PATTERN = re.compile(r"feed\s+(\w+)", re.IGNORECASE)
CHECK_PATTERN = re.compile(r"(check|查询|状态)", re.IGNORECASE)
RESET_PATTERN = re.compile(r"(reset|重新开始|重置)", re.IGNORECASE)
HEAL_PATTERN = re.compile(r"heal\s+(\w+)", re.IGNORECASE)
TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE)
ENCOURAGE_PATTERN = re.compile(r"encourage\s+(\w+)", re.IGNORECASE)
LOVE_PATTERN = re.compile(r"love\s+(\w+)", re.IGNORECASE)
REVIVE_PATTERN = re.compile(r"revive\s+(\w+)", re.IGNORECASE)
# =============================================================================
# Game constants
# =============================================================================
TICK_INTERVAL = 5.0 # Seconds between ticks
# Survival (base values, modified by difficulty)
BASE_ENERGY_DECAY_PER_TICK = 2
BASE_HP_DECAY_WHEN_STARVING = 5
# Command costs and effects
FEED_COST = 10
FEED_ENERGY_RESTORE = 20
HEAL_COST = 15
HEAL_HP_RESTORE = 30
ENCOURAGE_COST = 5
ENCOURAGE_COST = 5
ENCOURAGE_MOOD_BOOST = 15
LOVE_COST = 5
LOVE_MOOD_BOOST = 20
REVIVE_COST = 10 # Casual mode cost
INITIAL_USER_GOLD = 100
IDLE_CHAT_PROBABILITY = 0.15
# =============================================================================
# AI Director & Narrative Voting (Phase 9)
# =============================================================================
DIRECTOR_TRIGGER_INTERVAL = 60 # Ticks between narrative events (5 minutes at 5s/tick)
DIRECTOR_MIN_ALIVE_AGENTS = 2 # Minimum alive agents to trigger narrative
VOTING_DURATION_SECONDS = 60 # Duration of voting window
VOTE_BROADCAST_INTERVAL = 1.0 # How often to broadcast vote updates
# =============================================================================
# Day/Night cycle
# =============================================================================
TICKS_PER_DAY = 120 # 10 minutes per day at 5s/tick
DAY_PHASES = {
"dawn": (0, 15), # Ticks 0-15
"day": (16, 75), # Ticks 16-75
"dusk": (76, 90), # Ticks 76-90
"night": (91, 119) # Ticks 91-119
}
PHASE_MODIFIERS = {
"dawn": {"energy_decay": 0.8, "hp_recovery": 1, "mood_change": 3},
"day": {"energy_decay": 1.0, "hp_recovery": 2, "mood_change": 2},
"dusk": {"energy_decay": 1.2, "hp_recovery": 0, "mood_change": -2},
"night": {"energy_decay": 1.3, "hp_recovery": 0, "mood_change": -3}
}
# =============================================================================
# Weather system
# =============================================================================
WEATHER_TYPES = {
"Sunny": {"energy_modifier": 1.0, "mood_change": 5},
"Cloudy": {"energy_modifier": 1.0, "mood_change": 0},
"Rainy": {"energy_modifier": 1.2, "mood_change": -8},
"Stormy": {"energy_modifier": 1.4, "mood_change": -15},
"Hot": {"energy_modifier": 1.3, "mood_change": -5},
"Foggy": {"energy_modifier": 1.1, "mood_change": -3}
}
WEATHER_TRANSITIONS = {
"Sunny": {"Sunny": 0.5, "Cloudy": 0.3, "Hot": 0.15, "Rainy": 0.05},
"Cloudy": {"Cloudy": 0.3, "Sunny": 0.3, "Rainy": 0.25, "Foggy": 0.1, "Stormy": 0.05},
"Rainy": {"Rainy": 0.3, "Cloudy": 0.35, "Stormy": 0.2, "Foggy": 0.15},
"Stormy": {"Stormy": 0.2, "Rainy": 0.5, "Cloudy": 0.3},
"Hot": {"Hot": 0.3, "Sunny": 0.5, "Cloudy": 0.15, "Stormy": 0.05},
"Foggy": {"Foggy": 0.2, "Cloudy": 0.4, "Rainy": 0.25, "Sunny": 0.15}
}
WEATHER_MIN_DURATION = 15
WEATHER_MAX_DURATION = 35
# =============================================================================
# Social interactions
# =============================================================================
SOCIAL_INTERACTIONS = {
"chat": {"affection": (1, 4), "trust": (0, 2), "weight": 0.4},
"share_food": {"affection": (3, 7), "trust": (2, 4), "weight": 0.15, "min_energy": 40},
"help": {"affection": (4, 8), "trust": (3, 6), "weight": 0.15, "min_energy": 30},
"comfort": {"affection": (3, 6), "trust": (2, 4), "weight": 0.2, "target_max_mood": 40},
"argue": {"affection": (-8, -3), "trust": (-5, -2), "weight": 0.1, "max_mood": 35}
}
# Initial NPC data
INITIAL_AGENTS = [
{"name": "Jack", "personality": "Brave", "social_tendency": "extrovert"},
{"name": "Luna", "personality": "Cunning", "social_tendency": "neutral"},
{"name": "Bob", "personality": "Honest", "social_tendency": "introvert"},
]
class GameEngine:
"""
The core game engine for island survival simulation.
Manages agents, users, time, weather, social interactions, and survival mechanics.
"""
def __init__(self, connection_manager: "ConnectionManager") -> None:
self._manager = connection_manager
self._running = False
self._tick_count = 0
self._tick_interval = TICK_INTERVAL
self._tick_interval = TICK_INTERVAL
self._config: Optional[GameConfig] = None
# Phase 22: Contextual Dialogue System
# Key: agent_id (who needs to respond), Value: {partner_id, last_text, topic, expires_at_tick}
self._active_conversations = {}
# Phase 9: AI Director & Narrative Voting
self._director = DirectorService()
self._vote_manager = VoteManager(
duration_seconds=VOTING_DURATION_SECONDS,
broadcast_interval=VOTE_BROADCAST_INTERVAL,
)
self._game_mode = GameMode.SIMULATION
self._last_narrative_tick = 0
self._current_plot: PlotPoint | None = None
self._mode_change_tick = 0 # Tick when mode changed
# Set up vote broadcast callback
self._vote_manager.set_broadcast_callback(self._on_vote_update)
@property
def is_running(self) -> bool:
return self._running
def _get_config(self) -> GameConfig:
"""Get or load game configuration."""
if self._config is None:
with get_db_session() as db:
config = db.query(GameConfig).first()
if config is None:
config = GameConfig()
# Expunge to detach from session, then make it usable outside
db.expunge(config)
# Access all attributes while still valid to load them
_ = (config.difficulty, config.energy_decay_multiplier,
config.hp_decay_multiplier, config.auto_revive_enabled,
config.auto_revive_delay_ticks, config.revive_hp,
config.revive_energy, config.social_interaction_probability)
self._config = config
return self._config
def _seed_initial_data(self) -> None:
"""Seed initial agents, world state, and config if database is empty."""
with get_db_session() as db:
# Seed agents
if db.query(Agent).count() == 0:
logger.info("Seeding initial agents...")
for agent_data in INITIAL_AGENTS:
agent = Agent(
name=agent_data["name"],
personality=agent_data["personality"],
social_tendency=agent_data.get("social_tendency", "neutral"),
status="Alive",
hp=100,
energy=100,
mood=70
)
db.add(agent)
logger.info(f"Created {len(INITIAL_AGENTS)} initial agents")
# Seed world state
if db.query(WorldState).first() is None:
logger.info("Seeding initial world state...")
world = WorldState(day_count=1, weather="Sunny", resource_level=100)
db.add(world)
# Seed game config (casual mode by default)
if db.query(GameConfig).first() is None:
logger.info("Seeding game config (casual mode)...")
config = GameConfig(difficulty="casual")
db.add(config)
def _get_or_create_user(self, db, username: str) -> User:
"""Get existing user or create new one."""
user = db.query(User).filter(User.username == username).first()
if user is None:
user = User(username=username, gold=INITIAL_USER_GOLD)
db.add(user)
db.flush()
logger.info(f"New user registered: {username}")
return user
# =========================================================================
# Event broadcasting
# =========================================================================
async def _broadcast_event(self, event_type: str, data: dict) -> None:
"""Create and broadcast a game event."""
event = GameEvent(event_type=event_type, timestamp=time.time(), data=data)
await self._manager.broadcast(event)
async def _broadcast_vfx(self, effect: str, target_id: int = 0, message: str = "") -> None:
"""Helper to broadcast visual effect events."""
await self._broadcast_event(EventType.VFX_EVENT, {
"effect": effect,
"target_id": target_id,
"message": message
})
async def _broadcast_agents_status(self) -> None:
"""Broadcast all agents' current status."""
with get_db_session() as db:
agents = db.query(Agent).all()
agents_data = []
for agent in agents:
data = agent.to_dict()
# Phase 21-B: Inject relationships
data["relationships"] = self._get_agent_relationships(db, agent.id)
agents_data.append(data)
await self._broadcast_event(EventType.AGENTS_UPDATE, {"agents": agents_data})
def _get_agent_relationships(self, db, agent_id: int) -> list:
"""Fetch significant relationships for an agent."""
# Phase 21-B: Only send non-stranger relationships to save bandwidth
rels = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == agent_id,
AgentRelationship.relationship_type != "stranger"
).all()
results = []
for r in rels:
results.append({
"target_id": r.agent_to_id,
"type": r.relationship_type,
"affection": r.affection
})
return results
async def _broadcast_world_status(self) -> None:
"""Broadcast world state."""
with get_db_session() as db:
world = db.query(WorldState).first()
if world:
await self._broadcast_event(EventType.WORLD_UPDATE, world.to_dict())
# =========================================================================
# AI Director & Narrative Voting (Phase 9)
# =========================================================================
async def _on_vote_update(self, snapshot: VoteSnapshot) -> None:
"""Callback for broadcasting vote updates."""
await self._broadcast_event(EventType.VOTE_UPDATE, snapshot.to_dict())
async def _set_game_mode(self, new_mode: GameMode, message: str = "") -> None:
"""Switch game mode and broadcast the change."""
old_mode = self._game_mode
self._game_mode = new_mode
self._mode_change_tick = self._tick_count
ends_at = 0.0
if new_mode == GameMode.VOTING:
session = self._vote_manager.current_session
if session:
ends_at = session.end_ts
await self._broadcast_event(EventType.MODE_CHANGE, {
"mode": new_mode.value,
"old_mode": old_mode.value,
"message": message,
"ends_at": ends_at,
})
logger.info(f"Game mode changed: {old_mode.value} -> {new_mode.value}")
def _get_world_state_for_director(self) -> dict:
"""Build world state context for the Director."""
with get_db_session() as db:
world = db.query(WorldState).first()
agents = db.query(Agent).filter(Agent.status == "Alive").all()
alive_agents = [
{"name": a.name, "hp": a.hp, "energy": a.energy, "mood": a.mood}
for a in agents
]
mood_avg = sum(a.mood for a in agents) / len(agents) if agents else 50
return {
"day": world.day_count if world else 1,
"weather": world.weather if world else "Sunny",
"time_of_day": world.time_of_day if world else "day",
"alive_agents": alive_agents,
"mood_avg": mood_avg,
"recent_events": [], # Could be populated from event history
"tension_level": self._director.calculate_tension_level({
"alive_agents": alive_agents,
"weather": world.weather if world else "Sunny",
"mood_avg": mood_avg,
}),
}
async def _should_trigger_narrative(self) -> bool:
"""Check if conditions are met to trigger a narrative event."""
# Only trigger in simulation mode
if self._game_mode != GameMode.SIMULATION:
return False
# Check tick interval
ticks_since_last = self._tick_count - self._last_narrative_tick
if ticks_since_last < DIRECTOR_TRIGGER_INTERVAL:
return False
# Check minimum alive agents
with get_db_session() as db:
alive_count = db.query(Agent).filter(Agent.status == "Alive").count()
if alive_count < DIRECTOR_MIN_ALIVE_AGENTS:
return False
return True
async def _trigger_narrative_event(self) -> None:
"""Trigger a narrative event from the Director."""
logger.info("Director triggering narrative event...")
# Switch to narrative mode
await self._set_game_mode(GameMode.NARRATIVE, "The Director intervenes...")
# Generate plot point
world_state = self._get_world_state_for_director()
plot = await self._director.generate_plot_point(world_state)
self._current_plot = plot
self._last_narrative_tick = self._tick_count
# Broadcast narrative event
await self._broadcast_event(EventType.NARRATIVE_PLOT, plot.to_dict())
logger.info(f"Narrative event: {plot.title}")
# Start voting session
options = [
VoteOption(choice_id=c.choice_id, text=c.text)
for c in plot.choices
]
self._vote_manager.start_vote(options, duration_seconds=VOTING_DURATION_SECONDS)
# Broadcast vote started
vote_data = self._vote_manager.get_vote_started_data()
if vote_data:
await self._broadcast_event(EventType.VOTE_STARTED, vote_data)
# Switch to voting mode
await self._set_game_mode(
GameMode.VOTING,
f"Vote now! {plot.choices[0].text} or {plot.choices[1].text}"
)
async def _process_voting_tick(self) -> None:
"""Process voting phase - check if voting has ended."""
if self._game_mode != GameMode.VOTING:
return
result = self._vote_manager.maybe_finalize()
if result:
# Voting ended
await self._broadcast_event(EventType.VOTE_ENDED, {
"vote_id": result.vote_id,
"total_votes": result.total_votes,
})
await self._broadcast_event(EventType.VOTE_RESULT, result.to_dict())
# Switch to resolution mode
await self._set_game_mode(
GameMode.RESOLUTION,
f"The audience has spoken: {result.winning_choice_text}"
)
# Process resolution
await self._process_vote_result(result)
async def _process_vote_result(self, result) -> None:
"""Process the voting result and apply consequences."""
if not self._current_plot:
logger.error("No current plot for resolution")
await self._set_game_mode(GameMode.SIMULATION, "Returning to normal...")
return
# Get resolution from Director
world_state = self._get_world_state_for_director()
resolution = await self._director.resolve_vote(
plot_point=self._current_plot,
winning_choice_id=result.winning_choice_id,
world_state=world_state,
)
# Apply effects
await self._apply_resolution_effects(resolution.effects)
# Broadcast resolution
await self._broadcast_event(EventType.RESOLUTION_APPLIED, resolution.to_dict())
logger.info(f"Resolution applied: {resolution.message}")
# Clear current plot
self._director.clear_current_plot()
self._current_plot = None
# Return to simulation after a brief pause
await asyncio.sleep(3.0) # Let players read the resolution
await self._set_game_mode(GameMode.SIMULATION, "The story continues...")
async def _apply_resolution_effects(self, effects: dict) -> None:
"""Apply resolution effects to the game world."""
def _coerce_int(value) -> int:
"""Safely convert LLM output (string/float/int) to int."""
try:
return int(value)
except (TypeError, ValueError):
return 0
mood_delta = _coerce_int(effects.get("mood_delta", 0))
hp_delta = _coerce_int(effects.get("hp_delta", 0))
energy_delta = _coerce_int(effects.get("energy_delta", 0))
if not any([mood_delta, hp_delta, energy_delta]):
return
with get_db_session() as db:
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
if mood_delta:
agent.mood = max(0, min(100, agent.mood + mood_delta))
if hp_delta:
agent.hp = max(0, min(100, agent.hp + hp_delta))
if energy_delta:
agent.energy = max(0, min(100, agent.energy + energy_delta))
logger.info(
f"Applied resolution effects: mood={mood_delta}, "
f"hp={hp_delta}, energy={energy_delta}"
)
async def process_vote(self, voter_id: str, choice_index: int, source: str = "twitch") -> bool:
"""
Process a vote from Twitch or Unity.
Args:
voter_id: Unique identifier for the voter
choice_index: 0-indexed choice number
source: Vote source ("twitch" or "unity")
Returns:
True if vote was recorded
"""
if self._game_mode != GameMode.VOTING:
return False
return self._vote_manager.cast_vote(voter_id, choice_index, source)
def parse_vote_command(self, message: str) -> int | None:
"""Parse a message for vote commands. Returns choice index or None."""
return self._vote_manager.parse_twitch_message(message)
# =========================================================================
# Day/Night cycle (Phase 2)
# =========================================================================
async def _process_weather(self) -> None:
"""Process weather transitions."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world:
return
world.weather_duration += 1
# Should we transition?
if world.weather_duration >= random.randint(WEATHER_MIN_DURATION, WEATHER_MAX_DURATION):
old_weather = world.weather
transitions = WEATHER_TRANSITIONS.get(old_weather, {"Sunny": 1.0})
# Biased random choice
choices = list(transitions.keys())
weights = list(transitions.values())
new_weather = random.choices(choices, weights=weights, k=1)[0]
if new_weather != old_weather:
world.weather = new_weather
world.weather_duration = 0
await self._broadcast_event(EventType.WEATHER_CHANGE, {
"old_weather": old_weather,
"new_weather": new_weather,
"message": f"The weather is changing to {new_weather}."
})
async def _advance_time(self) -> Optional[dict]:
"""Advance time and return phase change info if applicable."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world:
return None
old_phase = world.time_of_day
world.current_tick_in_day += 1
# New day
if world.current_tick_in_day >= TICKS_PER_DAY:
world.current_tick_in_day = 0
world.day_count += 1
# Phase 17-A: Regenerate resources
world.tree_left_fruit = min(5, world.tree_left_fruit + 2)
world.tree_right_fruit = min(5, world.tree_right_fruit + 2)
await self._broadcast_event(EventType.DAY_CHANGE, {
"day": world.day_count,
"message": f"Day {world.day_count} begins! Trees have new fruit."
})
# Determine current phase
tick = world.current_tick_in_day
for phase, (start, end) in DAY_PHASES.items():
if start <= tick <= end:
if world.time_of_day != phase:
world.time_of_day = phase
return {"old_phase": old_phase, "new_phase": phase, "day": world.day_count}
break
return None
# =========================================================================
# Weather system (Phase 3)
# =========================================================================
async def _update_weather(self) -> Optional[dict]:
"""Update weather based on transition probabilities."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world:
return None
world.weather_duration += 1
# Check if weather should change
target_duration = random.randint(WEATHER_MIN_DURATION, WEATHER_MAX_DURATION)
if world.weather_duration >= target_duration:
old_weather = world.weather
transitions = WEATHER_TRANSITIONS.get(old_weather, {"Sunny": 1.0})
new_weather = random.choices(
list(transitions.keys()),
weights=list(transitions.values())
)[0]
if new_weather != old_weather:
world.weather = new_weather
world.weather_duration = 0
return {"old_weather": old_weather, "new_weather": new_weather}
return None
async def _update_moods(self) -> None:
"""Update agent moods based on weather and time."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world:
return
weather_effect = WEATHER_TYPES.get(world.weather, {}).get("mood_change", 0)
phase_effect = PHASE_MODIFIERS.get(world.time_of_day, {}).get("mood_change", 0)
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
# Apply mood changes (scaled down for per-tick application)
mood_delta = (weather_effect + phase_effect) * 0.1
agent.mood = max(0, min(100, agent.mood + mood_delta))
# Update mood state
if agent.mood >= 70:
agent.mood_state = "happy"
elif agent.mood >= 40:
agent.mood_state = "neutral"
elif agent.mood >= 20:
agent.mood_state = "sad"
else:
agent.mood_state = "anxious"
# =========================================================================
# Relationship 2.0 (Phase 17-B)
# =========================================================================
async def _assign_social_roles(self) -> None:
"""Assign social roles based on personality and social tendency."""
with get_db_session() as db:
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
if agent.social_role != "neutral":
continue # Already assigned
# Role assignment based on personality and tendency
if agent.social_tendency == "extrovert" and agent.mood > 60:
agent.social_role = "leader"
elif agent.social_tendency == "introvert" and agent.mood < 40:
agent.social_role = "loner"
elif random.random() < 0.3:
agent.social_role = "follower"
# Otherwise stays neutral
async def _process_clique_behavior(self) -> None:
"""Leaders influence followers' actions."""
# Run occasionally
if self._tick_count % 10 != 0:
return
with get_db_session() as db:
leaders = db.query(Agent).filter(
Agent.status == "Alive",
Agent.social_role == "leader"
).all()
followers = db.query(Agent).filter(
Agent.status == "Alive",
Agent.social_role == "follower"
).all()
for leader in leaders:
# Followers near leader copy their action
for follower in followers:
if follower.current_action == "Idle" and leader.current_action not in ["Idle", None]:
follower.current_action = leader.current_action
follower.location = leader.location
await self._broadcast_event(EventType.COMMENT, {
"user": "System",
"message": f"{follower.name} follows {leader.name}'s lead!"
})
# =========================================================================
# Survival mechanics
# =========================================================================
async def _process_survival_tick(self) -> None:
"""Process survival mechanics with difficulty modifiers."""
config = self._get_config()
deaths = []
with get_db_session() as db:
world = db.query(WorldState).first()
phase_mod = PHASE_MODIFIERS.get(world.time_of_day if world else "day", {})
weather_mod = WEATHER_TYPES.get(world.weather if world else "Sunny", {})
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in alive_agents:
# 1. Contracting Sickness
# Phase 20-B: Check if sheltered
agent.is_sheltered = agent.location in ["tree_left", "tree_right"]
if not agent.is_sick:
sickness_chance = 0.01 # Base 1% per tick (every 5s)
# Weather impact
current_weather = world.weather if world else "Sunny"
if current_weather == "Rainy":
sickness_chance += 0.05
elif current_weather == "Stormy":
sickness_chance += 0.10
# Phase 20-B: Shelter mitigation (Reduce sickness chance by 80%)
if agent.is_sheltered and current_weather in ["Rainy", "Stormy"]:
sickness_chance *= 0.2
# Immunity impact (Higher immunity = lower chance)
# Immunity 50 -> -2.5%, Immunity 100 -> -5%
sickness_chance -= (agent.immunity / 2000.0)
if random.random() < sickness_chance:
agent.is_sick = True
agent.mood -= 20
logger.info(f"Agent {agent.name} has fallen sick!")
# We could broadcast a specific event, but AGENTS_UPDATE will handle visual state
# Just log it or maybe a system message?
await self._broadcast_event(EventType.COMMENT, {
"user": "System",
"message": f"{agent.name} is looking pale... (Sick)"
})
# 2. Sickness Effects
if agent.is_sick:
# Decay HP and Energy faster
agent.hp = max(0, agent.hp - 2)
agent.energy = max(0, agent.energy - 2)
# Lower mood over time
if self._tick_count % 5 == 0:
agent.mood = max(0, agent.mood - 1)
# --- End Sickness ---
# Calculate energy decay with all modifiers
base_decay = BASE_ENERGY_DECAY_PER_TICK
decay = base_decay * config.energy_decay_multiplier
decay *= phase_mod.get("energy_decay", 1.0)
weather_decay_mod = weather_mod.get("energy_modifier", 1.0)
# Phase 20-B: Shelter mitigation (Reduce weather energy penalty by 80%)
if agent.is_sheltered and weather_decay_mod > 1.0:
weather_decay_mod = 1.0 + (weather_decay_mod - 1.0) * 0.2
decay *= weather_decay_mod
agent.energy = max(0, agent.energy - int(decay))
# HP recovery during day phases (Only if NOT sick)
hp_recovery = phase_mod.get("hp_recovery", 0)
if hp_recovery > 0 and agent.energy > 20 and not agent.is_sick:
agent.hp = min(100, agent.hp + hp_recovery)
# Starvation damage
if agent.energy <= 0:
hp_decay = BASE_HP_DECAY_WHEN_STARVING * config.hp_decay_multiplier
agent.hp = max(0, agent.hp - int(hp_decay))
# Death check
if agent.hp <= 0:
agent.status = "Dead"
agent.death_tick = self._tick_count
if agent.is_sick:
# Clear sickness on death
agent.is_sick = False
deaths.append({"name": agent.name, "personality": agent.personality})
logger.info(f"Agent {agent.name} has died!")
# Broadcast death events
for death in deaths:
await self._broadcast_event(EventType.AGENT_DIED, {
"agent_name": death["name"],
"message": f"{death['name']} ({death['personality']}) has died..."
})
# Phase 14: Alive agents remember the death
with get_db_session() as db:
witnesses = db.query(Agent).filter(Agent.status == "Alive").all()
for witness in witnesses:
await memory_service.add_memory(
agent_id=witness.id,
description=f"{death['name']} died. It was a sad day.",
importance=8,
related_entity_name=death["name"],
memory_type="event"
)
async def _process_auto_revive(self) -> None:
"""Auto-revive dead agents in casual mode."""
config = self._get_config()
if not config.auto_revive_enabled:
return
with get_db_session() as db:
dead_agents = db.query(Agent).filter(Agent.status == "Dead").all()
for agent in dead_agents:
if agent.death_tick is None:
continue
ticks_dead = self._tick_count - agent.death_tick
if ticks_dead >= config.auto_revive_delay_ticks:
agent.status = "Alive"
agent.hp = config.revive_hp
agent.energy = config.revive_energy
agent.mood = 50
agent.mood_state = "neutral"
agent.death_tick = None
await self._broadcast_event(EventType.AUTO_REVIVE, {
"agent_name": agent.name,
"message": f"{agent.name} has been revived!"
})
logger.info(f"Agent {agent.name} auto-revived")
# =========================================================================
# Social system (Phase 5)
# =========================================================================
async def _process_social_tick(self) -> None:
"""Process autonomous social interactions between agents."""
config = self._get_config()
if random.random() > config.social_interaction_probability:
return
with get_db_session() as db:
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
if len(alive_agents) < 2:
return
# Select initiator (extroverts more likely)
weights = []
for agent in alive_agents:
if agent.social_tendency == "extrovert":
weights.append(2.0)
elif agent.social_tendency == "introvert":
weights.append(0.5)
else:
weights.append(1.0)
initiator = random.choices(alive_agents, weights=weights)[0]
# Select target (not self)
targets = [a for a in alive_agents if a.id != initiator.id]
if not targets:
return
target = random.choice(targets)
# Get or create relationship
relationship = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == initiator.id,
AgentRelationship.agent_to_id == target.id
).first()
if not relationship:
relationship = AgentRelationship(
agent_from_id=initiator.id,
agent_to_id=target.id
)
db.add(relationship)
db.flush()
# Determine interaction type
interaction_type = self._select_interaction(initiator, target, relationship)
if not interaction_type:
return
# Apply interaction effects
effects = SOCIAL_INTERACTIONS[interaction_type]
affection_change = random.randint(*effects["affection"])
trust_change = random.randint(*effects.get("trust", (0, 0)))
relationship.affection = max(-100, min(100, relationship.affection + affection_change))
relationship.trust = max(-100, min(100, relationship.trust + trust_change))
relationship.interaction_count += 1
relationship.last_interaction_tick = self._tick_count
relationship.update_relationship_type()
# Store data for broadcasting
interaction_data = {
"initiator_id": initiator.id,
"initiator_name": initiator.name,
"target_id": target.id,
"target_name": target.name,
"interaction_type": interaction_type,
"relationship_type": relationship.relationship_type
}
world = db.query(WorldState).first()
weather = world.weather if world else "Sunny"
time_of_day = world.time_of_day if world else "day"
# Generate LLM dialogue for interaction
asyncio.create_task(self._trigger_social_dialogue(
interaction_data, weather, time_of_day
))
def _select_interaction(self, initiator: Agent, target: Agent, relationship: AgentRelationship) -> Optional[str]:
"""Select appropriate interaction type based on conditions."""
valid_interactions = []
weights = []
for itype, config in SOCIAL_INTERACTIONS.items():
# Check conditions
if "min_energy" in config and initiator.energy < config["min_energy"]:
continue
if "max_mood" in config and initiator.mood > config["max_mood"]:
continue
if "target_max_mood" in config and target.mood > config["target_max_mood"]:
continue
valid_interactions.append(itype)
weights.append(config["weight"])
if not valid_interactions:
return None
return random.choices(valid_interactions, weights=weights)[0]
async def _trigger_social_dialogue(self, interaction_data: dict, weather: str, time_of_day: str) -> None:
"""Generate and broadcast social interaction dialogue."""
try:
dialogue = await llm_service.generate_social_interaction(
initiator_name=interaction_data["initiator_name"],
target_name=interaction_data["target_name"],
interaction_type=interaction_data["interaction_type"],
relationship_type=interaction_data["relationship_type"],
weather=weather,
time_of_day=time_of_day
)
await self._broadcast_event(EventType.SOCIAL_INTERACTION, {
**interaction_data,
"dialogue": dialogue
})
# Phase 22: Contextual Dialogue - Store context for responder
# Initiator just spoke. Target needs to respond next tick.
initiator_id = interaction_data["initiator_id"]
target_id = interaction_data["target_id"]
# 50% chance to continue the conversation (A -> B -> A)
should_continue = True # For the first response (A->B), almost always yes unless "argue" maybe?
if should_continue:
self._active_conversations[target_id] = {
"partner_id": initiator_id,
"last_text": dialogue,
"topic": interaction_data["interaction_type"], # Rough topic
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
}
except Exception as e:
logger.error(f"Error in social dialogue: {e}")
# =========================================================================
# Economy / Altruism (Phase 23)
# =========================================================================
async def _process_altruism_tick(self) -> None:
"""Process altruistic item sharing based on need."""
if random.random() > 0.5: # 50% chance per tick to check
return
with get_db_session() as db:
agents = db.query(Agent).filter(Agent.status == "Alive").all()
# Shuffle to avoid priority bias
random.shuffle(agents)
for giver in agents:
giver_inv = self._get_inventory(giver)
# Check surplus
item_to_give = None
# Give Herb if have plenty
if giver_inv.get("herb", 0) >= 3:
item_to_give = "herb"
# Give Food if have plenty and energy is high
elif giver_inv.get("food", 0) >= 1 and giver.energy > 80:
item_to_give = "food"
if not item_to_give:
continue
# Find needy neighbor
for candidate in agents:
if candidate.id == giver.id: continue
cand_inv = self._get_inventory(candidate)
score = 0
if item_to_give == "herb":
# High priority: Sick and no herbs
if candidate.is_sick and cand_inv.get("herb", 0) == 0:
score = 100
elif item_to_give == "food":
# High priority: Starving and no food
if candidate.energy < 30 and cand_inv.get("food", 0) == 0:
score = 50
if score > 0:
# Check relationship (don't give to enemies)
rel = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == giver.id,
AgentRelationship.agent_to_id == candidate.id
).first()
type_ = rel.relationship_type if rel else "stranger"
if type_ in ["rival", "enemy"]:
continue
# Execute Give
giver_inv[item_to_give] -= 1
self._set_inventory(giver, giver_inv)
cand_inv[item_to_give] = cand_inv.get(item_to_give, 0) + 1
self._set_inventory(candidate, cand_inv)
# Update Relationship (Giver -> Receiver)
if not rel:
rel = AgentRelationship(agent_from_id=giver.id, agent_to_id=candidate.id)
db.add(rel)
rel.affection = min(100, rel.affection + 10)
rel.trust = min(100, rel.trust + 5)
rel.interaction_count += 1
rel.update_relationship_type()
# Update Relationship (Receiver -> Giver)
rel2 = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == candidate.id,
AgentRelationship.agent_to_id == giver.id
).first()
if not rel2:
rel2 = AgentRelationship(agent_from_id=candidate.id, agent_to_id=giver.id)
db.add(rel2)
rel2.affection = min(100, rel2.affection + 8)
rel2.trust = min(100, rel2.trust + 3)
rel2.update_relationship_type()
# Broadcast
await self._broadcast_event(EventType.GIVE_ITEM, {
"from_id": giver.id,
"to_id": candidate.id,
"item_type": item_to_give,
"message": f"{giver.name} gave 1 {item_to_give} to {candidate.name}."
})
logger.info(f"{giver.name} gave {item_to_give} to {candidate.name}")
# One action per agent per tick
break
async def _process_activity_tick(self) -> None:
"""Decide and execute autonomous agent actions."""
# Only process activity every few ticks to avoid chaotic movement
if self._tick_count % 3 != 0:
return
with get_db_session() as db:
world = db.query(WorldState).first()
if not world:
return
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
new_action = agent.current_action
new_location = agent.location
target_name = None
target_name = None
should_update = False
# Phase 22: Handle Pending Conversations (High Priority)
if agent.id in self._active_conversations:
pending = self._active_conversations[agent.id]
# Check expiry
if self._tick_count > pending["expires_at_tick"]:
del self._active_conversations[agent.id]
else:
# Force response
new_action = "Chat"
new_location = agent.location # Stay put
should_update = True
# Generate Response Immediately
partner = db.query(Agent).filter(Agent.id == pending["partner_id"]).first()
if partner:
target_name = partner.name
# Generate reply
# We consume the pending state so we don't loop forever
previous_text = pending["last_text"]
del self._active_conversations[agent.id]
# Maybe add a chance for A to respond back to B (A-B-A)?
# For simplicity, let's just do A-B for now, or 50% chance for A-B-A
should_reply_back = random.random() < 0.5
# Extract values before async task (avoid detached session issues)
asyncio.create_task(self._process_conversation_reply(
agent.id, agent.name, partner.id, partner.name,
previous_text, pending["topic"], should_reply_back
))
else:
del self._active_conversations[agent.id]
# 1. Critical Needs (Override everything)
elif world.time_of_day == "night":
if agent.current_action != "Sleep":
new_action = "Sleep"
new_location = "campfire"
should_update = True
elif agent.energy < 30:
if agent.current_action != "Gather":
new_action = "Gather"
new_location = random.choice(["tree_left", "tree_right"])
should_update = True
# Phase 20-B: Seek Shelter during Storms
elif world.weather == "Stormy" and not agent.is_sheltered:
if agent.current_action != "Seek Shelter":
new_action = "Seek Shelter"
new_location = random.choice(["tree_left", "tree_right"])
should_update = True
# 1.5. Sickness Handling (Phase 16)
elif agent.is_sick:
inv = self._get_inventory(agent)
if inv.get("medicine", 0) > 0:
# Use medicine immediately
await self._use_medicine(agent)
new_action = "Use Medicine"
new_location = agent.location
should_update = True
elif inv.get("herb", 0) >= 3:
# Craft medicine
await self._craft_medicine(agent)
new_action = "Craft Medicine"
new_location = agent.location
should_update = True
elif agent.current_action != "Gather Herb":
# Go gather herbs
new_action = "Gather Herb"
new_location = "herb_patch"
should_update = True
# 2. Mood / Social Needs
elif agent.mood < 40 and agent.current_action not in ["Sleep", "Gather", "Socialize", "Gather Herb"]:
new_action = "Socialize"
potential_friends = [a for a in agents if a.id != agent.id]
if potential_friends:
friend = random.choice(potential_friends)
new_location = "agent"
target_name = friend.name
target_name = friend.name
should_update = True
# Phase 21-C: Advanced Social Locomotion (Follow)
# If "follower" role (or just feeling social), follow a friend/leader
elif agent.current_action not in ["Sleep", "Gather", "Dance", "Follow"] and random.random() < 0.15:
target = self._find_follow_target(db, agent)
if target:
new_action = "Follow"
new_location = "agent"
target_name = target.name
should_update = True
# If Happy (>80) and near others, chance to start dancing
elif agent.mood > 80 and agent.current_action != "Dance":
# Check for nearby agents (same location)
nearby_count = 0
for other in agents:
if other.id != agent.id and other.status == "Alive" and other.location == agent.location:
nearby_count += 1
# Dance Party Trigger! (Need at least 1 friend, 10% chance)
if nearby_count >= 1 and random.random() < 0.10:
new_action = "Dance"
# Keep location same
new_location = agent.location
should_update = True
# 2. Idle Behavior (Default)
elif agent.current_action not in ["Sleep", "Chat", "Dance"]:
# Random chance to move nearby or chat
if random.random() < 0.3:
new_action = "Wander"
new_location = "nearby" # Will be randomized in Unity/GameManager mapping
should_update = True
# Phase 23: Altruism - Give Item if needed (50% chance per tick to check)
if random.random() < 0.5:
await self._process_altruism_tick()
elif random.random() < 0.1:
new_action = "Idle"
should_update = True
# 4. Finish Tasks (Simulation)
elif agent.current_action == "Gather" and agent.energy >= 90:
new_action = "Idle"
new_location = "center"
should_update = True
elif agent.current_action == "Gather" and agent.location in ["tree_left", "tree_right"]:
# Phase 17-A: Consume fruit when gathering
fruit_available = await self._consume_fruit(world, agent.location)
if fruit_available:
agent.energy = min(100, agent.energy + 30)
new_action = "Idle"
new_location = "center"
should_update = True
else:
# No fruit! Try other tree or express frustration
other_tree = "tree_right" if agent.location == "tree_left" else "tree_left"
other_fruit = world.tree_right_fruit if agent.location == "tree_left" else world.tree_left_fruit
if other_fruit > 0:
new_action = "Gather"
new_location = other_tree
should_update = True
else:
# All trees empty!
new_action = "Hungry"
new_location = "center"
should_update = True
await self._broadcast_event(EventType.COMMENT, {
"user": "System",
"message": f"{agent.name} can't find any fruit! The trees are empty..."
})
elif agent.current_action == "Sleep" and world.time_of_day != "night":
new_action = "Wake Up"
new_location = "center"
should_update = True
elif agent.current_action == "Gather Herb":
# Simulate herb gathering (add herbs)
await self._gather_herb(agent)
new_action = "Idle"
new_location = "center"
should_update = True
# Execute Update
if should_update:
agent.current_action = new_action
agent.location = new_location
# Generate simple thought/bark
dialogue = self._get_action_bark(agent, new_action, target_name)
await self._broadcast_event(EventType.AGENT_ACTION, {
"agent_id": agent.id,
"agent_name": agent.name,
"action_type": new_action,
"location": new_location,
"target_name": target_name,
"dialogue": dialogue
})
def _get_action_bark(self, agent: Agent, action: str, target: str = None) -> str:
"""Get a simple bark text for an action."""
if action == "Sleep":
return random.choice(["Yawn... sleepy...", "Time to rest.", "Zzz..."])
elif action == "Gather":
return random.choice(["Hungry!", "Need food.", "Looking for coconuts..."])
elif action == "Gather Herb":
return random.choice(["I need herbs...", "Looking for medicine plants.", "Feeling sick..."])
elif action == "Craft Medicine":
return random.choice(["Let me make some medicine.", "Mixing herbs...", "Almost done!"])
elif action == "Use Medicine":
return random.choice(["Ahh, much better!", "Medicine tastes awful but works!", "Feeling cured!"])
elif action == "Socialize":
return f"Looking for {target}..." if target else "Need a friend."
elif action == "Wander":
return random.choice(["Hmm...", "Nice weather.", "Taking a walk."])
elif action == "Wake Up":
return "Good morning!"
elif action == "Wake Up":
return "Good morning!"
elif action == "Dance":
return random.choice(["Party time!", "Let's dance!", "Woo!"])
elif action == "Follow":
return f"Wait for me, {target}!"
return ""
def _find_follow_target(self, db, agent: Agent) -> Optional[Agent]:
"""Find a suitable target to follow (Leader or Friend)."""
# 1. Prefer Leaders
leader = db.query(Agent).filter(
Agent.social_role == "leader",
Agent.status == "Alive",
Agent.id != agent.id
).first()
if leader and random.random() < 0.7:
return leader
# 2. Fallback to Close Friends
rels = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == agent.id,
AgentRelationship.relationship_type.in_(["close_friend", "friend"])
).all()
if rels:
r = random.choice(rels)
target = db.query(Agent).filter(Agent.id == r.agent_to_id, Agent.status == "Alive").first()
return target
return None
# =========================================================================
# Inventory & Crafting (Phase 16)
# =========================================================================
def _get_inventory(self, agent: Agent) -> dict:
"""Parse agent inventory JSON."""
import json
try:
return json.loads(agent.inventory) if agent.inventory else {}
except json.JSONDecodeError:
return {}
def _set_inventory(self, agent: Agent, inv: dict) -> None:
"""Set agent inventory from dict."""
import json
agent.inventory = json.dumps(inv)
async def _consume_fruit(self, world: WorldState, location: str) -> bool:
"""Consume fruit from a tree. Returns True if successful."""
if location == "tree_left":
if world.tree_left_fruit > 0:
world.tree_left_fruit -= 1
logger.info(f"Fruit consumed from tree_left. Remaining: {world.tree_left_fruit}")
return True
elif location == "tree_right":
if world.tree_right_fruit > 0:
world.tree_right_fruit -= 1
logger.info(f"Fruit consumed from tree_right. Remaining: {world.tree_right_fruit}")
return True
return False
async def _gather_herb(self, agent: Agent) -> None:
"""Agent gathers herbs."""
inv = self._get_inventory(agent)
herbs_found = random.randint(1, 2)
inv["herb"] = inv.get("herb", 0) + herbs_found
self._set_inventory(agent, inv)
await self._broadcast_event(EventType.AGENT_ACTION, {
"agent_id": agent.id,
"agent_name": agent.name,
"action_type": "Gather Herb",
"location": "herb_patch",
"dialogue": f"Found {herbs_found} herbs!"
})
logger.info(f"Agent {agent.name} gathered {herbs_found} herbs. Total: {inv['herb']}")
async def _craft_medicine(self, agent: Agent) -> None:
"""Agent crafts medicine from herbs."""
inv = self._get_inventory(agent)
if inv.get("herb", 0) >= 3:
inv["herb"] -= 3
inv["medicine"] = inv.get("medicine", 0) + 1
self._set_inventory(agent, inv)
await self._broadcast_event(EventType.CRAFT, {
"agent_id": agent.id,
"agent_name": agent.name,
"item": "medicine",
"ingredients": {"herb": 3}
})
logger.info(f"Agent {agent.name} crafted medicine. Inventory: {inv}")
async def _use_medicine(self, agent: Agent) -> None:
"""Agent uses medicine to cure sickness."""
inv = self._get_inventory(agent)
if inv.get("medicine", 0) > 0:
inv["medicine"] -= 1
self._set_inventory(agent, inv)
agent.is_sick = False
agent.hp = min(100, agent.hp + 20)
agent.mood = min(100, agent.mood + 10)
await self._broadcast_event(EventType.USE_ITEM, {
"agent_id": agent.id,
"agent_name": agent.name,
"item": "medicine",
"effect": "cured sickness"
})
logger.info(f"Agent {agent.name} used medicine and is cured!")
# =========================================================================
# Phase 24: Group Activities & Rituals
# =========================================================================
async def _process_campfire_gathering(self) -> None:
"""Encourage agents to gather at campfire at night."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world or world.time_of_day != "night":
return
# Only run check occasionally to avoid spamming decision logic every tick if not needed
if self._tick_count % 5 != 0:
return
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
# If agent is critical, they will prioritize self-preservation in _process_activity_tick
# But if they are just idle or wandering, we nudge them to campfire
if agent.hp < 30 or agent.energy < 20 or agent.is_sick:
continue
# If already there, stay
if agent.location == "campfire":
continue
# Force move to campfire "ritual"
# We update their "current_action" so the next tick they don't override it immediately
# But _process_activity_tick runs based on priorities.
# To make this sticky, we might need a "GroupActivity" state or just rely on
# tweaking the decision logic. For now, let's just forcefully set target if Idle.
if agent.current_action in ["Idle", "Wander"]:
agent.current_action = "Gathering"
agent.location = "campfire" # Teleport logic or Move logic?
# Actually, our decision logic sets location.
# Let's just update location for simplicity as 'walking' is handled by frontend interpolation
# if the distance is small, but massive jumps might look weird.
# Ideally we set a goal. But for this engine, setting location IS the action result usually.
pass
async def _process_group_activity(self) -> None:
"""Trigger storytelling if enough agents are at the campfire."""
# Only at night
with get_db_session() as db:
world = db.query(WorldState).first()
if not world or world.time_of_day != "night":
return
# Low probability check (don't spam stories)
if random.random() > 0.05:
return
# Check who is at campfire
agents_at_fire = db.query(Agent).filter(
Agent.status == "Alive",
Agent.location == "campfire"
).all()
if len(agents_at_fire) < 2:
return
# Select Storyteller (Highest Mood or Extrovert)
storyteller = max(agents_at_fire, key=lambda a: a.mood + (20 if a.social_tendency == 'extrovert' else 0))
listeners = [a for a in agents_at_fire if a.id != storyteller.id]
# Generate Story
topics = ["the ghost ship", "the ancient ruins", "a strange dream", "the day we arrived"]
topic = random.choice(topics)
story_content = await llm_service.generate_story(storyteller.name, topic)
# Broadcast Event
await self._broadcast_event(EventType.GROUP_ACTIVITY, {
"activity_type": "storytelling",
"storyteller_id": storyteller.id,
"storyteller_name": storyteller.name,
"listener_ids": [l.id for l in listeners],
"content": story_content,
"topic": topic
})
# Boost Mood for everyone involved
storyteller.mood = min(100, storyteller.mood + 10)
for listener in listeners:
listener.mood = min(100, listener.mood + 5)
# =========================================================================
# LLM-powered agent speech
# =========================================================================
async def _trigger_agent_speak(
self, agent_id: int, agent_name: str, agent_personality: str,
agent_hp: int, agent_energy: int, agent_mood: int,
event_description: str, event_type: str = "feed"
) -> None:
"""Fire-and-forget LLM call to generate agent speech."""
try:
class AgentSnapshot:
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
self.id = id
self.name = name
self.personality = personality
self.hp = hp
self.energy = energy
self.mood = mood
self.is_sheltered = is_sheltered
agent_snapshot = AgentSnapshot(
agent_id, agent_name, agent_personality, agent_hp, agent_energy, agent_mood
)
text = await llm_service.generate_reaction(agent_snapshot, event_description, event_type)
await self._broadcast_event(EventType.AGENT_SPEAK, {
"agent_id": agent_id,
"agent_name": agent_name,
"text": text
})
except Exception as e:
logger.error(f"Error in agent speak: {e}")
async def _trigger_idle_chat(self) -> None:
"""Randomly select an alive agent to say something."""
with get_db_session() as db:
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
world = db.query(WorldState).first()
weather = world.weather if world else "Sunny"
time_of_day = world.time_of_day if world else "day"
if not alive_agents:
return
agent = random.choice(alive_agents)
agent_data = {
"id": agent.id, "name": agent.name, "personality": agent.personality,
"hp": agent.hp, "energy": agent.energy, "mood": agent.mood,
"mood_state": agent.mood_state, "is_sheltered": agent.is_sheltered
}
try:
class AgentSnapshot:
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
self.id = id
self.name = name
self.personality = personality
self.hp = hp
self.energy = energy
self.mood = mood
self.is_sheltered = is_sheltered
agent_snapshot = AgentSnapshot(
agent_data["id"], agent_data["name"], agent_data["personality"],
agent_data["hp"], agent_data["energy"], agent_data["mood"],
agent_data.get("is_sheltered", False)
)
text = await llm_service.generate_idle_chat(agent_snapshot, weather, time_of_day)
await self._broadcast_event(EventType.AGENT_SPEAK, {
"agent_id": agent_data["id"],
"agent_name": agent_data["name"],
"text": text
})
except Exception as e:
logger.error(f"Error in idle chat: {e}")
# =========================================================================
# Command handlers
# =========================================================================
async def _handle_feed(self, username: str, agent_name: str) -> None:
"""Handle feed command."""
feed_result = None
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
if user.gold < FEED_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {FEED_COST}, have {user.gold}"
})
return
user.gold -= FEED_COST
old_energy = agent.energy
agent.energy = min(100, agent.energy + FEED_ENERGY_RESTORE)
agent.mood = min(100, agent.mood + 5)
feed_result = {
"agent_id": agent.id, "agent_name": agent.name,
"agent_personality": agent.personality, "agent_hp": agent.hp,
"agent_energy": agent.energy, "agent_mood": agent.mood,
"actual_restore": agent.energy - old_energy, "user_gold": user.gold
}
if feed_result:
await self._broadcast_event(EventType.FEED, {
"user": username, "agent_name": feed_result['agent_name'],
"energy_restored": feed_result['actual_restore'],
"agent_energy": feed_result['agent_energy'], "user_gold": feed_result['user_gold'],
"message": f"{username} fed {feed_result['agent_name']}!"
})
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": feed_result["user_gold"]})
# VFX: Food Cloud
await self._broadcast_vfx("food", feed_result["agent_id"], "")
asyncio.create_task(self._trigger_agent_speak(
feed_result["agent_id"], feed_result["agent_name"],
feed_result["agent_personality"], feed_result["agent_hp"],
feed_result["agent_energy"], feed_result["agent_mood"],
f"User {username} gave you food!", "feed"
))
async def _handle_heal(self, username: str, agent_name: str) -> None:
"""Handle heal command."""
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
if user.gold < HEAL_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {HEAL_COST}, have {user.gold}"
})
return
user.gold -= HEAL_COST
old_hp = agent.hp
was_sick = agent.is_sick
agent.hp = min(100, agent.hp + HEAL_HP_RESTORE)
agent.is_sick = False # Cure sickness
msg = f"{username} healed {agent.name}!"
if was_sick:
msg = f"{username} cured {agent.name}'s sickness!"
await self._broadcast_event(EventType.HEAL, {
"user": username, "agent_name": agent.name,
"hp_restored": agent.hp - old_hp, "agent_hp": agent.hp,
"user_gold": user.gold, "message": msg
})
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
asyncio.create_task(self._trigger_agent_speak(
agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood,
f"User {username} healed you!", "heal"
))
async def _handle_encourage(self, username: str, agent_name: str) -> None:
"""Handle encourage command."""
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
if user.gold < ENCOURAGE_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {ENCOURAGE_COST}, have {user.gold}"
})
return
user.gold -= ENCOURAGE_COST
old_mood = agent.mood
agent.mood = min(100, agent.mood + ENCOURAGE_MOOD_BOOST)
await self._broadcast_event(EventType.ENCOURAGE, {
"user": username, "agent_name": agent.name,
"mood_boost": agent.mood - old_mood, "agent_mood": agent.mood,
"user_gold": user.gold, "message": f"{username} encouraged {agent.name}!"
})
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
asyncio.create_task(self._trigger_agent_speak(
agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood,
f"User {username} encouraged you!", "encourage"
))
async def _handle_love(self, username: str, agent_name: str) -> None:
"""Handle love command (hearts)."""
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
if user.gold < LOVE_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {LOVE_COST}, have {user.gold}"
})
return
user.gold -= LOVE_COST
old_mood = agent.mood
agent.mood = min(100, agent.mood + LOVE_MOOD_BOOST)
# Update relationship (Phase 5 integration: could add affection)
# For now just simple mood boost and FX
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
# VFX: Hearts
await self._broadcast_vfx("heart", agent.id, f"{username} sends love to {agent.name}!")
asyncio.create_task(self._trigger_agent_speak(
agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood,
f"User {username} sent you love!", "love"
))
async def _handle_talk(self, username: str, agent_name: str, topic: str = "") -> None:
"""Handle talk command - free conversation with agent."""
with get_db_session() as db:
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
agent_data = {
"id": agent.id, "name": agent.name, "personality": agent.personality,
"hp": agent.hp, "energy": agent.energy, "mood": agent.mood
}
# Generate conversation response
try:
response = await llm_service.generate_conversation_response(
agent_name=agent_data["name"],
agent_personality=agent_data["personality"],
agent_mood=agent_data["mood"],
username=username,
topic=topic or "just chatting"
)
await self._broadcast_event(EventType.TALK, {
"user": username, "agent_name": agent_data["name"],
"topic": topic, "response": response
})
except Exception as e:
logger.error(f"Error in talk: {e}")
async def _handle_revive(self, username: str, agent_name: str) -> None:
"""Handle revive command (casual mode)."""
config = self._get_config()
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status == "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is already alive!"})
return
if user.gold < REVIVE_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {REVIVE_COST}, have {user.gold}"
})
return
user.gold -= REVIVE_COST
agent.status = "Alive"
agent.hp = config.revive_hp
agent.energy = config.revive_energy
agent.mood = 50
agent.mood_state = "neutral"
agent.death_tick = None
await self._broadcast_event(EventType.REVIVE, {
"user": username, "agent_name": agent.name,
"user_gold": user.gold, "message": f"{username} revived {agent.name}!"
})
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
async def _handle_check(self, username: str) -> None:
"""Handle check/status command."""
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agents = db.query(Agent).all()
world = db.query(WorldState).first()
config = db.query(GameConfig).first()
user_data = {"username": user.username, "gold": user.gold}
agents_data = [agent.to_dict() for agent in agents]
world_data = world.to_dict() if world else {}
config_data = config.to_dict() if config else {}
await self._broadcast_event(EventType.CHECK, {
"user": user_data, "agents": agents_data,
"world": world_data, "config": config_data,
"message": f"{username}'s status - Gold: {user_data['gold']}"
})
async def _handle_reset(self, username: str) -> None:
"""Handle reset command - reset all agents."""
with get_db_session() as db:
agents = db.query(Agent).all()
for agent in agents:
agent.hp = 100
agent.energy = 100
agent.mood = 70
agent.mood_state = "neutral"
agent.status = "Alive"
agent.death_tick = None
world = db.query(WorldState).first()
if world:
world.day_count = 1
world.current_tick_in_day = 0
world.time_of_day = "day"
world.weather = "Sunny"
world.weather_duration = 0
await self._broadcast_event(EventType.SYSTEM, {
"message": f"{username} triggered a restart! All survivors have been revived."
})
await self._broadcast_agents_status()
async def process_comment(self, user: str, message: str) -> None:
"""Process a comment through command matching."""
await self._broadcast_event(EventType.COMMENT, {"user": user, "message": message})
# Match commands in priority order
if match := FEED_PATTERN.search(message):
await self._handle_feed(user, match.group(1))
return
if match := HEAL_PATTERN.search(message):
await self._handle_heal(user, match.group(1))
return
if match := TALK_PATTERN.search(message):
topic = match.group(2) or ""
await self._handle_talk(user, match.group(1), topic.strip())
return
if match := ENCOURAGE_PATTERN.search(message):
await self._handle_encourage(user, match.group(1))
return
if match := LOVE_PATTERN.search(message):
await self._handle_love(user, match.group(1))
return
if match := REVIVE_PATTERN.search(message):
await self._handle_revive(user, match.group(1))
return
if CHECK_PATTERN.search(message):
await self._handle_check(user)
return
if RESET_PATTERN.search(message):
await self._handle_reset(user)
return
# =========================================================================
# Random Events (Phase 17-C)
# =========================================================================
RANDOM_EVENTS = {
"storm_damage": {"weight": 30, "description": "A sudden storm damages the island!"},
"treasure_found": {"weight": 25, "description": "Someone found a buried treasure!"},
"beast_attack": {"weight": 20, "description": "A wild beast attacks the camp!"},
"rumor_spread": {"weight": 25, "description": "A rumor starts spreading..."},
}
async def _process_random_events(self) -> None:
"""Process random events (10% chance per day at dawn)."""
# Only trigger at dawn (once per day)
if self._tick_count % 100 != 1: # Roughly once every ~100 ticks
return
if random.random() > 0.10: # 10% chance
return
# Pick random event
events = list(self.RANDOM_EVENTS.keys())
weights = [self.RANDOM_EVENTS[e]["weight"] for e in events]
event_type = random.choices(events, weights=weights)[0]
with get_db_session() as db:
world = db.query(WorldState).first()
agents = db.query(Agent).filter(Agent.status == "Alive").all()
if not agents:
return
event_data = {"event_type": event_type, "message": ""}
if event_type == "storm_damage":
# All agents lose HP and resources depleted
for agent in agents:
agent.hp = max(0, agent.hp - 15)
if world:
world.tree_left_fruit = max(0, world.tree_left_fruit - 2)
world.tree_right_fruit = max(0, world.tree_right_fruit - 2)
event_data["message"] = "A violent storm hits! Everyone is injured and fruit trees are damaged."
elif event_type == "treasure_found":
# Random agent finds treasure (bonus herbs/medicine)
lucky = random.choice(agents)
inv = self._get_inventory(lucky)
inv["medicine"] = inv.get("medicine", 0) + 2
inv["herb"] = inv.get("herb", 0) + 3
self._set_inventory(lucky, inv)
event_data["message"] = f"{lucky.name} found a buried treasure with medicine and herbs!"
event_data["agent_name"] = lucky.name
elif event_type == "beast_attack":
# Random agent gets attacked
victim = random.choice(agents)
victim.hp = max(0, victim.hp - 25)
victim.mood = max(0, victim.mood - 20)
event_data["message"] = f"A wild beast attacked {victim.name}!"
event_data["agent_name"] = victim.name
elif event_type == "rumor_spread":
# Random relationship impact
if len(agents) >= 2:
a1, a2 = random.sample(list(agents), 2)
a1.mood = max(0, a1.mood - 10)
a2.mood = max(0, a2.mood - 10)
event_data["message"] = f"A rumor about {a1.name} and {a2.name} is spreading..."
await self._broadcast_event(EventType.RANDOM_EVENT, event_data)
logger.info(f"Random event triggered: {event_type}")
# =========================================================================
# Game loop
# =========================================================================
async def _game_loop(self) -> None:
"""The main game loop with all systems."""
logger.info("Game loop started - The Island awaits...")
await self._broadcast_agents_status()
await self._broadcast_world_status()
while self._running:
self._tick_count += 1
# Phase 9: Check voting phase (always runs)
await self._process_voting_tick()
# Phase 9: Check if we should trigger a narrative event
if await self._should_trigger_narrative():
await self._trigger_narrative_event()
# Skip simulation processing during narrative/voting/resolution modes
if self._game_mode != GameMode.SIMULATION:
await asyncio.sleep(self._tick_interval)
continue
# ========== SIMULATION MODE PROCESSING ==========
# 1. Advance time (Phase 2)
phase_change = await self._advance_time()
if phase_change:
await self._broadcast_event(EventType.PHASE_CHANGE, {
"old_phase": phase_change["old_phase"],
"new_phase": phase_change["new_phase"],
"day": phase_change["day"],
"message": f"The {phase_change['new_phase']} begins..."
})
# 2. Update weather (Phase 3)
weather_change = await self._update_weather()
if weather_change:
await self._broadcast_event(EventType.WEATHER_CHANGE, {
"old_weather": weather_change["old_weather"],
"new_weather": weather_change["new_weather"],
"message": f"Weather changed to {weather_change['new_weather']}"
})
# 3. Process survival (with difficulty modifiers)
await self._process_survival_tick()
# 4. Auto-revive check (casual mode)
await self._process_auto_revive()
# 5. Update moods (Phase 3)
await self._update_moods()
# Phase 24: Group Activities
# Check for campfire time (Night)
await self._process_campfire_gathering()
# Check for storytelling events
await self._process_group_activity()
# 6. Autonomous Activity (Phase 13)
await self._process_activity_tick()
# 7. Social interactions (Phase 5)
await self._process_social_tick()
# Phase 23: Altruism (Item Exchange)
await self._process_altruism_tick()
# 8. Random Events (Phase 17-C)
await self._process_random_events()
# 9. Clique Behavior (Phase 17-B)
await self._assign_social_roles()
await self._process_clique_behavior()
# 7. Idle chat
with get_db_session() as db:
alive_count = db.query(Agent).filter(Agent.status == "Alive").count()
if alive_count > 0 and random.random() < IDLE_CHAT_PROBABILITY:
asyncio.create_task(self._trigger_idle_chat())
# 8. Broadcast states
await self._broadcast_agents_status()
# Tick event
with get_db_session() as db:
world = db.query(WorldState).first()
day = world.day_count if world else 1
time_of_day = world.time_of_day if world else "day"
weather = world.weather if world else "Sunny"
await self._broadcast_event(EventType.TICK, {
"tick": self._tick_count,
"day": day,
"time_of_day": time_of_day,
"weather": weather,
"alive_agents": alive_count,
"game_mode": self._game_mode.value # Phase 9: Include game mode
})
await asyncio.sleep(self._tick_interval)
logger.info("Game loop stopped")
async def start(self) -> None:
"""Start the game engine."""
if self._running:
logger.warning("Engine already running")
return
logger.info("Initializing database...")
init_db()
self._seed_initial_data()
# Reload config
self._config = None
self._get_config()
self._running = True
asyncio.create_task(self._game_loop())
logger.info("Game engine started - The Island awaits...")
async def stop(self) -> None:
"""Stop the game engine."""
self._running = False
logger.info("Game engine stopping...")
async def process_command(self, user: str, text: str) -> None:
"""Process a command from Twitch chat."""
# Use the existing process_comment method to handle commands
await self.process_comment(user, text)
async def handle_gift(self, user: str, amount: int, gift_type: str = "bits") -> None:
"""
Handle a gift/donation (bits, subscription, or test).
Args:
user: Name of the donor
amount: Value of the gift
gift_type: Type of gift (bits, test, etc.)
"""
# 1. Add gold to user
gold_added = amount
with get_db_session() as db:
user_obj = self._get_or_create_user(db, user)
user_obj.gold += gold_added
await self._broadcast_event(EventType.USER_UPDATE, {
"user": user,
"gold": user_obj.gold,
"message": f"{user} received {gold_added} gold!"
})
# Check for alive agents for reaction
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
agent = random.choice(alive_agents) if alive_agents else None
# Extract data immediately to avoid DetachedInstanceError after session closes
agent_name = agent.name if agent else "Survivor"
agent_personality = agent.personality if agent else "friendly"
# 2. Generate AI gratitude
gratitude = await llm_service.generate_gratitude(
user=user,
amount=amount,
agent_name=agent_name,
agent_personality=agent_personality,
gift_name=gift_type
)
# 3. Store Memory (Phase 14)
if agent:
memory_text = f"User {user} gave me {amount} {gift_type}. I felt grateful."
await memory_service.add_memory(
agent_id=agent.id,
description=memory_text,
importance=random.randint(6, 9), # Gifts are important
related_entity_name=user,
memory_type="gift"
)
# 4. Broadcast gift effect to Unity
await self._broadcast_event("gift_effect", {
"user": user,
"gift_type": gift_type,
"value": amount,
"message": f"{user} sent {amount} {gift_type}!",
"agent_name": agent_name if agent else None,
"gratitude": gratitude,
"duration": 8.0
})
logger.info(f"Processed gift: {user} -> {amount} {gift_type} (Gratitude: {gratitude})")
async def process_bits(self, user: str, amount: int) -> None:
"""Deprecated: Use handle_gift instead."""
await self.handle_gift(user, amount, "bits")
async def _process_conversation_reply(
self, responder_id: int, responder_name: str, partner_id: int, partner_name: str,
previous_text: str, topic: str, should_reply_back: bool
) -> None:
"""Handle the secondary turn of a conversation."""
try:
# Relationship
with get_db_session() as db:
rel = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == responder_id,
AgentRelationship.agent_to_id == partner_id
).first()
rel_type = rel.relationship_type if rel else "acquaintance"
# Basic world info
world = db.query(WorldState).first()
weather = world.weather if world else "Sunny"
time_of_day = world.time_of_day if world else "day"
# Generate reply
# We use the same generate_social_interaction but with previous_dialogue set
# 'interaction_type' is reused as topic
reply = await llm_service.generate_social_interaction(
initiator_name=responder_name,
target_name=partner_name,
interaction_type=topic,
relationship_type=rel_type,
weather=weather,
time_of_day=time_of_day,
previous_dialogue=previous_text
)
# Broadcast response
await self._broadcast_event(EventType.SOCIAL_INTERACTION, {
"initiator_id": responder_id,
"initiator_name": responder_name,
"target_id": partner_id,
"target_name": partner_name,
"interaction_type": "reply",
"relationship_type": rel_type,
"dialogue": reply
})
# Chain next turn?
if should_reply_back:
self._active_conversations[partner_id] = {
"partner_id": responder_id,
"last_text": reply,
"topic": topic,
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
}
except Exception as e:
logger.error(f"Error in conversation reply: {e}")