From 2431b2a5bd38973143f7d403529ae5e2432dce49 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 23:46:28 +0700 Subject: [PATCH] feat(openworlds): add acts chronicle read model --- viewer/openworlds/screen-acts.jsx | 125 +++++++++++++++++++----- viewer/server.py | 108 ++++++++++++++++++++ viewer/tests/test_openworlds_static.py | 10 ++ viewer/tests/test_readmodel_surfaces.py | 53 ++++++++++ 4 files changed, 269 insertions(+), 27 deletions(-) diff --git a/viewer/openworlds/screen-acts.jsx b/viewer/openworlds/screen-acts.jsx index c17bb3de..3e58e39c 100644 --- a/viewer/openworlds/screen-acts.jsx +++ b/viewer/openworlds/screen-acts.jsx @@ -1,7 +1,52 @@ /* Screen: Acts — chronicle progression, current act, memorable moments */ function ScreenActs({ onNavigate, state, setState }) { - const [selectedAct, setSelectedAct] = React.useState(ACTS.find((a) => a.current) || ACTS[0]); + const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; + const activeCampaign = + campaigns.find((c) => c.id === state?.activeCampaign) || + campaigns[0] || + {}; + const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || ""; + const [surface, setSurface] = React.useState(null); + const [surfaceStatus, setSurfaceStatus] = React.useState("loading"); + const acts = (Array.isArray(surface?.acts) && surface.acts.length) ? surface.acts : (surface ? [] : ACTS); + const currentAct = acts.find((a) => a.id === surface?.currentActId) || acts.find((a) => a.current) || acts[0] || null; + const [selectedActId, setSelectedActId] = React.useState(""); + const selectedAct = acts.find((a) => a.id === selectedActId) || currentAct; + + const loadSurface = React.useCallback(async (isCancelled = () => false) => { + const query = window.combatSurfaceFromCampaign + ? window.combatSurfaceFromCampaign(activeCampaign, state) + : (campaignId ? `?campaign=${encodeURIComponent(campaignId)}` : ""); + try { + const response = await fetch("/acts-surface" + query, { cache: "no-store" }); + if (!response.ok) throw new Error(`acts surface ${response.status}`); + const payload = await response.json(); + if (isCancelled()) return; + setSurface(payload); + setSurfaceStatus("ready"); + } catch (error) { + if (!isCancelled()) setSurfaceStatus(error?.message || "unavailable"); + } + }, [activeCampaign, state, campaignId]); + + React.useEffect(() => { + let cancelled = false; + let timer = null; + const guardedLoad = async () => { if (!cancelled) await loadSurface(() => cancelled); }; + const stopPolling = () => { if (timer !== null) { window.clearInterval(timer); timer = null; } }; + const startPolling = () => { if (timer === null) timer = window.setInterval(guardedLoad, 7000); }; + const handleVisibility = () => { + if (document.visibilityState === "visible") { guardedLoad(); startPolling(); } else { stopPolling(); } + }; + document.addEventListener("visibilitychange", handleVisibility); + handleVisibility(); + return () => { cancelled = true; stopPolling(); document.removeEventListener("visibilitychange", handleVisibility); }; + }, [loadSurface]); + + React.useEffect(() => { + if (currentAct?.id && !acts.some((a) => a.id === selectedActId)) setSelectedActId(currentAct.id); + }, [currentAct?.id, selectedActId, acts]); return (
@@ -10,8 +55,18 @@ function ScreenActs({ onNavigate, state, setState }) {
The Chronicle, in

Acts

+
+ {surface ? (surface.tracked ? surface.dayLabel : surface.emptyState?.title) : surfaceStatus} +
+ {surface && !surface.tracked && ( +
+
Read-only
+
{surface.emptyState?.body}
+
+ )} +
{/* Spine */}
- {ACTS.map((a, i) => ( + {acts.map((a, i) => ( setSelectedAct(a)} + isLast={i === acts.length - 1} + selected={selectedAct?.id === a.id} + onSelect={() => setSelectedActId(a.id)} /> ))} + {!acts.length &&
No compiled acts are available for this save yet.
}
{/* RIGHT — Act detail */} - +
); } function ActSpineRow({ act, isLast, selected, onSelect }) { - const tone = act.status === "complete" ? "var(--emerald)" : - act.status === "current" ? "var(--gold-glow)" : - act.status === "future" ? "var(--ink-500)" : + const status = act.status || (act.current ? "current" : ""); + const tone = ["complete", "completed", "resolved"].includes(status) ? "var(--emerald)" : + ["current", "active"].includes(status) ? "var(--gold-glow)" : + ["future", "planned"].includes(status) ? "var(--ink-500)" : "var(--b-400)"; return (
@@ -78,19 +135,19 @@ function ActSpineRow({ act, isLast, selected, onSelect }) { }}>
- {act.status === "future" ? "?????" : act.name} + {status === "future" ? "?????" : (act.name || act.title)} - {act.duration} + {act.duration || status}
- {act.status === "future" ? "the chronicle has not reached this yet" : act.subtitle} + {status === "future" ? "the chronicle has not reached this yet" : (act.subtitle || act.summary || `${(act.beats || []).length} beats`)}
- {act.status === "current" && ( + {(status === "current" || status === "active" || act.current) && (
You are here
)} - {act.status === "complete" && ( + {["complete", "completed", "resolved"].includes(status) && (
Resolved {act.outcome && · {act.outcome}} @@ -101,8 +158,21 @@ function ActSpineRow({ act, isLast, selected, onSelect }) { ); } -function ActDetail({ act }) { - if (act.status === "future") { +function ActDetail({ act, surface }) { + if (!act) { + return ( +
+
+

{surface?.emptyState?.title || "No act selected"}

+

+ {surface?.emptyState?.body || "The chronicle is waiting for compiled campaign-director state."} +

+
+
+ ); + } + const status = act.status || (act.current ? "current" : ""); + if (status === "future") { return (
@@ -117,9 +187,9 @@ function ActDetail({ act }) { } return (
-
Act {act.numeral}{act.status === "current" ? " · in progress" : ""}
-

{act.name}

-
{act.subtitle}
+
Act {act.numeral || act.id}{(status === "current" || status === "active") ? " · in progress" : ""}
+

{act.name || act.title}

+
{act.subtitle || status}
@@ -127,7 +197,7 @@ function ActDetail({ act }) { )} -

{act.synopsis}

+

{act.synopsis || act.summary || "This act has no player-facing synopsis yet."}

@@ -138,11 +208,11 @@ function ActDetail({ act }) { Key choices made - {act.choices.length === 0 ? ( + {!(act.choices || surface?.majorChoices || []).length ? (
No turning points yet. The road still has shape to give.
) : (
- {act.choices.map((c, i) => ( + {(act.choices || surface?.majorChoices || []).map((c, i) => (
- Moments the chronicle remembers + Beats and callbacks
- {act.memories.map((m, i) => ( + {(act.memories || act.beats || surface?.threads || []).map((m, i) => (
- +
- "{m.text}" + "{m.text || m.title || m.questTitle || m.note}"
- {m.when} + {m.when || (m.triggerDay ? `day ${m.triggerDay}` : m.status)}
))} + {!(act.memories || act.beats || surface?.threads || []).length &&
No beats have been tracked for this act yet.
}
{act.partyAtStart && ( diff --git a/viewer/server.py b/viewer/server.py index 138e5ca5..36e9a645 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -2266,6 +2266,110 @@ def build_journal_surface( } +def _acts_from_path(snapshot: dict) -> tuple[bool, str, list[dict], list[dict]]: + """Project optional adventure-path state without inventing progress when it is absent.""" + path = snapshot.get("adventure_path") + if not isinstance(path, dict): + return False, "", [], [] + current = _text(path.get("current_act_id") or path.get("currentActId")) + raw_acts = path.get("acts") + diagnostics_raw = path.get("diagnostics") + acts: list[dict] = [] + if isinstance(raw_acts, list): + for index, act in enumerate(raw_acts, start=1): + if not isinstance(act, dict): + continue + act_id = _text(act.get("id"), f"act-{index}") + beats: list[dict] = [] + raw_beats = act.get("beats") + if isinstance(raw_beats, list): + for beat_index, beat in enumerate(raw_beats, start=1): + if not isinstance(beat, dict): + continue + beats.append({ + "id": _text(beat.get("id"), f"{act_id}:beat-{beat_index}"), + "title": _text(beat.get("title"), "Untitled beat"), + "status": _text(beat.get("status"), "planned"), + }) + status = _text(act.get("status"), "planned") + acts.append({ + "id": act_id, + "title": _text(act.get("title") or act.get("name"), f"Act {index}"), + "status": status, + "current": act_id == current or status in {"active", "current"}, + "summary": _text(act.get("summary") or act.get("synopsis")), + "beats": beats, + }) + diagnostics = [ + {"message": _text(item)} + for item in (diagnostics_raw if isinstance(diagnostics_raw, list) else []) + if _text(item) + ] + tracked = bool(acts or current or diagnostics) + if tracked and not current: + active = next((a for a in acts if a.get("current")), None) + current = _text(active.get("id")) if isinstance(active, dict) else "" + return tracked, current, acts, diagnostics + + +def _acts_major_choices(snapshot: dict) -> list[dict]: + decisions = snapshot.get("decisions") + out: list[dict] = [] + if not isinstance(decisions, list): + return out + for decision in decisions: + if not isinstance(decision, dict): + continue + day = _num(decision.get("day")) + out.append({ + "id": _text(decision.get("id"), f"decision-{len(out) + 1}"), + "day": int(day) if day is not None else None, + "summary": _text(decision.get("summary"), "A choice was recorded."), + "chosen": _text(decision.get("chosen")), + "context": _text(decision.get("rationale")), + }) + out.sort(key=lambda row: (row.get("day") if row.get("day") is not None else -1, row.get("id") or ""), reverse=True) + return out[:12] + + +def build_acts_surface( + snapshot: dict, + *, + campaign_id: str, + live: bool, + is_live_view: bool, +) -> dict: + """Read-only chronicle/payoff surface for the OpenWorlds Acts screen. + + If no adventure-path state exists yet, the surface says so explicitly instead of + pretending prototype acts are real campaign progress. + """ + snapshot = snapshot if isinstance(snapshot, dict) else {} + tracked, current, acts, diagnostics = _acts_from_path(snapshot) + return { + "campaign_id": campaign_id, + "title": _text(snapshot.get("title"), campaign_id or "Open Worlds"), + "world": _text(snapshot.get("world_id"), "unknown"), + "dayLabel": _openworlds_day_label(snapshot), + "tracked": tracked, + "currentActId": current, + "acts": acts, + "majorChoices": _acts_major_choices(snapshot), + "threads": _journal_evolutions(snapshot), + "directorAdvisory": _director_advisory(snapshot), + "diagnostics": diagnostics, + "emptyState": { + "title": "Acts not tracked yet", + "body": "The campaign director has not compiled act progress for this save yet.", + }, + "live": bool(live), + "is_live_view": bool(is_live_view), + "can_act": False, + "state_authority": "engine", + "write_lane": "/move", + } + + # ── Character sheets surface (full party read model) ────────────────────────── def _ability_mod(score: object) -> int: @@ -4438,6 +4542,10 @@ def do_GET(self) -> None: # noqa: N802 # The quest journal read model: tracked quests + unresolved hooks (as rumors) # + the Campaign Director's top structural debts (#72) as a GM advisory. self._serve_simple_surface(parse_qs(parsed.query), build_journal_surface) + elif route == "/acts-surface": + # Read-only act/chronicle payoff surface. It shows compiled path state when the + # engine has one and otherwise says the act tracker is not wired for this save yet. + self._serve_simple_surface(parse_qs(parsed.query), build_acts_surface) elif route == "/character-surface": # The party's full character sheets (classes/skills/spells/resources/AC/death # saves) projected from the engine snapshot into the heroes screen shape. diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index d3081735..b9a6dbcc 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -141,6 +141,16 @@ def test_openworlds_table_posts_only_enabled_session_actions(self): self.assertNotIn("snapshot.json", source) self.assertNotIn("writeSnapshot", source) + def test_openworlds_acts_screen_binds_viewer_acts_surface(self): + status, ctype, body = self._get("/openworlds/screen-acts.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + self.assertIn('fetch("/acts-surface', source) + self.assertIn("window.combatSurfaceFromCampaign", source) + self.assertIn("emptyState", source) + def test_openworlds_rejects_path_traversal(self): self.assertEqual(self._status("/openworlds/../server.py"), 404) self.assertEqual(self._status("/openworlds/%2e%2e/server.py"), 404) diff --git a/viewer/tests/test_readmodel_surfaces.py b/viewer/tests/test_readmodel_surfaces.py index 92700fc0..41c3d1c4 100644 --- a/viewer/tests/test_readmodel_surfaces.py +++ b/viewer/tests/test_readmodel_surfaces.py @@ -285,6 +285,59 @@ def test_journal_threads_empty_when_no_quest_evolution_scheduled(self): self.assertEqual(surface["threads"], []) self.assertEqual({q["id"]: q for q in surface["quests"]}["q2"]["evolvesTo"], "") + # ── acts / chronicle payoff ─────────────────────────────────────────────── + + def test_acts_surface_degrades_to_untracked_without_path_state(self): + self._write("camp_marches", _SNAPSHOT) + status, surface = self._get_json("/acts-surface?campaign=camp_marches") + self.assertEqual(status, 200) + self.assert_envelope(surface, "camp_marches") + self.assertFalse(surface["tracked"]) + self.assertEqual(surface["acts"], []) + self.assertIn("not tracked", surface["emptyState"]["title"].lower()) + self.assertEqual(surface["threads"], []) + self.assertEqual(surface["state_authority"], "engine") + self.assert_no_private_keys(surface) + + def test_acts_surface_projects_adventure_path_choices_threads_and_debts(self): + snap = copy.deepcopy(_SNAPSHOT) + snap["adventure_path"] = { + "current_act_id": "act-1", + "acts": [ + { + "id": "act-1", + "title": "The Lanternrest", + "status": "active", + "summary": "The road reaches the impossible inn.", + "beats": [ + {"id": "b1", "title": "Reach the courtyard", "status": "resolved"}, + {"id": "b2", "title": "Open the eastern door", "status": "active"}, + ], + "dm_notes": "hidden twist", + } + ], + "diagnostics": ["unknown beat ref: missing"], + } + snap["decisions"] = [ + {"id": "d1", "day": 11, "summary": "Spared Falgrim", "chosen": "let him ride", "rationale": "Mira asked for mercy"}, + ] + snap["quests"]["q2"]["evolves_to"] = "h-reckoning" + snap["consequences"].append({ + "id": "c_evo", "trigger_day": 12, "fired": False, "thread_id": "", + "text": "It returns now.", "note": "evolves_from:q2", + }) + self._write("camp_marches", snap) + status, surface = self._get_json("/acts-surface?campaign=camp_marches") + self.assertEqual(status, 200) + self.assertTrue(surface["tracked"]) + self.assertEqual(surface["currentActId"], "act-1") + self.assertEqual(surface["acts"][0]["title"], "The Lanternrest") + self.assertEqual([b["status"] for b in surface["acts"][0]["beats"]], ["resolved", "active"]) + self.assertEqual(surface["majorChoices"][0]["summary"], "Spared Falgrim") + self.assertEqual(surface["threads"][0]["status"], "due") + self.assertEqual(surface["diagnostics"][0]["message"], "unknown beat ref: missing") + self.assert_no_private_keys(surface) + # ── character ─────────────────────────────────────────────────────────────── def test_character_surface_projects_full_party_sheets(self):