291 lines
9.7 KiB
Python
291 lines
9.7 KiB
Python
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
|