Initial commit.
Basic docker deployment with Local LLM integration and simple game state.
This commit is contained in:
285
game_state.py
Normal file
285
game_state.py
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/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}
|
||||
Reference in New Issue
Block a user