import json import logging from datetime import datetime, timedelta from typing import Any from llm_runtime import llm, _normalize_llm_output, _format_prompt from langchain_core.messages import HumanMessage, SystemMessage logger = logging.getLogger(__name__) class WorldState: """Objective reality - the source of truth about the game world.""" def __init__(self): self.world_clock = None self.locations = {} self.entities = {} self.story_flags = {} self.ambient_events = [] def to_dict(self) -> dict: """Serialize state to JSON-compatible dict.""" return { "world_clock": self.world_clock.get_time_str() if self.world_clock else None, "locations": self.locations, "entities": self.entities, "story_flags": self.story_flags, "ambient_events": self.ambient_events[-10:], # Keep last 10 events } def from_dict(self, data: dict): """Load state from dict (typically from scenario).""" self.locations = data.get("locations", {}) self.entities = data.get("entities", {}) self.story_flags = data.get("story_flags", {}) self.ambient_events = data.get("ambient_events", []) ARCHITECT_SYSTEM_PROMPT = """You are the World Architect - the objective reality of the game world. Your role is to: 1. Process entity actions and determine realistic consequences 2. Maintain the consistency and causality of the world 3. Manage world time advancement (typically 5-30 minutes per action) 4. Update entity states and location properties based on actions 5. Track story flags and significant events 6. Return ONLY a valid JSON response with state changes You are NOT an NPC. You don't have opinions or feelings. You are pure logic. CONSTRAINTS: - Time advances by realistic amounts (5-30 minutes typically, more for extended actions) - Changes must be causally connected to the action - Property changes must make narrative sense - Entity locations change only if action involves movement - Story flags change only on significant narrative events - Maintain causality: if A happens, B is realistic consequence - Consider secondary effects: if someone leaves, location becomes emptier RESPONSE FORMAT: Return ONLY valid JSON (no markdown, no explanation) with this structure: { "clock_delta_minutes": , "rationale_clock": "", "entity_deltas": { "entity_id": { "location": "", "status": "", "custom_property_name": "" } }, "location_deltas": { "location_id": { "description_append": "", "custom_property_name": "" } }, "story_flag_deltas": { "flag_name": true }, "ambient_events": [ { "time": "", "location": "location_id", "description": "", "visible_to": ["entity_id"] } ], "rationale": "<1-2 sentence summary of world changes>" } All fields are optional. Only include fields that changed. If something didn't change, omit it from the response. If clock_delta_minutes is not provided, assume 10 minutes.""" # Note: The architect should consider: # - Does the action move the entity? (update location) # - Does it change entity status? (alert, calm, injured, tired) # - Does it affect the location? (damage, objects, visibility) # - Does it trigger story progression? (update flags) # - Are there secondary effects? (other entities react, properties degrade) def build_architect_prompt( entity_id: str, action: str, current_state: dict, entity_name: str = "", ) -> list: """Build the complete prompt for the Architect LLM call.""" state_json = json.dumps(current_state, indent=2) human_prompt = f""" CURRENT WORLD STATE: {state_json} ACTION TO PROCESS: Entity: {entity_name or entity_id} (ID: {entity_id}) Action: {action} Determine what changes to the world state as a direct consequence of this action. Return ONLY the JSON response with changed fields.""" return [ SystemMessage(content=ARCHITECT_SYSTEM_PROMPT), HumanMessage(content=human_prompt), ] def invoke_architect( entity_id: str, action: str, current_state: dict, entity_name: str = "", ) -> dict: """ Invoke the Architect LLM to determine world state changes. Args: entity_id: ID of the entity performing the action action: Description of what the entity did current_state: Current world state dict entity_name: Display name of entity (for context) Returns: State delta dict with only changed fields """ logger.info("Architect processing action from %s: %s", entity_id, action[:80]) messages = build_architect_prompt(entity_id, action, current_state, entity_name) # Log the prompt logger.debug("Architect prompt:\n%s", _format_prompt(messages)) try: response = llm.invoke(messages) response_text = _normalize_llm_output(response.content) logger.debug("Architect raw response: %s", response_text) # Parse the response as JSON delta = json.loads(response_text) logger.info("State delta: %s", json.dumps(delta, indent=2)[:200]) return delta except json.JSONDecodeError as e: logger.error("Architect response was not valid JSON: %s", response_text) logger.error("JSON parse error: %s", e) # Return empty delta on parse failure (world continues unchanged) return {} except Exception as e: logger.error("Architect invocation failed: %s", e) return {} def apply_state_delta(world_state: WorldState, delta: dict) -> None: """ Apply a state delta to the world state. Args: world_state: The WorldState object to modify delta: State delta returned by Architect """ if not delta: return # Advance clock clock_delta_minutes = delta.get("clock_delta_minutes", 10) if world_state.world_clock and clock_delta_minutes: world_state.world_clock.advance_minutes(clock_delta_minutes) logger.info( "Clock advanced %d minutes to %s", clock_delta_minutes, world_state.world_clock.get_time_str(), ) # Apply entity deltas entity_deltas = delta.get("entity_deltas", {}) if entity_deltas: for entity_id, changes in entity_deltas.items(): if entity_id not in world_state.entities: logger.warning("Entity delta for unknown entity: %s", entity_id) continue entity = world_state.entities[entity_id] for key, value in changes.items(): if value is not None: # Only apply non-None values entity[key] = value logger.debug("Entity %s updated: %s", entity_id, changes) # Apply location deltas location_deltas = delta.get("location_deltas", {}) if location_deltas: for location_id, changes in location_deltas.items(): if location_id not in world_state.locations: world_state.locations[location_id] = {} location = world_state.locations[location_id] for key, value in changes.items(): if value is None: continue if key == "description_append" and value: # Append to description instead of replace if "description" not in location: location["description"] = "" location["description"] += f" {value}" else: location[key] = value logger.debug("Location %s updated: %s", location_id, changes) # Apply story flag deltas flag_deltas = delta.get("story_flag_deltas", {}) if flag_deltas: for flag_name, value in flag_deltas.items(): if value is not None: world_state.story_flags[flag_name] = value logger.info("Story flag '%s' set to %s", flag_name, value) # Record ambient events ambient_events = delta.get("ambient_events", []) if ambient_events: for event in ambient_events: world_state.ambient_events.append(event) logger.info( "Ambient event at %s (%s): %s", event.get("time"), event.get("location"), event.get("description"), ) def get_world_context_for_entity( world_state: WorldState, entity_id: str ) -> dict: """ Get the portion of world state that an entity might realistically perceive. This is NOT the full world state - entities can't see everything. They might have local knowledge, rumors, or direct observation. Args: world_state: The objective world state entity_id: The entity requesting context Returns: A filtered version of world state relevant to this entity """ entity_data = world_state.entities.get(entity_id, {}) entity_location = entity_data.get("location") # Entity knows: their own state, their location's state, and story flags context = { "world_clock": world_state.world_clock.get_time_str() if world_state.world_clock else None, "current_location": entity_location, "self": entity_data, "location_state": world_state.locations.get(entity_location, {}) if entity_location else {}, "other_entities_here": { eid: edata for eid, edata in world_state.entities.items() if edata.get("location") == entity_location and eid != entity_id }, "story_flags": world_state.story_flags, # Entities don't see full world state - only what they could know } return context