micro-rpg-engine / engine /game_state.py
luizbarbedo's picture
Upload folder using huggingface_hub
7fe39f3 verified
Raw
History Blame Contribute Delete
5.68 kB
"""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)