From 21c8c039569a43bd048a73d6f7a62b1f761fd2fe Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 23:34:13 +0700 Subject: [PATCH] feat(viewer): surface betrayal-warning + quest-evolution callbacks in read-models (#118/#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project two newly-landed living-story engine features into the OpenWorlds read-model surfaces, read-only (the viewer is a projection of engine state): - Relations (#118): each companion row carries an advisory `betrayalWarning` (None unless live) recomputed from the snapshot, mirroring the engine's companion_arc._betrayal_warning danger band [-40, -20] — present only for a LIVE unfired attitude_below agenda below its breaking point. The relations screen renders a "Bond fracturing" telegraph beside the Approval gauge, and flags a choice-deepened rift via the engine-set decision flag. The sealed agenda's private intent never leaks. - Journal (#120): each quest row carries evolvesTo/callbackInDays, and a new `_journal_evolutions` builder projects scheduled rule-of-three callbacks from consequences tagged `evolves_from:` (due / pending / fired), skipping worldsim background beats. The journal screen shows a per-quest "this thread will return" badge plus a "Threads & Callbacks" sub-list. Icon-free (icon registry not yet merged); all changes confined to the disjoint journal/relations builder regions + screens + test_readmodel_surfaces.py, no sibling-PR files touched. Tests round-trip through the strict Campaign model. --- viewer/openworlds/screen-journal.jsx | 44 ++++++++ viewer/openworlds/screen-relations.jsx | 35 +++++- viewer/server.py | 128 ++++++++++++++++++++++ viewer/tests/test_readmodel_surfaces.py | 139 ++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 1 deletion(-) diff --git a/viewer/openworlds/screen-journal.jsx b/viewer/openworlds/screen-journal.jsx index b1d7b4f6..cdb3c523 100644 --- a/viewer/openworlds/screen-journal.jsx +++ b/viewer/openworlds/screen-journal.jsx @@ -15,6 +15,8 @@ function ScreenJournal({ onNavigate, state, setState }) { const surfaceQuests = Array.isArray(surface?.quests) ? surface.quests : null; const quests = surfaceQuests || (Array.isArray(state?.quests) ? state.quests : []); const advisory = surface?.directorAdvisory || { debts: [], total_debts: 0 }; + // Scheduled quest-evolution callbacks (#120) — the "this thread will return" threads. + const threads = Array.isArray(surface?.threads) ? surface.threads : []; const [activeQuest, setActiveQuest] = React.useState(""); const [tab, setTab] = React.useState("active"); @@ -173,6 +175,18 @@ function ScreenJournal({ onNavigate, state, setState }) { Inscribed {quest.dateOpened || "Day 9 of Gozran"} + {/* Rule-of-three evolution badge (#120): a quest carrying an evolves_to hook + will echo back. Display-only telegraph; icon-free. */} + {quest.evolvesTo && ( +
+ + {quest.callbackInDays > 0 + ? `This thread will return · echoes in ${quest.callbackInDays} day${quest.callbackInDays === 1 ? "" : "s"}` + : "This thread will return"} + +
+ )} +

