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
95 changes: 78 additions & 17 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const writeLane = surface?.writeLane || { endpoint: surface?.write_lane || "/move" };
const actionContext = surface?.actionContext || {};
const consequenceContext = actionContext?.consequences || {};
Expand Down Expand Up @@ -657,7 +666,61 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
{pendingStuck && <DmStuckBeat />}
</div>

{/* 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. */}
<div style={{ flex: "0 0 auto", marginTop: 14 }}>
<SectionTitle>Actions</SectionTitle>
{!actions.length && (
<div className="body-sm muted" style={{ marginTop: 4 }}>
No actions are available until a campaign snapshot loads.
</div>
)}
{explorationActions.length > 0 && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 6, marginTop: 8 }}>
{explorationActions.map((a) => (
<EncounterButton
key={`${a.group}:${a.id}`}
icon={a.available ? (a.icon || "quest.scroll") : "inventory.locked"}
label={a.label}
detail={a.available ? a.groupLabel : a.disabled_reason}
hint={ACTION_HINTS[a.id]}
tone={a.available ? "" : "crimson"}
disabled={!a.available || pendingActive}
onClick={() => invokeAction(a)}
/>
))}
</div>
)}
{actionsInCombat && combatActions.length > 0 && (
<React.Fragment>
<div className="body-sm" style={{ color: "var(--crimson)", fontFamily: "var(--f-display)", letterSpacing: "0.12em", textTransform: "uppercase", fontSize: 11, marginTop: 12, marginBottom: 6 }}>
In Combat
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 6 }}>
{combatActions.map((a) => (
<EncounterButton
key={`${a.group}:${a.id}`}
icon={a.available ? (a.icon || "combat.attack") : "inventory.locked"}
label={a.label}
detail={a.available ? a.groupLabel : a.disabled_reason}
hint={ACTION_HINTS[a.id]}
tone={a.available ? "royal" : "crimson"}
disabled={!a.available || pendingActive}
onClick={() => invokeAction(a)}
/>
))}
</div>
</React.Fragment>
)}
</div>

{/* 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. */}
<div style={{ flex: "0 0 auto", marginTop: 14, padding: 12, background: "rgba(80,50,20,0.06)", boxShadow: "inset 0 0 0 1px rgba(140,100,60,0.35)" }}>
<div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 8 }}>
Expand All @@ -674,7 +737,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
</div>
{/* #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. */}
<div className="body-xs muted" style={{ marginBottom: 6 }}>
Type freely and press <strong>Declare</strong>, or use the quick actions on the right (hover each for what it does).
Type freely and press <strong>Declare</strong>, or use the <strong>Actions</strong> above (hover each for what it does).
</div>
<div style={{ display: "flex", gap: 10 }}>
<input
Expand Down Expand Up @@ -747,20 +810,14 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
<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) => (
<EncounterButton
key={`${a.group}:${a.id}`}
icon={a.available ? (a.icon || "quest.scroll") : "inventory.locked"}
label={a.label}
detail={a.available ? a.groupLabel : a.disabled_reason}
hint={ACTION_HINTS[a.id]}
tone={a.available ? (a.group === "combat" ? "royal" : "") : "crimson"}
disabled={!a.available || pendingActive}
onClick={() => invokeAction(a)}
/>
))}
{!actions.length && <div className="body-sm muted">No actions are available until a campaign snapshot loads.</div>}
{/* #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. */}
<div className="body-sm muted" style={{ marginBottom: 4 }}>
{actions.length
? "Your moves are in the main column, beside Declare."
: "No actions are available until a campaign snapshot loads."}
</div>

<div className="divider" style={{ margin: "14px 0" }}>
Expand Down Expand Up @@ -867,7 +924,11 @@ function LogEntry({ entry }) {
width: 4, alignSelf: "stretch",
background: "linear-gradient(180deg, var(--b-400), transparent)",
}} />
<div className="body" style={{ flex: 1 }}>
{/* #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. */}
<div className="body" style={{ flex: 1, whiteSpace: "pre-line" }}>
<span className="eyebrow" style={{ color: "var(--crimson)", marginRight: 8 }}>Chronicle</span>
{text}
</div>
Expand Down
63 changes: 63 additions & 0 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<EncounterButton")
self.assertGreater(first_button, center)
self.assertLess(first_button, right)
# The palette sits with the Declare box (the primary input) in the main action flow.
self.assertIn(">Actions<", source)
self.assertIn("DECLARE: free-text action box", source)
self.assertLess(source.index(">Actions<"), source.index("DECLARE: free-text action box"))

Comment on lines +214 to +239

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update these assertions to match the actual JSX in this PR.

screen-table.jsx does not currently contain <EncounterButton, >Actions<, DECLARE: free-text action box, explorationActions.map, combatActions.map, or onClick={() => invokeAction(a)}. This block will fail as-is because the checks are asserting against source that is not present here.

Also applies to: 240-261

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/tests/test_openworlds_static.py` around lines 214 - 239, The test
asserts for JSX tokens that no longer exist; update the assertions in
test_openworlds_table_promotes_action_palette_into_main_column to check for the
actual component/strings in the current screen-table.jsx instead of
"<EncounterButton", ">Actions<", "DECLARE: free-text action box",
"explorationActions.map", "combatActions.map", and "onClick={() =>
invokeAction(a)}"; find and replace those expected markers with the real JSX
identifiers used in this PR (the component/prop names rendered for the action
palette and declare box) and make the same corresponding changes in the
duplicate assertion block referenced around lines 240-261 so both tests match
the current JSX output.

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).
Expand Down
Loading