feat: Implement World Architect

This commit is contained in:
2026-04-12 11:29:06 +05:30
parent 86d9bfa746
commit 079fe3ff22
11 changed files with 431 additions and 443 deletions

290
world_architect.py Normal file
View File

@@ -0,0 +1,290 @@
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": <number 5-30>,
"rationale_clock": "<brief explanation of time advancement>",
"entity_deltas": {
"entity_id": {
"location": "<new location or null>",
"status": "<new status or null>",
"custom_property_name": "<new value>"
}
},
"location_deltas": {
"location_id": {
"description_append": "<add to description or null>",
"custom_property_name": "<new value>"
}
},
"story_flag_deltas": {
"flag_name": true
},
"ambient_events": [
{
"time": "<HH:MM format>",
"location": "location_id",
"description": "<What happened>",
"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