#!/usr/bin/env python3 """ Deterministic game state engine. The LLM is responsible ONLY for narrative description. All world logic and outcomes are computed here and persisted per session. Scenario (Stage 1): - Single room - Locked door - Hidden key (revealed by searching) - Player must find key, add it to inventory, unlock and open the door to complete State bootstrap: - Loads canonical initial state from JSON in ./state: * items.json (key) * locks.json (door lock) * exits.json (door/exit) * room.json (room descriptors and references to ids) * containers.json (e.g., the loose flagstone that can hide/reveal the key) These files may cross-reference each other via ids; this class resolves them and produces a consolidated in-memory state used by the engine. """ from __future__ import annotations from dataclasses import dataclass, field from typing import List, Dict, Any, Optional from pathlib import Path import json @dataclass class GameState: # World facts room_description: str = ( "A dim stone chamber with worn flagstones and a heavy wooden door to the north. " "Dust gathers in the corners, and one flagstone near the center looks slightly loose." ) door_description: str = "A heavy wooden door reinforced with iron; its lock glints, unmoved for years." door_locked: bool = True door_open: bool = False door_id: Optional[int] = None lock_id: Optional[int] = None lock_key_id: Optional[int] = None # Key lifecycle key_description: str = "A brass key with a tarnished surface." key_hidden: bool = True # Not yet discoverable by name key_revealed: bool = False # Revealed by searching key_taken: bool = False key_id: Optional[int] = None # Container (e.g., loose flagstone) that may hide the key container_id: Optional[int] = None # Player inventory: List[str] = field(default_factory=list) # Exits metadata exposed to UI/LLM (direction -> {id,type}) exits: Dict[str, Any] = field(default_factory=dict) # Progress completed: bool = False def to_public_dict(self) -> Dict[str, Any]: """Minimal canonical state that the LLM may see (facts only).""" return { "room": { "description": self.room_description, "exits": {k: dict(v) for k, v in self.exits.items()}, }, "door": { "id": self.door_id, "description": self.door_description, "locked": self.door_locked, "open": self.door_open, "lock_id": self.lock_id, "key_id": self.lock_key_id, }, "key": { "id": self.key_id, "description": self.key_description, "revealed": self.key_revealed, "taken": self.key_taken, }, "inventory": list(self.inventory), "completed": self.completed, } # ---------- Bootstrap from JSON files ---------- @classmethod def from_files(cls, state_dir: str | Path = "state") -> "GameState": """ Create a GameState initialized from JSON files in state_dir. Files are optional; sensible defaults are used if missing. Expected files: - items.json: key item (id, type, description, hidden, revealed, taken) - locks.json: door lock (id, type, description, locked, open, key_id) - exits.json: door/exit (id, type, description, lock_id, open/locked overrides) - room.json: room (id, type, description, exits {dir: exit_id}, items [ids], containers [ids]) - containers.json: container that can hide/reveal item (id, hidden, revealed, openable, open, description) """ base = Path(state_dir) def _load_json(p: Path) -> Dict[str, Any]: try: if p.exists() and p.stat().st_size > 0: with p.open("r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else {} except Exception: pass return {} items = _load_json(base / "items.json") locks = _load_json(base / "locks.json") exits = _load_json(base / "exits.json") room = _load_json(base / "room.json") containers = _load_json(base / "containers.json") # Resolve room description default_room = ( "A dim stone chamber with worn flagstones and a heavy wooden door to the north. " "Dust gathers in the corners, and one flagstone near the center looks slightly loose." ) room_description = str(room.get("description", default_room)) if isinstance(room, dict) else default_room # Resolve exits metadata from room references (direction -> exit_id) exits_meta: Dict[str, Any] = {} if isinstance(room, dict) and isinstance(room.get("exits"), dict): for direction, ex_id in room["exits"].items(): exits_meta[direction] = {"id": ex_id, "type": (exits.get("type", "door") if isinstance(exits, dict) else "door")} # Resolve door description/flags. Prefer locks.json, fallback to exits.json door_description = ( str(locks.get("description")) if isinstance(locks, dict) and "description" in locks else (str(exits.get("description")) if isinstance(exits, dict) and "description" in exits else "A heavy wooden door reinforced with iron; its lock glints, unmoved for years.") ) door_locked = bool(locks.get("locked", True)) if isinstance(locks, dict) else bool(exits.get("locked", True)) if isinstance(exits, dict) else True door_open = bool(locks.get("open", False)) if isinstance(locks, dict) else bool(exits.get("open", False)) if isinstance(exits, dict) else False door_id = int(exits.get("id")) if isinstance(exits, dict) and "id" in exits else None lock_id = int(locks.get("id")) if isinstance(locks, dict) and "id" in locks else (int(exits.get("lock_id")) if isinstance(exits, dict) and "lock_id" in exits else None) lock_key_id = int(locks.get("key_id")) if isinstance(locks, dict) and "key_id" in locks else None # Resolve key description/flags and ids key_description = str(items.get("description", "A brass key with a tarnished surface.")) if isinstance(items, dict) else "A brass key with a tarnished surface." key_hidden = bool(items.get("hidden", True)) if isinstance(items, dict) else True key_revealed = bool(items.get("revealed", False)) if isinstance(items, dict) else False key_taken = bool(items.get("taken", False)) if isinstance(items, dict) else False key_id = int(items.get("id")) if isinstance(items, dict) and "id" in items else None # Container influence (if the referenced container houses the key) container_id = None if isinstance(room, dict) and isinstance(room.get("containers"), list) and len(room["containers"]) > 0: container_id = room["containers"][0] if isinstance(containers, dict): # If a single container is defined and either not referenced or the id matches, merge visibility flags if container_id is None or containers.get("id") == container_id: container_hidden = bool(containers.get("hidden", False)) container_revealed = bool(containers.get("revealed", False)) # Hidden if either marks hidden and not revealed yet key_hidden = (key_hidden or container_hidden) and not (key_revealed or container_revealed) key_revealed = key_revealed or container_revealed return cls( room_description=room_description, door_description=door_description, door_locked=door_locked, door_open=door_open, door_id=door_id, lock_id=lock_id, lock_key_id=lock_key_id, key_description=key_description, key_hidden=key_hidden, key_revealed=key_revealed, key_taken=key_taken, key_id=key_id, container_id=container_id, exits=exits_meta, ) # ------------- Intent handling ------------- def apply_action(self, user_text: str) -> Dict[str, Any]: """ Parse a user action, update state deterministically, and return an ENGINE_OUTCOME suitable for feeding into the LLM narrator. Returns: dict with: - events: List[str] describing factual outcomes (not narrative prose) """ text = (user_text or "").strip().lower() events: List[str] = [] if not text: return {"events": ["No action provided."]} # Simple keyword intent parsing def has(*words: str) -> bool: return all(w in text for w in words) # Inventory check if has("inventory") or has("items") or has("bag"): inv = ", ".join(self.inventory) if self.inventory else "empty" events.append(f"Inventory checked; current items: {inv}.") return {"events": events} # Look/examine room if has("look") or has("examine") or has("observe") or has("describe"): events.append("Player surveys the room; no state change.") return {"events": events} # Search actions reveal the key (once) if has("search") or has("inspect") or has("check") or has("look around") or has("look closely"): if not self.key_revealed and self.key_hidden: self.key_revealed = True self.key_hidden = False events.append("A small brass key is revealed beneath a loose flagstone.") else: events.append("Search performed; nothing new is revealed.") return {"events": events} # Take/pick up the key if ("key" in text) and (has("take") or has("pick") or has("grab") or has("get")): if not self.key_revealed and not self.key_taken: events.append("Key not visible; cannot take what has not been revealed.") elif self.key_taken: events.append("Key already in inventory; no change.") else: self.key_taken = True if "brass key" not in self.inventory: self.inventory.append("brass key") events.append("Player picks up the brass key and adds it to inventory.") return {"events": events} # Unlock door with key if ("door" in text) and (has("unlock") or has("use key") or (has("use") and "key" in text)): if self.door_open: events.append("Door is already open; unlocking unnecessary.") elif not self.key_taken: events.append("Player lacks the brass key; door remains locked.") elif not self.door_locked: events.append("Door already unlocked; no change.") elif (self.lock_key_id is not None and self.key_id is not None and self.lock_key_id != self.key_id): events.append("The brass key does not fit this lock; the door remains locked.") else: self.door_locked = False events.append("Door is unlocked with the brass key.") return {"events": events} # Open door / go through door if ("door" in text) and (has("open") or has("go through") or has("enter")): if self.door_open: events.append("Door is already open; state unchanged.") elif self.door_locked: events.append("Door is locked; opening fails.") else: self.door_open = True self.completed = True events.append("Door is opened and the player exits the chamber. Scenario complete.") return {"events": events} # Use key on door (explicit phrasing) if has("use", "key") and ("door" in text): if not self.key_taken: events.append("Player attempts to use a key they do not have.") elif self.door_locked and (self.lock_key_id is not None and self.key_id is not None and self.lock_key_id != self.key_id): events.append("The brass key does not fit this lock; the door remains locked.") elif self.door_locked: self.door_locked = False events.append("Door is unlocked with the brass key.") else: events.append("Door already unlocked; no change.") return {"events": events} # Fallback: no matching intent events.append("No recognized action; state unchanged.") return {"events": events}