Files
ai-rpg/backend/app/core/triggers.py

311 lines
11 KiB
Python
Raw Normal View History

2026-06-19 16:31:45 +03:00
"""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))