285 lines
13 KiB
Python
285 lines
13 KiB
Python
#!/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} |