| """ |
| WorldState is the ground truth of the game world. |
| The LLM can never contradict what is recorded here. |
| All mutations go through update() ensuring no direct assignment. |
| """ |
|
|
| from dataclasses import dataclass, field |
| from typing import Optional |
| import json |
| import copy |
|
|
|
|
| @dataclass |
| class Item: |
| id: str |
| name: str |
| description: str |
| location: str |
| properties: dict = field(default_factory=dict) |
|
|
|
|
| @dataclass |
| class MemoryEntry: |
| """ |
| A single episodic memory belonging to an NPC. |
| |
| significance: 1-3 |
| 1 = minor (player asked about the weather) |
| 2 = notable (player helped or insulted them) |
| 3 = significant (player attacked, betrayed, or saved them) |
| """ |
| turn: int |
| description: str |
| emotional_tone: str |
| significance: int = 1 |
|
|
|
|
| @dataclass |
| class NPC: |
| id: str |
| name: str |
| description: str |
| location: str |
| disposition: str |
| alive: bool = True |
| inventory: list[str] = field(default_factory=list) |
| memories: list[MemoryEntry] = field(default_factory=list) |
|
|
| def relevant_memories(self, max_memories: int = 5) -> list[MemoryEntry]: |
| """ |
| Return the most relevant memories for prompt injection. |
| Priority: significant memories first, then most recent. |
| Capped to avoid bloating the prompt. |
| """ |
| sorted_memories = sorted( |
| self.memories, |
| key=lambda m: (m.significance, m.turn), |
| reverse=True |
| ) |
| return sorted_memories[:max_memories] |
|
|
| def memory_summary(self) -> str: |
| """ |
| Compact string for prompt injection. |
| Only called when this NPC is in the current location. |
| """ |
| relevant = self.relevant_memories() |
| if not relevant: |
| return f"{self.name} has no prior interactions with the player." |
|
|
| lines = [f"{self.name}'s memory of the player:"] |
| for m in relevant: |
| lines.append(f" - Turn {m.turn} ({m.emotional_tone}): {m.description}") |
| return "\n".join(lines) |
|
|
| @dataclass |
| class Location: |
| id: str |
| name: str |
| description: str |
| exits: dict[str, str] |
| items: list[str] = field(default_factory=list) |
| npcs: list[str] = field(default_factory=list) |
| visited: bool = False |
|
|
|
|
| @dataclass |
| class Player: |
| location: str |
| inventory: list[str] = field(default_factory=list) |
| health: int = 100 |
| known_facts: list[str] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class WorldState: |
| """ |
| Single source of truth for the entire game world. |
| """ |
| locations: dict[str, Location] = field(default_factory=dict) |
| npcs: dict[str, NPC] = field(default_factory=dict) |
| items: dict[str, Item] = field(default_factory=dict) |
| player: Player = field(default_factory=lambda: Player(location="start")) |
| turn: int = 0 |
| world_name: str = "Unknown World" |
| history: list[dict] = field(default_factory=list) |
|
|
| def current_location(self) -> Optional[Location]: |
| return self.locations.get(self.player.location) |
|
|
| def items_in_location(self, location_id: str) -> list[Item]: |
| loc = self.locations.get(location_id) |
| if not loc: |
| return [] |
| return [self.items[i] for i in loc.items if i in self.items] |
|
|
| def npcs_in_location(self, location_id: str) -> list[NPC]: |
| loc = self.locations.get(location_id) |
| if not loc: |
| return [] |
| return [self.npcs[n] for n in loc.npcs if n in self.npcs] |
|
|
| def player_inventory_items(self) -> list[Item]: |
| return [self.items[i] for i in self.player.inventory if i in self.items] |
|
|
| def to_context_summary(self) -> str: |
| """ |
| Produces a structured context block for the LLM prompt. |
| Includes NPC episodic memories when NPCs are present in the scene. |
| """ |
| loc = self.current_location() |
| if not loc: |
| return "ERROR: Player location not found." |
|
|
| loc_items = self.items_in_location(self.player.location) |
| loc_npcs = self.npcs_in_location(self.player.location) |
| player_items = self.player_inventory_items() |
|
|
| exits_str = ", ".join( |
| f"{d} → {self.locations[lid].name}" |
| for d, lid in loc.exits.items() |
| if lid in self.locations |
| ) or "none" |
|
|
| items_str = ", ".join(f"{i.name} [id:{i.id}]" for i in loc_items) or "none" |
|
|
| |
| npc_lines = [] |
| for n in loc_npcs: |
| status = "alive" if n.alive else "dead" |
| npc_lines.append(f"{n.name} [id:{n.id}] ({n.disposition}, {status})") |
| npcs_str = ", ".join(npc_lines) or "none" |
|
|
| inv_str = ", ".join(f"{i.name} [id:{i.id}]" for i in player_items) or "nothing" |
|
|
| |
| memory_block = "" |
| if loc_npcs: |
| memory_lines = [] |
| for npc in loc_npcs: |
| if npc.alive: |
| memory_lines.append(f"[id:{npc.id}] {npc.memory_summary()}") |
| if memory_lines: |
| memory_block = "\nNPC MEMORIES (what they remember about the player):\n" |
| memory_block += "\n".join(memory_lines) |
|
|
| return f"""WORLD STATE (TURN {self.turn}) — THESE FACTS CANNOT BE CONTRADICTED: |
| Location: {loc.name} |
| Description: {loc.description} |
| Exits: {exits_str} |
| Items here: {items_str} |
| NPCs here: {npcs_str} |
| Player health: {self.player.health}/100 |
| Player inventory: {inv_str}{memory_block} |
| """ |
|
|
|
|
| def to_player_summary(self) -> str: |
| """ |
| Snapshot for the UI. |
| """ |
| loc = self.current_location() |
| if not loc: |
| return "Location unknown." |
|
|
| loc_items = self.items_in_location(self.player.location) |
| loc_npcs = self.npcs_in_location(self.player.location) |
| player_items = self.player_inventory_items() |
|
|
| exits = ", ".join( |
| f"{d} to {self.locations[lid].name}" |
| for d, lid in loc.exits.items() |
| if lid in self.locations |
| ) or "none" |
|
|
| items = ", ".join(i.name for i in loc_items) or "nothing" |
| npcs = ", ".join( |
| f"{n.name} ({n.disposition})" |
| for n in loc_npcs if n.alive |
| ) or "nobody" |
|
|
| visited = [ |
| loc.name for loc_id, loc in self.locations.items() |
| if loc.visited and loc_id != self.player.location |
| ] |
|
|
| visited_str = ", ".join(visited) if visited else "nowhere yet" |
|
|
| carrying = ", ".join(i.name for i in player_items) or "nothing" |
|
|
| facts = "" |
| if self.player.known_facts: |
| facts = "\n\nThings you know:\n" + "\n".join( |
| f" {f}" for f in self.player.known_facts |
| ) |
|
|
| return ( |
| f"Turn {self.turn}\n\n" |
| f"You are in {loc.name}.\n" |
| f"{loc.description}\n\n" |
| f"You can go: {exits}\n" |
| f"You can see: {items}\n" |
| f"Present: {npcs}\n" |
| f"You have visited: {visited_str}\n" |
| f"You are carrying: {carrying}" |
| f"{facts}" |
| ) |
|
|
|
|
| def to_map_summary(self) -> str: |
| """ |
| Map of visited locations only, for GM route planning. |
| Unvisited locations stay hidden until the player discovers them. |
| """ |
| lines = ["KNOWN MAP (locations the player has visited):"] |
| for loc_id, loc in self.locations.items(): |
| if not loc.visited: |
| continue |
| exits = ", ".join( |
| f"{direction} → {self.locations[lid].name}" |
| for direction, lid in loc.exits.items() |
| if lid in self.locations |
| ) or "no exits" |
| lines.append(f" {loc.name}: {exits}") |
|
|
| if len(lines) == 1: |
| return "KNOWN MAP: nowhere explored yet beyond current location." |
| return "\n".join(lines) |
|
|
|
|
| def apply_update(self, update: dict) -> list[str]: |
| """ |
| Apply a structured update from the GM agent. |
| Returns a list of change descriptions for logging. |
| Validates before applying and rejects invalid transitions. |
| """ |
| changes = [] |
|
|
| |
| if "move_player" in update: |
| dest = update["move_player"] |
|
|
| |
| directions = dest if isinstance(dest, list) else [dest] |
|
|
| |
| |
| if len(directions) > 1: |
| |
| simulated_loc = self.player.location |
| valid_directions = [] |
| for direction in directions: |
| loc = self.locations.get(simulated_loc) |
| if loc and direction in loc.exits and loc.exits[direction] in self.locations: |
| next_loc_id = loc.exits[direction] |
| next_loc = self.locations[next_loc_id] |
| if not next_loc.visited and next_loc_id != self.player.location: |
| |
| valid_directions.append(direction) |
| break |
| valid_directions.append(direction) |
| simulated_loc = next_loc_id |
| else: |
| break |
| directions = valid_directions if valid_directions else directions[:1] |
|
|
| for direction in directions: |
| loc = self.current_location() |
| if direction in loc.exits and loc.exits[direction] in self.locations: |
| new_loc_id = loc.exits[direction] |
| self.player.location = new_loc_id |
| self.locations[new_loc_id].visited = True |
| changes.append(f"Player moved to {self.locations[new_loc_id].name}") |
| else: |
| changes.append(f"REJECTED move to {direction} — not a valid exit") |
| break |
|
|
| |
| if "pickup_item" in update: |
| item_id = update["pickup_item"] |
| loc = self.current_location() |
| if item_id in loc.items and item_id in self.items: |
| loc.items.remove(item_id) |
| self.player.inventory.append(item_id) |
| changes.append(f"Player picked up {self.items[item_id].name}") |
| else: |
| changes.append(f"REJECTED pickup {item_id} — not in current location") |
|
|
| |
| if "drop_item" in update: |
| item_id = update["drop_item"] |
| if item_id in self.player.inventory: |
| self.player.inventory.remove(item_id) |
| self.current_location().items.append(item_id) |
| changes.append(f"Player dropped {self.items[item_id].name}") |
|
|
| |
| if "npc_state" in update: |
| for npc_id, new_state in update["npc_state"].items(): |
| if npc_id in self.npcs: |
| npc = self.npcs[npc_id] |
| if "alive" in new_state: |
| npc.alive = new_state["alive"] |
| if "disposition" in new_state: |
| npc.disposition = new_state["disposition"] |
| changes.append(f"NPC {npc.name} state updated: {new_state}") |
|
|
| |
| if "npc_memory" in update: |
| for npc_id, memory_data in update["npc_memory"].items(): |
| if npc_id in self.npcs: |
| npc = self.npcs[npc_id] |
| entry = MemoryEntry( |
| turn=self.turn, |
| description=memory_data.get("description", ""), |
| emotional_tone=memory_data.get("emotional_tone", "neutral"), |
| significance=int(memory_data.get("significance", 1)), |
| ) |
| npc.memories.append(entry) |
| changes.append( |
| f"{npc.name} remembers: '{entry.description}' " |
| f"[{entry.emotional_tone}, significance {entry.significance}]" |
| ) |
|
|
| |
| if "player_health" in update: |
| delta = update["player_health"] |
| self.player.health = max(0, min(100, self.player.health + delta)) |
| changes.append(f"Player health: {self.player.health}/100") |
|
|
| |
| if "add_fact" in update: |
| self.player.known_facts.append(update["add_fact"]) |
| changes.append(f"Fact learned: {update['add_fact']}") |
|
|
| self.turn += 1 |
| return changes |
|
|
| def to_json(self) -> str: |
| return json.dumps(self, default=lambda o: o.__dict__, indent=2) |
|
|
| @classmethod |
| def from_json(cls, data: str) -> "WorldState": |
| raw = json.loads(data) |
| |
| state = cls() |
| state.world_name = raw.get("world_name", "Unknown") |
| state.turn = raw.get("turn", 0) |
| state.history = raw.get("history", []) |
| state.player = Player(**raw["player"]) |
| state.locations = {} |
| for k, v in raw["locations"].items(): |
| loc_data = dict(v) |
| loc_data.setdefault("visited", False) |
| loc_data.setdefault("items", []) |
| loc_data.setdefault("npcs", []) |
| state.locations[k] = Location(**loc_data) |
| state.npcs = {} |
| for k, v in raw["npcs"].items(): |
| npc_data = dict(v) |
| |
| raw_memories = npc_data.pop("memories", []) |
| npc = NPC(**npc_data) |
| npc.memories = [MemoryEntry(**m) for m in raw_memories] |
| state.npcs[k] = npc |
| state.items = {k: Item(**v) for k, v in raw["items"].items()} |
| return state |
|
|