311 lines
11 KiB
Python
311 lines
11 KiB
Python
|
|
"""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))
|