"""The authoritative game state. The whole point of the engine is that *this* is the source of truth, not the language model. The model proposes changes; `GameState` (via the parser) decides what actually happens, clamping every value to a legal range. A small model can hallucinate "you now have 9000 HP" — the state will not let it. """ from __future__ import annotations from dataclasses import dataclass, field, asdict from typing import Optional import json MAX_HP_CAP = 999 MAX_INVENTORY = 24 @dataclass class Enemy: """An enemy currently in combat. `None` on the state means no active fight.""" name: str hp: int max_hp: int attack: int = 3 @property def alive(self) -> bool: return self.hp > 0 @dataclass class NPC: """A named character the model has introduced. Persisted so the model can be reminded who exists — this is a big lever for consistency.""" name: str role: str = "" # "blacksmith", "old hermit", ... disposition: str = "neutral" # friendly / neutral / hostile note: str = "" # one-line memory, e.g. "owes you a favour" @dataclass class GameState: # --- vitals --- hp: int = 20 max_hp: int = 20 gold: int = 10 level: int = 1 xp: int = 0 # --- world --- location: str = "The Crossroads" inventory: list[str] = field(default_factory=lambda: ["Rusty Dagger", "Bread"]) npcs: dict[str, NPC] = field(default_factory=dict) quest: str = "Discover why the village of Mossfall fell silent." # --- combat (None when not fighting) --- enemy: Optional[Enemy] = None # --- meta --- turn: int = 0 game_over: bool = False log: list[str] = field(default_factory=list) # ------------------------------------------------------------------ vitals def damage(self, amount: int) -> None: amount = max(0, int(amount)) self.hp = max(0, self.hp - amount) if self.hp == 0: self.game_over = True def heal(self, amount: int) -> None: amount = max(0, int(amount)) self.hp = min(self.max_hp, self.hp + amount) def add_gold(self, amount: int) -> None: self.gold = max(0, self.gold + int(amount)) def add_xp(self, amount: int) -> None: self.xp += max(0, int(amount)) # simple, legible leveling curve: 10 * level to advance while self.xp >= self.level * 10: self.xp -= self.level * 10 self.level += 1 self.max_hp = min(MAX_HP_CAP, self.max_hp + 5) self.hp = self.max_hp # full heal on level-up self.log.append(f"LEVEL UP → {self.level} (max HP {self.max_hp})") # --------------------------------------------------------------- inventory def add_item(self, item: str) -> bool: item = item.strip() if not item or len(self.inventory) >= MAX_INVENTORY: return False self.inventory.append(item) return True def remove_item(self, item: str) -> bool: item = item.strip().lower() for i, owned in enumerate(self.inventory): if owned.lower() == item: self.inventory.pop(i) return True return False def has_item(self, item: str) -> bool: item = item.strip().lower() return any(owned.lower() == item for owned in self.inventory) # --------------------------------------------------------------------- npc def upsert_npc(self, npc: NPC) -> None: key = npc.name.strip().lower() if not key: return if key in self.npcs: # merge: keep old note unless a new non-empty one is given old = self.npcs[key] old.role = npc.role or old.role old.disposition = npc.disposition or old.disposition old.note = npc.note or old.note else: self.npcs[key] = npc # ------------------------------------------------------------------ combat def start_combat(self, enemy: Enemy) -> None: self.enemy = enemy def end_combat(self) -> None: self.enemy = None # ----------------------------------------------------------- (de)serialize def to_dict(self) -> dict: d = asdict(self) return d @classmethod def from_dict(cls, d: dict) -> "GameState": d = dict(d) if d.get("enemy"): d["enemy"] = Enemy(**d["enemy"]) if d.get("npcs"): d["npcs"] = {k: NPC(**v) for k, v in d["npcs"].items()} return cls(**d) def to_json(self) -> str: return json.dumps(self.to_dict(), ensure_ascii=False, indent=2) # ------------------------------------------------------------ for the LLM def context_snapshot(self) -> str: """A compact, human-readable snapshot fed to the model every turn so it never has to remember the numbers itself.""" lines = [ f"HP: {self.hp}/{self.max_hp}", f"Level: {self.level} XP: {self.xp}/{self.level * 10}", f"Gold: {self.gold}", f"Location: {self.location}", f"Inventory: {', '.join(self.inventory) if self.inventory else '(empty)'}", f"Current quest: {self.quest}", ] if self.enemy and self.enemy.alive: e = self.enemy lines.append(f"IN COMBAT with {e.name} ({e.hp}/{e.max_hp} HP, atk {e.attack})") if self.npcs: known = "; ".join( f"{n.name} ({n.role or n.disposition})" for n in self.npcs.values() ) lines.append(f"Known characters: {known}") return "\n".join(lines)