Spaces:
Running
Running
| """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 | |
| 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 | |
| def alive(self) -> bool: | |
| return self.hp > 0 | |
| 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" | |
| 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 | |
| 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) | |