- {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):