"""World calendar + trigger firing helpers. Triggers fire on changes to in-world time (NOT real-time polling). When the orchestrator advances world time (via the `advance_time` tool or the `time_advance` field in a plan), the engine checks all unfired triggers for that session and fires any whose `fire_at` is now <= the new world time. Each world may define its own calendar via `world.definition.calendar`: { "hours_per_day": 24, # default 24 "days_per_week": 7, # informational only (not used in math) "minutes_per_hour": 60 # default 60 } World time is stored as a string "day_{D}_hour_{H}" (we don't track minutes in the string to keep it compact — minutes are tracked separately in world.state.world_time if needed). The trigger's `fire_at` is also a "day_D_hour_H" string. We compare by totaling the in-world minutes since day-0-hour-0 for each side. """ from __future__ import annotations import json import re import uuid from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.llm import LlmClient from app.core.settings_service import get_all_settings from app.core.state_validator import apply_patch, validate_state from app.engine.tools.tools import TRIGGER_RUNNER_TOOL_SCHEMAS from app.logging_setup import get_logger from app.models import DeferredTrigger, Message, Session, World from app.prompts.templates import get_prompt log = get_logger("triggers") _TIME_RE = re.compile(r"^day_(\d+)_hour_(\d+)(?:_min_(\d+))?$") def get_calendar(world: World) -> Dict[str, int]: """Return the world's calendar config with defaults applied.""" defn = world.definition or {} cal = (defn.get("calendar") or {}) if isinstance(defn, dict) else {} return { "hours_per_day": int(cal.get("hours_per_day", 24) or 24), "minutes_per_hour": int(cal.get("minutes_per_hour", 60) or 60), "days_per_week": int(cal.get("days_per_week", 7) or 7), } def parse_world_time(t: Optional[str], cal: Dict[str, int]) -> int: """Parse 'day_D_hour_H[_min_M]' into total in-world minutes since day 0 hour 0. Returns 0 for unparseable input (so triggers with bad fire_at fire immediately rather than never — fail-open for visibility). """ if not t: return 0 m = _TIME_RE.match(t.strip()) if not m: # Try ISO datetime as a fallback (rare). try: from datetime import datetime return int(datetime.fromisoformat(t).timestamp() // 60) except Exception: return 0 day = int(m.group(1)) hour = int(m.group(2)) minute = int(m.group(3) or 0) hours_per_day = max(1, cal.get("hours_per_day", 24)) minutes_per_hour = max(1, cal.get("minutes_per_hour", 60)) return day * hours_per_day * minutes_per_hour + hour * minutes_per_hour + minute def format_world_time(total_minutes: int, cal: Dict[str, int]) -> str: """Inverse of parse_world_time: total minutes -> 'day_D_hour_H' string.""" hours_per_day = max(1, cal.get("hours_per_day", 24)) minutes_per_hour = max(1, cal.get("minutes_per_hour", 60)) minutes_per_day = hours_per_day * minutes_per_hour day = total_minutes // minutes_per_day rem = total_minutes % minutes_per_day hour = rem // minutes_per_hour minute = rem % minutes_per_hour if minute: return f"day_{day}_hour_{hour}_min_{minute}" return f"day_{day}_hour_{hour}" def advance_world_time( current_time: Optional[str], advance: Dict[str, int], world: World, ) -> Tuple[str, int, int]: """Advance world time by days/hours/minutes, honoring the world's calendar. Returns (new_time_string, new_total_minutes, delta_minutes). """ cal = get_calendar(world) cur_total = parse_world_time(current_time, cal) hours_per_day = cal["hours_per_day"] minutes_per_hour = cal["minutes_per_hour"] delta = ( int(advance.get("days", 0)) * hours_per_day * minutes_per_hour + int(advance.get("hours", 0)) * minutes_per_hour + int(advance.get("minutes", 0)) ) new_total = cur_total + delta new_str = format_world_time(new_total, cal) # Also update world_time in state if present. if world.state and isinstance(world.state, dict) and "world_time" in world.state: wt = world.state["world_time"] if isinstance(wt, dict): day = new_total // (hours_per_day * minutes_per_hour) rem = new_total % (hours_per_day * minutes_per_hour) hour = rem // minutes_per_hour minute = rem % minutes_per_hour wt["day"] = day wt["hour"] = hour wt["minute"] = minute wt["hours_per_day"] = hours_per_day wt["minutes_per_hour"] = minutes_per_hour return new_str, new_total, delta async def fire_due_triggers( db: AsyncSession, session_id: uuid.UUID, world: World, settings_map: Optional[Dict[str, Any]] = None, user_id: Optional[uuid.UUID] = None, ) -> List[Dict[str, Any]]: """Fire all due triggers for this session. "Due" = trigger.fired is False AND parse_world_time(trigger.fire_at) <= parse_world_time(world.current_time). Each fired trigger: 1. Calls the LLM (trigger_runner prompt) to produce narrative + state patch. 2. Applies the state patch to the world. 3. Saves a Message (visible if should_notify_player, hidden otherwise). 4. Marks trigger.fired = True. Returns a list of fired trigger dicts (for the orchestrator to include in the step_complete event). """ cal = get_calendar(world) cur_total = parse_world_time(world.current_time, cal) result = await db.execute( select(DeferredTrigger).where( DeferredTrigger.session_id == session_id, DeferredTrigger.fired.is_(False), ) ) triggers = list(result.scalars().all()) if not triggers: return [] # Sort by fire_at ascending so they fire in chronological order. triggers.sort(key=lambda t: parse_world_time(t.fire_at, cal)) fired: List[Dict[str, Any]] = [] for trigger in triggers: if parse_world_time(trigger.fire_at, cal) > cur_total: continue # not due yet try: await _fire_one(db, trigger, session_id, world, settings_map, user_id) fired.append({ "id": str(trigger.id), "fire_at": trigger.fire_at, "description": trigger.description, "payload": trigger.payload, }) except Exception as e: log.error( "trigger_fire_failed", trigger_id=str(trigger.id), session_id=str(session_id), error=f"{type(e).__name__}: {e}", ) # Mark as fired anyway so we don't retry forever on a broken trigger. trigger.fired = True if fired: await db.commit() return fired async def _fire_one( db: AsyncSession, trigger: DeferredTrigger, session_id: uuid.UUID, world: World, settings_map: Optional[Dict[str, Any]], user_id: Optional[uuid.UUID], ) -> None: """Fire a single trigger: produce narrative + apply state patch. Uses the `submit_trigger_result` tool (tool-calling-first design) to get structured output from the LLM. """ if settings_map is None: settings_map = await get_all_settings(db) llm = LlmClient(settings_map) # Trigger-runner prompt is always English (system content convention); # the LLM produces player-facing narrative in world.language (interpolated). system_prompt = get_prompt("trigger_runner", "en").format( description=trigger.description, payload=json.dumps(trigger.payload, ensure_ascii=False)[:600], state=json.dumps(world.state, ensure_ascii=False)[:1000], world_language=world.language, ) response = await llm.chat( messages=[{"role": "system", "content": system_prompt}], tools=TRIGGER_RUNNER_TOOL_SCHEMAS, temperature=0.5, max_tokens=600, purpose="trigger", user_id=user_id, session_id=session_id, db=db, ) # Extract from submit_trigger_result tool call; fall back to JSON parse. parsed: Dict[str, Any] = {} extracted = False for tc in (response.tool_calls or []): if tc.get("function", {}).get("name") == "submit_trigger_result": args_str = tc.get("function", {}).get("arguments", "{}") try: parsed = json.loads(args_str) if args_str else {} extracted = True except json.JSONDecodeError: pass break if not extracted: m = re.search(r"\{[\s\S]*\}", response.text or "") if m: try: parsed = json.loads(m.group(0)) except json.JSONDecodeError: pass # Apply state patch state_patch = parsed.get("state_patch", {}) or {} if state_patch: new_state = apply_patch(world.state, state_patch) schema = world.definition.get("world_schema", {}) ok, _errors = validate_state(new_state, schema) if ok: world.state = new_state narrative = parsed.get("narrative", "") or "" should_notify = bool(parsed.get("should_notify_player", True)) # Compute next message seq seq_result = await db.execute( select(Message.seq) .where(Message.session_id == session_id) .order_by(Message.seq.desc()) .limit(1) ) row = seq_result.first() next_seq = (row[0] + 1) if row else 1 if should_notify and narrative: msg = Message( session_id=session_id, seq=next_seq, role="system", kind="narrative_step", content=narrative, payload={ "trigger_id": str(trigger.id), "triggered_at": trigger.fire_at, "outcome": parsed.get("outcome", trigger.description), "world_time": world.current_time, "player_state": world.state.get("player", {}), "options": [], }, is_pinned=True, hidden=False, ) else: msg = Message( session_id=session_id, seq=next_seq, role="system", kind="technical_offscreen", content=f"[Trigger fired: {trigger.description}] Outcome: {parsed.get('outcome', '')}", payload={ "trigger_id": str(trigger.id), "outcome": parsed.get("outcome", ""), "state_patch": state_patch, }, is_pinned=False, hidden=True, ) db.add(msg) trigger.fired = True log.info("trigger_fired", trigger_id=str(trigger.id), session_id=str(session_id))