From 2e8c2dbdba968e3c950fbd9ef2b0cba2937430bc Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 23:44:12 +0700 Subject: [PATCH 1/2] Add viewer session action model context --- viewer/openworlds/screen-table.jsx | 24 +++++-- viewer/server.py | 95 +++++++++++++++++++++++++- viewer/tests/test_openworlds_static.py | 13 ++++ viewer/tests/test_session_surface.py | 69 +++++++++++++++++++ 4 files changed, 193 insertions(+), 8 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 000a1f8b..cd5b2b0b 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -23,6 +23,11 @@ function ScreenTable({ onNavigate, state, setState }) { const conditions = Array.isArray(surface?.conditions) ? surface.conditions : []; const recentEvents = Array.isArray(surface?.recentEvents) ? surface.recentEvents : []; const actions = Array.isArray(surface?.availableActions) ? surface.availableActions : []; + const enabledActions = Array.isArray(surface?.enabledActions) ? surface.enabledActions : actions.filter((a) => a?.available); + const blockedActions = Array.isArray(surface?.blockedActions) ? surface.blockedActions : actions.filter((a) => !a?.available); + const writeLane = surface?.writeLane || { endpoint: surface?.write_lane || "/move" }; + const actionContext = surface?.actionContext || {}; + const consequenceContext = actionContext?.consequences || {}; const roundOrder = Array.isArray(surface?.roundOrder) ? surface.roundOrder : []; const scene = surface?.scene || {}; const encounter = surface?.encounter || {}; @@ -30,9 +35,10 @@ function ScreenTable({ onNavigate, state, setState }) { const hero = party.find((p) => p.id === activeHero) || party[0] || { id: "", name: "Hero", short: "Hero", level: 1, class: "Adventurer", hp: 1, hpMax: 1 }; const visibleQuests = quests.filter((q) => !q.status || q.status === "active" || q.status === "open"); const canAct = Boolean(surface?.can_act); - const readOnlyReason = actions.find((a) => a.disabled_reason)?.disabled_reason || "read-only surface"; + const readOnlyReason = blockedActions.find((a) => a.disabled_reason)?.disabled_reason || "read-only surface"; const visibleLog = surface ? [...recentEvents, ...log] : [...demoLog, ...log]; const actionById = (id) => actions.find((a) => a.id === id); + const enabledActionById = (id) => enabledActions.find((a) => a.id === id); const loadSurface = React.useCallback(async (isCancelled = () => false) => { const params = new URLSearchParams(); @@ -107,14 +113,15 @@ function ScreenTable({ onNavigate, state, setState }) { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [visibleLog]); - const postMove = async (move, label) => { - if (!move || !canAct) { + const postMove = async (move, label, actionId) => { + const enabledAction = actionId ? enabledActionById(actionId) : null; + if (!move || !canAct || (actionId && !enabledAction)) { toast({ kind: "danger", title: "Action unavailable", body: readOnlyReason }); return; } const text = label || move.text || move.name || "declares an action"; try { - const response = await fetch("/move", { + const response = await fetch(writeLane.endpoint || "/move", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...move, campaign: surface?.campaign_id || campaignId }), @@ -138,7 +145,7 @@ function ScreenTable({ onNavigate, state, setState }) { toast({ kind: "danger", title: "Declare is unavailable", body: action?.disabled_reason || readOnlyReason }); return; } - await postMove({ kind: "do", text }, text); + await postMove({ kind: "do", text }, text, "do"); setInput(""); }; @@ -148,7 +155,7 @@ function ScreenTable({ onNavigate, state, setState }) { toast({ kind: "danger", title: `d${sides} unavailable`, body: action?.disabled_reason || readOnlyReason }); return; } - postMove({ kind: "check", name: `d${sides}`, text: `roll d${sides}` }, `requests a d${sides} roll`); + postMove({ kind: "check", name: `d${sides}`, text: `roll d${sides}` }, `requests a d${sides} roll`, "check"); }; const invokeAction = (action) => { @@ -161,7 +168,7 @@ function ScreenTable({ onNavigate, state, setState }) { return; } if (action.move) { - postMove(action.move, action.label); + postMove(action.move, action.label, action.id); } }; @@ -330,6 +337,9 @@ function ScreenTable({ onNavigate, state, setState }) { Encounter
{encounter.summary || scene.summary || "Choose what to risk."} + {Number(consequenceContext.dueCount || 0) > 0 && ( + ยท {consequenceContext.dueCount} consequence due + )}
{actions.slice(0, 6).map((a) => ( diff --git a/viewer/server.py b/viewer/server.py index 59d27f21..138e5ca5 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -867,6 +867,87 @@ def _session_available_actions(action_model: dict) -> list[dict]: return out +def _session_action_buckets(actions: list[dict]) -> tuple[list[dict], list[dict]]: + enabled: list[dict] = [] + blocked: list[dict] = [] + for action in actions: + item = dict(action) + if item.get("available"): + item.pop("disabled_reason", None) + enabled.append(item) + else: + item["disabled_reason"] = _text(item.get("disabled_reason"), "not available") + blocked.append(item) + return enabled, blocked + + +def _session_write_lane_metadata() -> dict: + return { + "endpoint": "/move", + "method": "POST", + "authority": "engine", + "payload": "player_move_intent", + "writesCampaignSnapshot": False, + "allowedKinds": sorted(_MOVE_KINDS), + } + + +def _session_consequence_context(snapshot: dict) -> dict: + current_day = snapshot.get("day") if isinstance(snapshot.get("day"), int) else None + consequences = snapshot.get("consequences") + signals: list[dict] = [] + due_count = 0 + pending_count = 0 + if isinstance(consequences, list): + for idx, consequence in enumerate(consequences): + if not isinstance(consequence, dict): + continue + resolved = bool(consequence.get("resolved") or consequence.get("fired")) + trigger_day = consequence.get("trigger_day", consequence.get("day")) + trigger = trigger_day if isinstance(trigger_day, int) and not isinstance(trigger_day, bool) else None + due = bool(not resolved and current_day is not None and trigger is not None and trigger <= current_day) + if due: + due_count += 1 + elif not resolved: + pending_count += 1 + signal = { + "id": _text(consequence.get("id"), f"consequence-{idx + 1}"), + "status": "resolved" if resolved else ("due" if due else "pending"), + } + if trigger is not None: + signal["triggerDay"] = trigger + signals.append(signal) + if len(signals) >= 6: + break + return { + "dueCount": due_count, + "pendingCount": pending_count, + "signals": signals, + } + + +def _session_action_context(snapshot: dict, location: dict, summary: str, quests: list[dict]) -> dict: + return { + "scene": { + "summary": summary, + "location": _text(location.get("name"), "Unknown location"), + "time": _openworlds_day_label(snapshot), + }, + "quests": [ + { + "id": _text(q.get("id")), + "title": _text(q.get("title")), + "objective": _text(q.get("objective")), + "status": _text(q.get("status"), "active"), + "location": _text(q.get("location")), + } + for q in quests[:4] + if isinstance(q, dict) + ], + "consequences": _session_consequence_context(snapshot), + } + + def _session_recent_events(raw_events: list[dict] | None) -> list[dict]: out: list[dict] = [] for row in raw_events or []: @@ -997,11 +1078,14 @@ def build_session_surface( action_model = build_action_model(snapshot, live=live, is_live_view=is_live_view) combat_view = build_combat_view(snapshot) actions = _session_available_actions(action_model) + enabled_actions, blocked_actions = _session_action_buckets(actions) combat_active = bool(combat_view.get("active")) round_no = combat_view.get("round") + active_quests = _session_active_quests(snapshot) summary = _text(snapshot.get("summary")) if not summary: summary = _text(location.get("description"), f"The party is gathered near {location['name']}.") + action_context = _session_action_context(snapshot, location, summary, active_quests) return { "campaign_id": campaign_id, @@ -1018,7 +1102,7 @@ def build_session_surface( }, "party": party, "conditions": _session_conditions(party), - "activeQuests": _session_active_quests(snapshot), + "activeQuests": active_quests, "quickInventory": _session_quick_inventory(snapshot), "encounter": { "active": combat_active, @@ -1037,6 +1121,9 @@ def build_session_surface( if isinstance(row, dict) ], "availableActions": actions, + "enabledActions": enabled_actions, + "blockedActions": blocked_actions, + "actionContext": action_context, "recentEvents": _session_recent_events(recent_events), "actionModel": action_model, "combatView": combat_view, @@ -1045,6 +1132,7 @@ def build_session_surface( "can_act": bool(live and is_live_view), "state_authority": "engine", "write_lane": "/move", + "writeLane": _session_write_lane_metadata(), } @@ -3786,6 +3874,11 @@ def reaction_reason() -> str | None: }, ], } + actions = _session_available_actions(model) + enabled_actions, blocked_actions = _session_action_buckets(actions) + model["writeLane"] = _session_write_lane_metadata() + model["enabledActions"] = enabled_actions + model["blockedActions"] = blocked_actions return model diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index f313ed20..d3081735 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -128,6 +128,19 @@ def test_openworlds_map_screen_binds_viewer_atlas_surface(self): self.assertIn("window.atlasSurfaceFromCampaign", source) self.assertNotIn("state?.locations", source) + def test_openworlds_table_posts_only_enabled_session_actions(self): + status, ctype, body = self._get("/openworlds/screen-table.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + self.assertIn("surface?.enabledActions", source) + self.assertIn("surface?.blockedActions", source) + self.assertIn("enabledActionById(actionId)", source) + self.assertIn("fetch(writeLane.endpoint || \"/move\"", source) + self.assertNotIn("snapshot.json", source) + self.assertNotIn("writeSnapshot", 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_session_surface.py b/viewer/tests/test_session_surface.py index d1e58a7f..77d739bb 100644 --- a/viewer/tests/test_session_surface.py +++ b/viewer/tests/test_session_surface.py @@ -190,6 +190,75 @@ def test_session_surface_includes_combat_order_and_disabled_reasons(self): self.assertEqual(_find_action(surface, "attack")["disabled_reason"], "action spent") self.assertEqual(_find_action(surface, "reaction")["disabled_reason"], "reaction spent") + def test_session_surface_projects_action_context_and_write_lane_metadata(self): + snapshot = { + "title": "Due Consequence", + "summary": "The council waits for the party's answer.", + "day": 8, + "time_of_day": "night", + "current_location_id": "council", + "locations": { + "council": { + "name": "Council Hall", + "description": "Lanterns burn low.", + "notes": "private council leverage", + }, + }, + "party": ["pc"], + "characters": {"pc": {"id": "pc", "name": "Vela", "kind": "player"}}, + "quests": { + "q_council": { + "title": "The Council Vote", + "description": "Choose who receives the charter.", + "status": "active", + "objectives": ["Name a claimant"], + "notes": "private winning answer", + }, + }, + "consequences": [ + { + "id": "charter_due", + "trigger_day": 7, + "resolved": False, + "note": "private baron betrayal", + }, + { + "id": "winter_later", + "trigger_day": 12, + "fired": False, + "note": "private winter plan", + }, + ], + "dm_notes": "private session agenda", + } + + surface = server.build_session_surface( + snapshot, + campaign_id="camp_context", + live=False, + is_live_view=False, + ) + + self.assertEqual(surface["writeLane"]["endpoint"], "/move") + self.assertEqual(surface["writeLane"]["authority"], "engine") + self.assertFalse(surface["writeLane"]["writesCampaignSnapshot"]) + self.assertIn("do", surface["writeLane"]["allowedKinds"]) + self.assertEqual([a["id"] for a in surface["enabledActions"]], []) + blocked = {a["id"]: a for a in surface["blockedActions"]} + self.assertEqual(blocked["continue"]["disabled_reason"], "no live move sink") + self.assertEqual(blocked["attack"]["disabled_reason"], "not in combat") + self.assertEqual(surface["actionContext"]["scene"]["location"], "Council Hall") + self.assertEqual(surface["actionContext"]["quests"][0]["title"], "The Council Vote") + self.assertEqual(surface["actionContext"]["consequences"]["dueCount"], 1) + self.assertEqual(surface["actionContext"]["consequences"]["pendingCount"], 1) + self.assertEqual(surface["actionContext"]["consequences"]["signals"][0]["id"], "charter_due") + self.assertEqual(surface["actionModel"]["blockedActions"][0]["disabled_reason"], "no live move sink") + encoded = json.dumps(surface) + self.assertNotIn("private", encoded) + self.assertNotIn("baron", encoded) + self.assertNotIn("winning answer", encoded) + self.assert_no_private_keys(surface) + def test_session_event_tail_rejects_unsafe_active_session_id(self): root = Path(self.enterContext(tempfile.TemporaryDirectory())) campaign_dir = root / "campaigns" / "camp_safe" From eb4c02658e24b9aca4c4d4568d375e146a915956 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 23:53:58 +0700 Subject: [PATCH 2/2] fix(viewer): address session action review notes --- viewer/openworlds/screen-table.jsx | 2 +- viewer/tests/test_session_surface.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index cd5b2b0b..1d50a59a 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -133,7 +133,7 @@ function ScreenTable({ onNavigate, state, setState }) { setLog((l) => [...l, { kind: "action", who: hero.name, text }]); loadSurface(); } catch (error) { - toast({ kind: "danger", title: "Move not sent", body: error?.message || "The viewer could not reach /move." }); + toast({ kind: "danger", title: "Move not sent", body: error?.message || `The viewer could not reach ${writeLane.endpoint || "/move"}.` }); } }; diff --git a/viewer/tests/test_session_surface.py b/viewer/tests/test_session_surface.py index 77d739bb..510d8fb9 100644 --- a/viewer/tests/test_session_surface.py +++ b/viewer/tests/test_session_surface.py @@ -252,7 +252,8 @@ def test_session_surface_projects_action_context_and_write_lane_metadata(self): self.assertEqual(surface["actionContext"]["consequences"]["dueCount"], 1) self.assertEqual(surface["actionContext"]["consequences"]["pendingCount"], 1) self.assertEqual(surface["actionContext"]["consequences"]["signals"][0]["id"], "charter_due") - self.assertEqual(surface["actionModel"]["blockedActions"][0]["disabled_reason"], "no live move sink") + action_model_blocked = {a["id"]: a for a in surface["actionModel"]["blockedActions"]} + self.assertEqual(action_model_blocked["continue"]["disabled_reason"], "no live move sink") encoded = json.dumps(surface) self.assertNotIn("private", encoded) self.assertNotIn("baron", encoded)