Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,22 @@ 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 || {};
const [activeHero, setActiveHero] = React.useState(() => party[0]?.id || "");
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();
Expand Down Expand Up @@ -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 }),
Expand All @@ -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"}.` });
}
};

Expand All @@ -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("");
};

Expand All @@ -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) => {
Expand All @@ -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);
}
};

Expand Down Expand Up @@ -330,6 +337,9 @@ function ScreenTable({ onNavigate, state, setState }) {
<SectionTitle>Encounter</SectionTitle>
<div className="body-sm muted" style={{ marginBottom: 10 }}>
{encounter.summary || scene.summary || "Choose what to risk."}
{Number(consequenceContext.dueCount || 0) > 0 && (
<span style={{ color: "var(--crimson)" }}> · {consequenceContext.dueCount} consequence due</span>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{actions.slice(0, 6).map((a) => (
Expand Down
95 changes: 94 additions & 1 deletion viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(),
}


Expand Down Expand Up @@ -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


Expand Down
13 changes: 13 additions & 0 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions viewer/tests/test_session_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading