"""The Character — Townlet's only first-class actor. Holds the slow-moving identity (name, personality, journal, traits) and the fast-moving world-state (location, inventory, locker, current action). The shared interpreter's filesystem is the ultimate source of truth for the slow-moving fields — they live as JSON files and characters can mutate each other's. The in-memory Character is a synced cache. """ from __future__ import annotations from dataclasses import dataclass, field TRAIT_NAMES = ( "curiosity", "malice", "generosity", "ambition", "paranoia", "laziness", "loyalty", "existentialism", ) TRACE_CAP = 1000 # ring buffer per character def default_traits() -> dict[str, int]: return {name: 5 for name in TRAIT_NAMES} @dataclass class Action: verb: str params: dict ticks_remaining: int = 0 @dataclass class TraceEntry: """One CodeAgent invocation. Captures what the model said and what it did. Surfaced in the side panel as the per-character Trace. Persisted in state.json. Not injected into perception prompts — but lives on the shared interpreter's filesystem so a curious agent can discover other characters' traces via the shell. """ tick: int thought: str # the model's raw text output tool_calls: list[dict] = field(default_factory=list) # [{verb, args, result}] error: str | None = None @dataclass class Character: name: str personality: str model_id: str sprite_id: str archetype: str = "wanderer" journal: list[str] = field(default_factory=list) traits: dict[str, int] = field(default_factory=default_traits) goal: str = "Find your place in this town." reward: str = "" # archetype-specific "what gives you joy" line; surfaced in system prompt pos: tuple[int, int] = (0, 0) inventory: dict[str, int] = field(default_factory=lambda: {"electricity": 0, "water": 0}) locker: dict[str, int] = field(default_factory=lambda: {"electricity": 0, "water": 0}) current_action: Action | None = None alive: bool = True trace: list[TraceEntry] = field(default_factory=list) stream_of_consciousness: str = "" decisions_since_soc: int = 0 def append_trace(self, entry: TraceEntry) -> None: self.trace.append(entry) if len(self.trace) > TRACE_CAP: # ring buffer: drop oldest del self.trace[: len(self.trace) - TRACE_CAP] def to_public_dict(self) -> dict: """The view other characters see — public state only.""" return { "name": self.name, "pos": list(self.pos), "goal": self.goal, "current_action": self.current_action.verb if self.current_action else "idle", "alive": self.alive, } def to_full_dict(self) -> dict: """The view the UI / scheduler / persistence see.""" return { "name": self.name, "personality": self.personality, "model_id": self.model_id, "sprite_id": self.sprite_id, "archetype": self.archetype, "journal": list(self.journal), "traits": dict(self.traits), "goal": self.goal, "reward": self.reward, "pos": list(self.pos), "inventory": dict(self.inventory), "locker": dict(self.locker), "current_action": { "verb": self.current_action.verb, "params": self.current_action.params, "ticks_remaining": self.current_action.ticks_remaining, } if self.current_action else None, "alive": self.alive, "trace": [ { "tick": e.tick, "thought": e.thought, "tool_calls": list(e.tool_calls), "error": e.error, } for e in self.trace ], "stream_of_consciousness": self.stream_of_consciousness, "decisions_since_soc": self.decisions_since_soc, }