From 7c4226da5800e3e68e7b712689bc520b442f7134 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 31 May 2026 05:13:39 +0700 Subject: [PATCH] fix(viewer): promote action palette into main column + render chronicle paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G3 (action palette): the action palette rendered ONLY in the 320px right rail and the right-rail map capped at actions.slice(0, 6), silently dropping the last two verbs (bonus-action + reaction). A first-time viewer (or a blind AI playtester) could miss every way to act besides free-text. - Promote the palette into the MAIN/center column, in the Chronicle panel footer directly above the Declare box, so it is the obvious primary way to act. Exploration verbs (Say/Do/Check/Continue/Cast/ Use) always show; combat verbs (Attack/Bonus/Reaction) show in an 'In Combat' group gated on actionsInCombat. - Remove the slice(0, 6) cap — every verb the read model emits renders. - actionsInCombat keys off the engine-mutated combat gauge (encounter.active / a combat verb being available), never fiction. - Right rail keeps the encounter summary + Round Order and points to the main-column palette (no divergent second button list). - Reuses EncounterButton + invokeAction + ACTION_HINTS — click path unchanged. G4 (chronicle paragraphs): the narration branch rendered {text} in a div.body with default white-space, collapsing a multi-paragraph DM beat into one run-on block. Add whiteSpace:'pre-line' so the DM's blank-line paragraph breaks render as separated paragraphs. sanitizeNarration is untouched. Tests: 3 regression tests added to OpenWorldsStaticRouteTests (palette in main column, no truncation + group split, narration preserves paragraph breaks). Full viewer suite 92/92. Verified live via the seeded /openworlds table screen (desktop + no-combat + tablet). --- viewer/openworlds/screen-table.jsx | 95 +++++++++++++++++++++----- viewer/tests/test_openworlds_static.py | 63 +++++++++++++++++ 2 files changed, 141 insertions(+), 17 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 5f067387..1fae26b4 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -281,6 +281,15 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { 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); + // #G3: split the action model by group so the MAIN-column palette can show exploration verbs + // (say/do/check/continue/cast/use) always, and combat verbs (attack/bonus/reaction) only when a + // fight is on. No truncation — every verb the read model emits renders (the old right-rail + // slice(0,6) silently dropped bonus-action + reaction). `actionsInCombat` keys off the same + // engine-mutated combat gauge the read model uses (any combat verb is enabled, or the encounter + // is flagged active) — never off fiction. + const explorationActions = actions.filter((a) => a.group !== "combat"); + const combatActions = actions.filter((a) => a.group === "combat"); + const actionsInCombat = Boolean(surface?.encounter?.active) || combatActions.some((a) => a.available); const writeLane = surface?.writeLane || { endpoint: surface?.write_lane || "/move" }; const actionContext = surface?.actionContext || {}; const consequenceContext = actionContext?.consequences || {}; @@ -657,7 +666,61 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { {pendingStuck && } - {/* Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and + {/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle + panel footer directly above the Declare box. This is the obvious, unmissable way to act + in the main play flow (it is NOT buried in the right rail any more). ALL relevant verbs + render — no slice(0,6) cap: exploration verbs (Say/Do/Check/Continue/Cast/Use) always, + combat verbs (Attack/Bonus/Reaction) in an "In Combat" group when in combat. Reuses the + EncounterButton component + invokeAction + ACTION_HINTS — the click path is unchanged. + flex 0 0 auto so it stays anchored/visible no matter how long the chronicle grows. */} +
+ Actions + {!actions.length && ( +
+ No actions are available until a campaign snapshot loads. +
+ )} + {explorationActions.length > 0 && ( +
+ {explorationActions.map((a) => ( + invokeAction(a)} + /> + ))} +
+ )} + {actionsInCombat && combatActions.length > 0 && ( + +
+ In Combat +
+
+ {combatActions.map((a) => ( + invokeAction(a)} + /> + ))} +
+
+ )} +
+ + {/* DECLARE: free-text action box — the other primary input, paired with the palette above. + Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and never pushed out of view by an ever-growing chronicle above it. */}
@@ -674,7 +737,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
{/* #337: one-line hint under the action bar so a first-timer knows free-text + Declare is the core loop, distinct from the quick-action buttons. */}
- Type freely and press Declare, or use the quick actions on the right (hover each for what it does). + Type freely and press Declare, or use the Actions above (hover each for what it does).
· {consequenceContext.dueCount} consequence due )}
-
- {actions.slice(0, 6).map((a) => ( - invokeAction(a)} - /> - ))} - {!actions.length &&
No actions are available until a campaign snapshot loads.
} + {/* #G3: the actionable palette now lives in the MAIN column beside the Declare + box (see ActionPalette below SceneHeader's chronicle panel) so it is the + obvious, primary way to act and is never buried in this side rail. This rail + keeps only the encounter framing + Round Order. */} +
+ {actions.length + ? "Your moves are in the main column, beside Declare." + : "No actions are available until a campaign snapshot loads."}
@@ -867,7 +924,11 @@ function LogEntry({ entry }) { width: 4, alignSelf: "stretch", background: "linear-gradient(180deg, var(--b-400), transparent)", }} /> -
+ {/* #G4: whiteSpace:"pre-line" honors the DM's blank-line paragraph breaks + (the sibling skill PR emits \n\n) so a multi-paragraph beat renders as + separated paragraphs instead of one run-on block. sanitizeNarration is + still applied above, untouched. */} +
Chronicle {text}
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 7053c8dd..e34eb86f 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -211,6 +211,69 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self): # The action bar is explicitly anchored (never pushed out by a growing chronicle). self.assertIn('flex: "0 0 auto"', source) + def test_openworlds_table_promotes_action_palette_into_main_column(self): + # #G3: the action palette must be PROMINENT in the main play flow, not buried in the + # 320px right rail. It is rendered in the CENTER column (LEFT — Party / CENTER — Scene + # + log / RIGHT — Quests), co-located with the free-text Declare box, so a first-time + # viewer (or a blind AI playtester) sees clickable actions without hunting in a side rail. + status, ctype, body = self._get("/openworlds/screen-table.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + # The three layout-region markers are present and ordered LEFT → CENTER → RIGHT. + left = source.index("LEFT — Party") + center = source.index("CENTER — Scene") + right = source.index("RIGHT — Quests") + self.assertLess(left, center) + self.assertLess(center, right) + # An EncounterButton palette renders inside the CENTER column (between the CENTER and + # RIGHT markers) — i.e. the palette is in the main column, not only the right rail. + first_button = source.index("Actions<", source) + self.assertIn("DECLARE: free-text action box", source) + self.assertLess(source.index(">Actions<"), source.index("DECLARE: free-text action box")) + + def test_openworlds_table_renders_all_actions_without_truncation(self): + # #G3: the palette must not silently cap the action list. The read model emits up to 8 + # verbs (exploration: say/do/check/continue/cast/use + combat: attack/bonus/reaction); + # the old right-rail `actions.slice(0, 6)` dropped bonus-action + reaction. The palette + # now splits by group and renders ALL of each group — no slice cap remains. + status, _ctype, body = self._get("/openworlds/screen-table.jsx") + + self.assertEqual(status, 200) + source = body.decode("utf-8") + # No surviving slice that truncates the action list below the full set. + self.assertNotRegex(source, r"actions\.slice\(\s*0\s*,\s*[0-7]\s*\)") + # Exploration verbs render always; combat verbs are grouped behind the in-combat gate. + self.assertIn("explorationActions", source) + self.assertIn("combatActions", source) + self.assertIn("actionsInCombat", source) + self.assertIn("explorationActions.map", source) + self.assertIn("combatActions.map", source) + # The grouping keys off the engine-mutated combat gauge (encounter.active / a combat verb + # being available), never off fiction — keeping the gates/triggers invariant. + self.assertIn("surface?.encounter?.active", source) + # The click path is unchanged: the palette still wires through invokeAction. + self.assertIn("onClick={() => invokeAction(a)}", source) + + def test_openworlds_table_chronicle_preserves_paragraph_breaks(self): + # #G4: a multi-paragraph DM beat must render as separated paragraphs, not one run-on + # block. The narration branch renders sanitized {text} in a `div.body`; with the default + # white-space the embedded blank-line paragraph breaks the DM emits collapse. The render + # honors them via whiteSpace:"pre-line" (and sanitizeNarration is still applied first). + status, _ctype, body = self._get("/openworlds/screen-table.jsx") + + self.assertEqual(status, 200) + source = body.decode("utf-8") + # The narration div opts into preserving newlines… + self.assertRegex(source, r'whiteSpace:\s*"pre-line"') + # …and the GM-advisory strip is still in the narration path (not removed by this change). + self.assertIn("sanitizeNarration(entry.text)", source) + def test_openworlds_app_bounds_the_live_session_tail(self): # #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long # session doesn't accumulate state without limit (the upstream half of the DOM-growth fix).