Files
text-adventure-llm/game_state.py
Aodhan Collins 912b205699 Initial commit.
Basic docker deployment with Local LLM integration and simple game state.
2025-08-17 19:31:33 +01:00

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}