diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 000a1f8b..1d50a59a 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 }),
@@ -126,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"}.` });
}
};
@@ -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..510d8fb9 100644
--- a/viewer/tests/test_session_surface.py
+++ b/viewer/tests/test_session_surface.py
@@ -190,6 +190,76 @@ 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")
+ 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)
+ 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"