| """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 = "" |
| disposition: str = "neutral" |
| note: str = "" |
|
|
|
|
| @dataclass |
| class GameState: |
| |
| hp: int = 20 |
| max_hp: int = 20 |
| gold: int = 10 |
| level: int = 1 |
| xp: int = 0 |
|
|
| |
| 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." |
|
|
| |
| enemy: Optional[Enemy] = None |
|
|
| |
| turn: int = 0 |
| game_over: bool = False |
| log: list[str] = field(default_factory=list) |
|
|
| |
| 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)) |
| |
| 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 |
| self.log.append(f"LEVEL UP → {self.level} (max HP {self.max_hp})") |
|
|
| |
| 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) |
|
|
| |
| def upsert_npc(self, npc: NPC) -> None: |
| key = npc.name.strip().lower() |
| if not key: |
| return |
| if key in self.npcs: |
| |
| 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 |
|
|
| |
| def start_combat(self, enemy: Enemy) -> None: |
| self.enemy = enemy |
|
|
| def end_combat(self) -> None: |
| self.enemy = None |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|