@@ -248,6 +262,36 @@ function ScreenJournal({ onNavigate, state, setState }) { ))} + {/* Threads & Callbacks (#120): scheduled quest-evolution echoes — a resolved + quest's pending "this thread will return" callback. Display-only. */} + {threads.length > 0 && ( + <> + +

Threads & Callbacks
+

What will return

+
+ {threads.map((t) => { + const tone = t.status === "due" ? "var(--crimson)" : t.status === "fired" ? "var(--emerald)" : "var(--royal)"; + return ( +
+
+ + {t.questTitle} + + {t.label} +
+
{t.note}
+
+ ); + })} +
+ + )} + {/* Wax seal */}
+
+ {w?.label || "Bond fracturing"} + + {typeof w?.attitude_value === "number" ? `${w.attitude_value} / band ${band}` : band} + +
+
+ {w?.note || "This companion is approaching a breaking point."} +
+ {w?.decision_active && ( +
+ A recorded choice has deepened the rift. +
+ )} +
+ ); +} + function NPCDetail({ n, onNavigate }) { return (
@@ -284,6 +313,10 @@ function NPCDetail({ n, onNavigate }) {
)} + {/* Betrayal-warning band (#118): advisory telegraph when a companion's bond has + soured into the engine's danger band. Read-only — surfaced from the engine's + own `betrayal_warning`; never an action. */} + {n.betrayalWarning && } {Array.isArray(n.banter_tags) && n.banter_tags.length > 0 && (
Banter
@@ -574,4 +607,4 @@ const NPCS = [ }, ]; -Object.assign(window, { ScreenRelations, FactionDetail, NPCDetail, RepBar, DispositionDot, FACTIONS, NPCS }); +Object.assign(window, { ScreenRelations, FactionDetail, NPCDetail, BetrayalWarning, RepBar, DispositionDot, FACTIONS, NPCS }); diff --git a/viewer/server.py b/viewer/server.py index 05bb7c0d..b535c2f5 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -1829,6 +1829,11 @@ def _journal_quests(snapshot: dict) -> list[dict]: loc = locs.get(location_id) if isinstance(loc, dict): region = _text(loc.get("region")) or _text(loc.get("name"), location_id) + # Rule-of-three evolution (#120): a quest carrying an `evolves_to` hook/seed will + # echo back as a scheduled callback once resolved. Surface the badge display-only. + evolves_to = _text(row.get("evolves_to")) + callback_raw = _num(row.get("callback_in_days")) + callback_in_days = int(callback_raw) if callback_raw is not None else 0 out.append({ "id": _text(qid), "title": _text(row.get("title"), _text(qid, "Quest")), @@ -1841,6 +1846,8 @@ def _journal_quests(snapshot: dict) -> list[dict]: "objectives": objectives, "entries": [], "location_id": location_id, + "evolvesTo": evolves_to, + "callbackInDays": callback_in_days if evolves_to else 0, }) return out @@ -1874,6 +1881,62 @@ def _journal_hooks(snapshot: dict) -> list[dict]: return out +# The deterministic Consequence.note tag the engine writes when a resolved quest with an +# `evolves_to` hook schedules its follow-on (server._evolution_note -> "evolves_from:"). +# It's a stable contract — both authored by and guarded on by the engine — so the journal +# can project scheduled evolutions read-only by matching this prefix. +_EVOLUTION_NOTE_PREFIX = "evolves_from:" + + +def _journal_evolutions(snapshot: dict) -> list[dict]: + """Project scheduled quest-evolution callbacks (#120) — the "this thread will return" + threads. Reads `consequences` whose `note` is the engine's deterministic + ``evolves_from:`` tag (server._maybe_schedule_quest_evolution), links each + back to its resolved quest, and marks it due / pending against the current in-world + day. Display-only: it never schedules, fires, or mutates anything. World-sim background + beats (a non-empty ``thread_id``) are NOT evolutions and are skipped.""" + cons = snapshot.get("consequences") + quests = snapshot.get("quests") if isinstance(snapshot.get("quests"), dict) else {} + out: list[dict] = [] + if not isinstance(cons, list): + return out + day = _num(snapshot.get("day")) + day = int(day) if day is not None else 0 + for con in cons: + if not isinstance(con, dict): + continue + note = _text(con.get("note")) + if not note.startswith(_EVOLUTION_NOTE_PREFIX): + continue + if _text(con.get("thread_id")): + continue # a worldsim background beat, not a quest evolution + quest_id = note[len(_EVOLUTION_NOTE_PREFIX):].strip() + q = quests.get(quest_id) if isinstance(quests.get(quest_id), dict) else {} + trigger_day = _num(con.get("trigger_day")) + trigger_day = int(trigger_day) if trigger_day is not None else day + fired = bool(con.get("fired")) + due = (not fired) and trigger_day <= day + out.append({ + "id": _text(con.get("id"), note), + "questId": quest_id, + "questTitle": _text(q.get("title"), quest_id or "a resolved thread"), + "evolvesTo": _text(q.get("evolves_to")), + "triggerDay": trigger_day, + "fired": fired, + "due": due, + "status": "fired" if fired else ("due" if due else "pending"), + "label": "Echo paid" if fired else ("Echo due" if due else "Echo pending"), + # Player-facing telegraph — never the engine's DM-only "weave a follow-on beat" + # prompt text. Display-only. + "note": ( + f"A resolved thread waits to return" + + (f" on day {trigger_day}." if not due and not fired else + (" now." if due else "; it has already echoed back.")) + ), + }) + return out + + def build_journal_surface( snapshot: dict, *, @@ -1893,6 +1956,8 @@ def build_journal_surface( "world": _text(snapshot.get("world_id"), "unknown"), "dayLabel": _openworlds_day_label(snapshot), "quests": quests, + # Scheduled quest-evolution callbacks (#120) — the "Threads & Callbacks" sub-list. + "threads": _journal_evolutions(snapshot), "directorAdvisory": advisory, "live": bool(live), "is_live_view": bool(is_live_view), @@ -2321,6 +2386,66 @@ def _attitude_disposition(ch: dict) -> str: return "neutral" +# Mirror of companion_arc.ATTITUDE_WARN_{LOW,HIGH} (the engine's danger band). A LIVE +# (unfired) `attitude_below` agenda whose companion sits in [-40, -20] AND below the +# agenda's breaking point is "approaching a fracture" — the engine emits an advisory +# `betrayal_warning` from `evaluate()`; here we recompute the SAME band read-only from the +# snapshot so the relations screen can telegraph it. Display-only: never mutates, never +# fires anything, reads only the approval gauge + the (engine-set) decision_flag presence. +_ATTITUDE_WARN_HIGH = -20 # upper edge: the bond has clearly soured +_ATTITUDE_WARN_LOW = -40 # lower edge: below this it's already deep-red / near-snap + + +def _betrayal_warning(ch: dict, snapshot: dict) -> dict | None: + """Advisory "approaching a breaking point" telegraph for a companion, recomputed + read-only from the snapshot (mirrors companion_arc._betrayal_warning). + + Returns a small advisory dict ONLY when the companion carries a LIVE (unfired) + ``attitude_below`` agenda AND its ``attitude_value`` sits in the danger band + [_ATTITUDE_WARN_LOW, _ATTITUDE_WARN_HIGH] AND has crossed below the agenda's + breaking point (``value``). None otherwise. Reads the sealed agenda's TRIGGER/VALUE/ + FIRED only to decide *whether* a warning is live — it NEVER surfaces the agenda's + private intent (`note`/`decision_flag` name), so no DM-only fiction leaks. The + ``decision_active`` flag is a plain bool: True when the agenda names a content flag + that is present+True in ``Campaign.flags`` (a recorded choice has already spiked the + odds), so the screen can foreshadow harder.""" + arc = ch.get("arc") + if not isinstance(arc, dict): + return None + agenda = arc.get("agenda") + if not isinstance(agenda, dict): + return None + if agenda.get("trigger") != "attitude_below" or bool(agenda.get("fired")): + return None + threshold = _num(agenda.get("value")) + av = _num(ch.get("attitude_value")) + if threshold is None or av is None: + return None + threshold = int(threshold) + av = int(av) + # Only warn while in the band AND actually below the agenda's breaking point (an + # agenda whose threshold is even lower isn't live yet). + if not (_ATTITUDE_WARN_LOW <= av <= _ATTITUDE_WARN_HIGH): + return None + if av >= threshold: + return None + flags = snapshot.get("flags") if isinstance(snapshot.get("flags"), dict) else {} + decision_flag = _text(agenda.get("decision_flag")) + decision_active = bool(flags.get(decision_flag)) if decision_flag else False + return { + "attitude_value": av, + "threshold": threshold, + "band": [_ATTITUDE_WARN_LOW, _ATTITUDE_WARN_HIGH], + "decision_active": decision_active, + "label": "Bond fracturing", + "note": ( + "This companion is approaching a breaking point — their bond has soured into " + "the danger band." + + (" A choice you made has deepened the rift." if decision_active else "") + ), + } + + def _relations_npcs(snapshot: dict) -> list[dict]: """Project NPCs the party has actually met (kind=='npc') + companions, with attitude and (for companions) the dossier's banter/relationship facts + arc state.""" @@ -2360,6 +2485,9 @@ def _relations_npcs(snapshot: dict) -> list[dict]: "faction": "", "disposition": _attitude_disposition(ch), "approval": int(approval) if approval is not None else None, + # Advisory telegraph (#118): present (a dict) only for a companion whose live + # attitude_below agenda sits in the danger band; None otherwise. Display-only. + "betrayalWarning": _betrayal_warning(ch, snapshot) if is_companion else None, "attitude": _text(ch.get("attitude")), "body": _text(ch.get("backstory")) or _text(ch.get("personality")) or _text(ch.get("notes")) or "Little is known of them yet.", "banter_tags": [str(t) for t in dossier.get("banter_tags", []) if str(t)] if isinstance(dossier.get("banter_tags"), list) else [], diff --git a/viewer/tests/test_readmodel_surfaces.py b/viewer/tests/test_readmodel_surfaces.py index 93f6e914..92700fc0 100644 --- a/viewer/tests/test_readmodel_surfaces.py +++ b/viewer/tests/test_readmodel_surfaces.py @@ -11,6 +11,7 @@ Campaign Director advisory exercises the engine.director detection path. """ +import copy import http.client import importlib.util import json @@ -195,6 +196,7 @@ def test_journal_surface_empty_without_snapshot(self): self.assertEqual(status, 200) self.assertEqual(surface["campaign_id"], "") self.assertEqual(surface["quests"], []) + self.assertEqual(surface["threads"], []) self.assertEqual(surface["directorAdvisory"]["debts"], []) def test_director_advisory_uses_engine_detection_path(self): @@ -212,6 +214,77 @@ def test_director_advisory_heuristic_matches_on_nonconformant_snapshot(self): self.assertEqual(advisory["source"], "viewer.heuristic") self.assertIn("hook_untracked", {d["kind"] for d in advisory["debts"]}) + def test_journal_surface_quest_carries_evolution_badge_and_threads_callback(self): + # Quest-evolution / callback (#120): a resolved quest carrying `evolves_to` + + # `callback_in_days`, plus the engine's scheduled `evolves_from:` Consequence, + # surfaces as both a per-quest badge AND a "Threads & Callbacks" thread row. + snap = copy.deepcopy(_SNAPSHOT) + snap["quests"]["q2"]["evolves_to"] = "h-reckoning" # q2 is the completed quest + snap["quests"]["q2"]["callback_in_days"] = 3 + # the engine schedules this on resolve (note == "evolves_from:"); day=12 + snap["consequences"].append({ + "id": "c_evo", "trigger_day": 15, "fired": False, "thread_id": "", + "text": "Bring back / evolve the resolved thread 'The Ferryman's Tab'.", + "note": "evolves_from:q2", + }) + self._write("camp_marches", snap) + status, surface = self._get_json("/journal-surface?campaign=camp_marches") + self.assertEqual(status, 200) + + # (a) per-quest badge fields + q2 = {q["id"]: q for q in surface["quests"]}["q2"] + self.assertEqual(q2["evolvesTo"], "h-reckoning") + self.assertEqual(q2["callbackInDays"], 3) + # a quest WITHOUT an evolves_to hook carries neither + q1 = {q["id"]: q for q in surface["quests"]}["q1"] + self.assertEqual(q1["evolvesTo"], "") + self.assertEqual(q1["callbackInDays"], 0) + + # (b) the Threads & Callbacks sub-list projects the scheduled evolution + threads = {t["id"]: t for t in surface["threads"]} + self.assertIn("c_evo", threads) + thread = threads["c_evo"] + self.assertEqual(thread["questId"], "q2") + self.assertEqual(thread["questTitle"], "The Ferryman's Tab") + self.assertEqual(thread["evolvesTo"], "h-reckoning") + self.assertEqual(thread["triggerDay"], 15) + self.assertFalse(thread["fired"]) + self.assertFalse(thread["due"]) # trigger_day 15 > current day 12 + self.assertEqual(thread["status"], "pending") + self.assertTrue(thread["note"]) + self.assert_no_private_keys(surface) + + def test_journal_threads_marks_due_and_skips_worldsim_beats(self): + # A pending evolution whose trigger_day has arrived is `due`; a worldsim background + # beat (a non-empty thread_id, even with an evolves_from-looking note) is NOT an + # evolution and must be skipped. + snap = copy.deepcopy(_SNAPSHOT) # day = 12 + snap["quests"]["q2"]["evolves_to"] = "h-reckoning" + snap["consequences"].append({ + "id": "c_due", "trigger_day": 12, "fired": False, "thread_id": "", + "text": "It returns now.", "note": "evolves_from:q2", + }) + snap["consequences"].append({ + "id": "c_ws", "trigger_day": 12, "fired": False, "thread_id": "standing-war", + "text": "A world beat.", "note": "evolves_from:q2", + }) + self._write("camp_marches", snap) + _status, surface = self._get_json("/journal-surface?campaign=camp_marches") + threads = {t["id"]: t for t in surface["threads"]} + self.assertIn("c_due", threads) + self.assertTrue(threads["c_due"]["due"]) + self.assertEqual(threads["c_due"]["status"], "due") + self.assertNotIn("c_ws", threads) # worldsim beat excluded + self.assertEqual(len(surface["threads"]), 1) + + def test_journal_threads_empty_when_no_quest_evolution_scheduled(self): + # The baseline conformant snapshot schedules no evolution -> no threads, and the + # completed quest carries an empty evolvesTo. + self._write("camp_marches", _SNAPSHOT) + _status, surface = self._get_json("/journal-surface?campaign=camp_marches") + self.assertEqual(surface["threads"], []) + self.assertEqual({q["id"]: q for q in surface["quests"]}["q2"]["evolvesTo"], "") + # ── character ─────────────────────────────────────────────────────────────── def test_character_surface_projects_full_party_sheets(self): @@ -283,6 +356,72 @@ def test_relations_surface_projects_factions_npcs_and_arcs(self): self.assertEqual(arcs["a1"]["companion"], "Mira of the Inkstain") self.assertEqual([s["status"] for s in arcs["a1"]["stages"]], ["active", "locked"]) self.assert_no_private_keys(surface) + # baseline companion (no arc) carries no betrayal warning + self.assertIsNone(npcs["mira"]["betrayalWarning"]) + + def _snapshot_with_companion_agenda(self, *, attitude_value, threshold, fired=False, + trigger="attitude_below", decision_flag="", flags=None): + """A model-conformant copy of _SNAPSHOT where the companion `mira` carries a sealed + attitude_below agenda + a given attitude, so the betrayal-warning band (#118) can be + exercised. Round-trips through the engine Campaign model (strict), matching the + established conformant-snapshot pattern.""" + snap = copy.deepcopy(_SNAPSHOT) + snap["characters"]["mira"]["attitude_value"] = attitude_value + agenda = {"trigger": trigger, "value": threshold, "fired": fired, "note": "sealed: turns on the party"} + if decision_flag: + agenda["decision_flag"] = decision_flag + snap["characters"]["mira"]["arc"] = {"arc_gates": [], "agenda": agenda} + if flags is not None: + snap["flags"] = flags + return snap + + def test_relations_surface_betrayal_warning_present_when_companion_in_danger_band(self): + # mira at -28 with a live attitude_below agenda (threshold -10) sits in the engine's + # danger band [-40, -20] AND below the breaking point -> the advisory surfaces. + snap = self._snapshot_with_companion_agenda(attitude_value=-28, threshold=-10) + self._write("camp_marches", snap) + status, surface = self._get_json("/relations-surface?campaign=camp_marches") + self.assertEqual(status, 200) + npcs = {n["id"]: n for n in surface["npcs"]} + warning = npcs["mira"]["betrayalWarning"] + self.assertIsNotNone(warning) + self.assertEqual(warning["attitude_value"], -28) + self.assertEqual(warning["threshold"], -10) + self.assertEqual(warning["band"], [-40, -20]) + self.assertFalse(warning["decision_active"]) + self.assertTrue(warning["note"]) + # the sealed agenda's private intent never leaks into the surface + self.assert_no_private_keys(surface) + + def test_relations_surface_betrayal_warning_flags_a_recorded_decision(self): + # A decision_flag set+True in Campaign.flags marks the rift as choice-deepened. + snap = self._snapshot_with_companion_agenda( + attitude_value=-35, threshold=-10, decision_flag="took_bribe", flags={"took_bribe": True}) + self._write("camp_marches", snap) + _status, surface = self._get_json("/relations-surface?campaign=camp_marches") + warning = {n["id"]: n for n in surface["npcs"]}["mira"]["betrayalWarning"] + self.assertIsNotNone(warning) + self.assertTrue(warning["decision_active"]) + + def test_relations_surface_omits_betrayal_warning_outside_the_band(self): + # Every off-band case must omit the warning (mirror companion_arc._betrayal_warning): + # - attitude above the band (not yet fracturing) + # - attitude in band but still at/above the agenda's breaking point + # - agenda already fired (the betrayal is the event, not a warning) + # - a non-attitude_below trigger sitting in the band + cases = [ + ("above_band", dict(attitude_value=-10, threshold=-5)), + ("at_or_above_threshold", dict(attitude_value=-30, threshold=-35)), + ("already_fired", dict(attitude_value=-30, threshold=-10, fired=True)), + ("wrong_trigger", dict(attitude_value=-30, threshold=20, trigger="day_reached")), + ] + for name, kwargs in cases: + with self.subTest(case=name): + cid = f"camp_{name}" # a distinct campaign dir per case (no rewrite clash) + snap = self._snapshot_with_companion_agenda(**kwargs) + self._write(cid, snap) + _status, surface = self._get_json(f"/relations-surface?campaign={cid}") + self.assertIsNone({n["id"]: n for n in surface["npcs"]}["mira"]["betrayalWarning"]) # ── parley ───────────────────────────────────────────────────────────────