from __future__ import annotations import json from dataclasses import asdict, dataclass, field from typing import Any, Literal GoalType = Literal[ "survive", "pursue_and_attack_target", "retaliate", "call_for_help", "help_or_defend", "respond_to_event", "investigate", "find_food", "routine_life", ] CitizenMode = Literal[ "routine", "threatened", "hostile_pursuit", "helping", "investigating", "fleeing", "recovering", ] NpcRole = Literal["guard", "builder", "gatherer"] HouseState = Literal["under_construction", "completed", "destroyed"] GameStatus = Literal["running", "win_population", "win_survival", "lose"] @dataclass(frozen=True, slots=True) class Vec3: x: float y: float z: float @dataclass(slots=True) class MemoryEntry: tick: int text: str @dataclass(slots=True) class MemoryEpisode: tick: int kind: str actor_id: str target_id: str | None object_id: str | None subject_kind: str perspective: str summary: str emotional_weight: float = 0.5 tags: list[str] = field(default_factory=list) @dataclass(slots=True) class NpcMemory: episodes: list[MemoryEpisode] = field(default_factory=list) facts: list[str] = field(default_factory=list) @dataclass(slots=True) class NpcConversationContext: messages: list[dict[str, str]] = field(default_factory=list) max_turns: int = 5 def add_turn(self, user_content: str, assistant_content: str) -> None: self.messages.append({"role": "user", "content": user_content}) self.messages.append({"role": "assistant", "content": assistant_content}) max_messages = self.max_turns * 2 if len(self.messages) > max_messages: self.messages = self.messages[-max_messages:] @dataclass(slots=True) class CitizenNeeds: safety: int = 20 food: int = 25 social: int = 30 status: int = 30 curiosity: int = 35 rest: int = 20 @dataclass(slots=True) class CitizenEmotions: fear: int = 0 anger: int = 0 trust_baseline: int = 50 stress: int = 0 @dataclass(slots=True) class CitizenGoal: goal_type: GoalType priority: int reason: str target_npc_id: str | None = None target_entity_id: str | None = None expires_at_tick: int | None = None @dataclass(slots=True) class Npc: id: str name: str role: str position: Vec3 health: int = 100 attack_damage: int = 10 personality: str = "neutral" god_directive: str | None = None directive_source: str | None = None directive_issued_tick: int | None = None directive_ttl_ticks: int | None = None intention: str = "idle" # Latest model chain-of-thought ("thinking") and the tick it was produced on, # surfaced to the UI. Empty for non-thinking turns. last_reasoning: str | None = None last_reasoning_tick: int | None = None memory_summary: str | None = None # True once the NPC has written its OWN memory_summary (the model owns it). # While set, the engine never appends raw "Tick N: ..." lines to it. memory_summary_self_authored: bool = False memory: list[MemoryEntry] = field(default_factory=list) structured_memory: NpcMemory = field(default_factory=NpcMemory) conversation_context: NpcConversationContext = field(default_factory=NpcConversationContext) relationships: dict[str, float] = field(default_factory=dict) needs: CitizenNeeds = field(default_factory=CitizenNeeds) emotions: CitizenEmotions = field(default_factory=CitizenEmotions) current_goal: CitizenGoal | None = None mode: CitizenMode = "routine" # Survival core-loop state (deterministic, engine-owned). hunger: float = 25.0 fear: float = 0.0 safety: float = 0.0 age: int = 0 max_age: int = 400 reproduction_cooldown: int = 0 importance: float = 1.0 survival_goal: str = "routine_life" inventory_food: int = 0 inventory_herbs: int = 0 inventory_wood: int = 0 inventory_weapon: int = 0 inventory_coins: int = 0 help_target_id: str | None = None # Whom the NPC is currently engaging (talk/attack/trade); drives facing in the renderer. focus_target_id: str | None = None home_house_id: str | None = None build_target_house_id: str | None = None patrol_index: int = 0 resources_transferred: int = 0 beasts_killed: int = 0 children_count: int = 0 houses_built: int = 0 # Set when this NPC is controlled by an external agent over MCP rather than # by the built-in deterministic/LLM planner. is_player: bool = False player_owner: str | None = None country_id: str | None = None special_status: str | None = None model_profile_id: str | None = None # When true, the engine does not role-gate this NPC's actions: it may take # any action regardless of role. Player NPCs set this, but it can also be # given to special non-player NPCs. unrestricted_actions: bool = False # Routes this NPC to a named connector (from secondary_connectors in config). # None means the default connector handles this NPC. connector_id: str | None = None @dataclass(slots=True) class ResourceNode: id: str resource_type: str # "food" | "herbs" | "wood" | "weapon" position: Vec3 amount: int max_amount: int = 0 # regrowth cap; 0 means "no regrowth" (set to amount at spawn) @dataclass(slots=True) class Beast: id: str position: Vec3 health: float = 180.0 damage: float = 12.0 speed: float = 1.0 # in tiles per tick target_npc_id: str | None = None target_house_id: str | None = None retreat_ticks: int = 0 state: str = "hunting" # hunting | attacking | retreating | dead @dataclass(slots=True) class House: id: str position: Vec3 owner_ids: list[str] = field(default_factory=list) hp: float = 60.0 max_hp: float = 60.0 state: HouseState = "completed" build_progress: int = 10 capacity: int = 3 occupant_ids: list[str] = field(default_factory=list) @dataclass(slots=True) class TreasuryState: id: str position: Vec3 resources: dict[str, int] = field(default_factory=dict) @dataclass(slots=True) class CannonState: id: str position: Vec3 operator_id: str | None = None cooldown_until_tick: int = 0 damage: int = 35 radius: int = 5 @dataclass(slots=True) class CountryState: id: str name: str color: str badge: str citizen_ids: list[str] = field(default_factory=list) ruler_id: str | None = None policy: str = "Gather resources, protect citizens, and avoid reckless war." treasury: TreasuryState | None = None cannon: CannonState | None = None next_election_tick: int = 50 @dataclass(slots=True) class ElectionState: country_id: str start_tick: int end_tick: int candidate_ids: list[str] votes: dict[str, str] = field(default_factory=dict) completed: bool = False @dataclass(slots=True) class WorldLogEvent: tick: int type: str summary: str severity: Literal["good", "neutral", "warning", "danger"] = "neutral" actor_id: str | None = None target_id: str | None = None object_id: str | None = None @dataclass(slots=True) class Terrain: kind: Literal["plain_green"] width: int depth: int color: str = "#43a047" @dataclass(slots=True) class WorldEvent: tick_created: int kind: str description: str position: Vec3 | None radius: float = 8.0 duration_ticks: int = 5 @dataclass(slots=True) class WorldState: tick: int seed: int terrain: Terrain npcs: list[Npc] last_tick_source: str = "initial" last_action_debug: list[dict[str, Any]] = field(default_factory=list) resource_nodes: list[ResourceNode] = field(default_factory=list) beasts: list[Beast] = field(default_factory=list) houses: list[House] = field(default_factory=list) active_events: list[WorldEvent] = field(default_factory=list) event_log: list[WorldLogEvent] = field(default_factory=list) countries: list[CountryState] = field(default_factory=list) elections: list[ElectionState] = field(default_factory=list) game_status: GameStatus = "running" population: int = 0 population_cap: int = 12 total_births: int = 0 deaths_by_cause: dict[str, int] = field(default_factory=dict) houses_built: int = 0 beasts_killed: int = 0 peak_population: int = 0 overseer_score: int = 0 chaos_score: int = 0 chaos_intervention_until: int = -1 famine_until: int = -1 famine_saved_max: dict[str, int] = field(default_factory=dict) overseer_mode: Literal["off", "advisor", "autopilot"] = "off" overseer_cycle_ticks: int = 8 overseer_last_tick: int | None = None overseer_last_thoughts: str | None = None overseer_priority_alert: str | None = None overseer_last_directives: list[dict[str, Any]] = field(default_factory=list) overseer_status: str = "idle" def living_npcs(self) -> list[Npc]: return [npc for npc in self.npcs if npc.health > 0] def to_dict(self) -> dict[str, Any]: return asdict(self) def to_json(self, *, indent: int | None = None) -> str: return json.dumps(self.to_dict(), indent=indent, sort_keys=True)