Skip to content

feat(viewer): 4 sat→7 web-viewer UX/correctness fixes (battle-log sanitize, dice-header strip, roll context, drop-confirm)#1161

Open
100yenadmin wants to merge 2 commits into
mainfrom
feat/sat-viewer-ux-fixes
Open

feat(viewer): 4 sat→7 web-viewer UX/correctness fixes (battle-log sanitize, dice-header strip, roll context, drop-confirm)#1161
100yenadmin wants to merge 2 commits into
mainfrom
feat/sat-viewer-ux-fixes

Conversation

@100yenadmin

Copy link
Copy Markdown
Member

Summary

Four web-viewer sat→7 fixes from the binding-sweep per-persona friction analysis. All ADDITIVE — they extend/reuse the existing chronicle sanitizer and established patterns (window.confirm, window.sanitizeNarration), nothing rewritten. The engine stays the sole writer; these are read/projection + UX guards only.

Scope note: the task framed all four as living in screen-table.jsx. Two actually render elsewhere, so the fix lands where the panel actually is — the Battle Log is screen-combat.jsx (confirmed: grep "Battle" screen-table.jsx → 0 hits) and the inventory Drop is screen-inventory.jsx. The sanitizer extensions (fix 2) + the dice-button context (fix 3) are in screen-table.jsx.

1. Battle-Log sanitizer coverage (adversarial friction)

BattleLogLine (screen-combat.jsx) rendered l.title/l.text RAW, so DM engine meta-text — a wrapper-progress line ("Momentum carries through the scene…", "Your choice takes hold…"), a roll-summary header, or other scaffolding — leaked into the combat log because the Chronicle's guard never reached this panel. It now routes both fields through the same window-exported sanitizeNarration the Chronicle uses, with the established defensive window.sanitizeNarration fallback. Real combat prose + the engine title + dice-meta badges survive intact.

2. Dice-result-header strip (narrative's only major — "cracked the 9/10")

Extended the scaffolding stripper to drop:

  • (a) the roll-result-summary header — "The intimidation lands at 18; the quiet interpose at 16." — incl. chained "; … at N" continuations, matched at the line level so the ;-splitter can't peel a clause through;
  • (b) a leading / standalone Markdown horizontal rule ("---", "***", "___", "— —"), plus a trailing rule tacked after a header.

High-confidence by design: the trigger requires a roll-summary VERB ("lands/settles/comes in/resolves at") bound to a bare integer. Genuine prose — "the bell tower strikes at 12", "we meet at 3", "the arrow lands at his feet", "the cat lands at her side", em-dash asides — passes through verbatim. A dedicated negative-prose suite locks this in.

3. Die-roll button context (adversarial minor)

The d20/d12/d8/d6 buttons fired a contextless "requests a dN roll", bypassing the "Type a move before declaring" guard. They now bind the roll to the player's typed intent if present, else attach the latest narrative line as context, so the DM never resolves a bare die — parity with the Declare text-guard. Logic extracted to a pure, unit-tested composeRollMove.

4. Drop-confirm on equipped gear (adversarial minor)

A one-click Drop on equipped body armor was irreversible with no confirmation. Both Drop affordances (context-menu + detail pane) now gate behind window.confirm only for currently-equipped items (isItemEquipped); loose stash items are never nagged. Same window.confirm pattern as quickload / seed-reset.

Tests

  • test_sanitize_narration.py (extended): dice-header strip (single / chained / triple-chain / trailing-rule), leading & standalone horizontal rules, and a hardened negative-prose suite (number-after-"at" with no roll verb, roll-verb with a noun target, em-dash asides).
  • test_battle_log_sanitize.py (new): renders the REAL BattleLogLine (transpiles screen-table.jsx + screen-combat.jsx into one sandbox, mirroring the sibling browser <script> tags) — asserts meta-text/roll-header/title-scaffolding are stripped AND real combat text + meta badges survive.
  • test_viewer_ux_guards.py (new): composeRollMove (typed-intent vs. attached-context vs. graceful floor) + isItemEquipped/dropEquippedConfirmMessage (equipped flagged, loose never nagged, defensive).

All exercise the SHIPPED .jsx via the bundled Babel under Node — no reimplementation.

Validation

GitHub Actions CI is currently billing-locked (jobs won't start) — validated locally:

  • New + extended tests: 70 passed.
  • Full viewer suite: 786 passed, 8 skipped, 1 failed — the sole failure is test_portrait_gen.py (ModuleNotFoundError: No module named 'pydantic'), a pre-existing env gap on origin/main unrelated to this change (touches only server-side portrait subprocess; my diff is JSX + tests).

PR opens for the merge queue when CI returns.

…itize, dice-header strip, roll context, drop-confirm)

From the binding-sweep per-persona friction analysis:

1. BATTLE-LOG SANITIZER COVERAGE (adversarial friction): the combat Battle Log
   rendered l.title/l.text RAW, so DM engine meta-text (wrapper-progress lines,
   roll-summary headers, scaffolding) leaked there — the Chronicle's guard never
   reached this panel. BattleLogLine now routes both fields through the SAME
   window-exported sanitizeNarration (screen-combat.jsx).

2. DICE-RESULT-HEADER STRIP (narrative's only major — 'cracked the 9/10'): extend
   the sanitizer to drop (a) the roll-result-summary header form ('… lands at 18;
   the quiet interpose at 16.') and (b) a leading/standalone Markdown horizontal
   rule ('---'/'***'/'___'/'— —'). Verb+bare-integer anchored and whole-line for the
   chained header so genuine prose ('the bell strikes at 12', 'the arrow lands at his
   feet', em-dash asides) is never eaten (screen-table.jsx).

3. DIE-ROLL BUTTON CONTEXT (adversarial minor): the d20/d12/d8/d6 buttons fired a
   contextless 'requests a dN roll', bypassing the Declare guard. They now bind the
   roll to the player's typed intent, else attach the latest narrative line — parity
   with the Declare text-guard (composeRollMove, screen-table.jsx).

4. DROP-CONFIRM ON EQUIPPED GEAR (adversarial minor): a one-click Drop on equipped
   body armor was irreversible. Both Drop affordances (context-menu + detail pane)
   now confirm via window.confirm ONLY for currently-equipped items; loose stash
   items are never nagged (isItemEquipped, screen-inventory.jsx).

Tests: extend test_sanitize_narration.py (dice-header + leading-rule strip + a
hardened negative-prose suite), add test_battle_log_sanitize.py (BattleLogLine
meta-text strip + real-combat-text survival) and test_viewer_ux_guards.py
(composeRollMove context + isItemEquipped/drop-confirm). Local viewer suite green
(only pre-existing test_portrait_gen pydantic-env failure is unrelated).
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Priority Level: P1

Summary

This PR introduces four additive UX and correctness fixes in the web viewer to prevent DM/engine meta-text leakage and to reduce player friction around dice requests and irreversible item drops. Changes extend existing sanitization/UX patterns, extract non-UI logic into pure/unit-testable helpers, and add targeted automated coverage (70 new/extended tests; 786 total passing).

Changes Overview

1. Battle Log Sanitizer Coverage (viewer/openworlds/screen-combat.jsx)

The BattleLogLine renderer now sanitizes both l.title and l.text through window.sanitizeNarration (with a defensive fallback to the raw strings if the sanitizer is not available yet). This prevents wrapper/projection/meta-text from leaking into the combat log.

Required before merge: No issues found.

2. Dice-result-header Strip + Horizontal-rule Cleanup (viewer/openworlds/screen-table.jsx)

sanitizeNarration is extended to remove:

  • roll-result summary headers (e.g., “lands/comes in/settles at ”), including chained continuations, anchored so the integer must behave like a clause-terminal roll header (e.g., ...at \d+(?=[.;,!?]|\s*$)), reducing false positives on in-world prose like “falls at 12 men”
  • leading/standalone Markdown horizontal rules (---, ***, ___, and em-dash divider variants), plus trailing rule glyphs after stripped roll-summary headers

The sentence/scaffolding stripping logic is also adjusted so roll-summary headers are treated as whole-sentence scaffolding and removed before fragmentary clause splitting could preserve partial continuations.

Required before merge: No issues found.

3. Die-roll Button Context (viewer/openworlds/screen-table.jsx)

Dice buttons now use a shared pure helper, composeRollMove(sides, intent, context), to avoid contextless roll requests:

  • prefers the player’s typed intent (draftText) as the move reason
  • otherwise attaches the latest sanitized streamed narration line as context
  • mirrors the intent behavior of the Declare flow; clears the input after posting when typed intent is present
    composeRollMove is exposed on window.

Required before merge: No issues found.

4. Drop-confirm on Equipped Gear (viewer/openworlds/screen-inventory.jsx)

Dropping currently equipped (“worn”) items now requires confirmation:

  • adds isItemEquipped(item, equipped) and dropEquippedConfirmMessage(name) as shared pure helpers
  • gates both “Drop” affordances (context-menu and detail pane) so unequipped stash drops never prompt
  • confirmation copy applies only when the item is currently equipped
    Helpers are exported to window for reuse.

Required before merge: No issues found.

Test Execution

  • viewer/tests/test_sanitize_narration.py: expanded sanitizer coverage for roll-summary header stripping, chained continuations, and horizontal-rule cleanup, including negative/false-positive prose cases
  • viewer/tests/test_battle_log_sanitize.py: validates actual BattleLogLine rendering output after sanitization
  • viewer/tests/test_viewer_ux_guards.py: unit tests for composeRollMove, isItemEquipped, and the equipped-item drop confirmation message

Result: 70 new/extended tests passing; 786 viewer suite tests passing (1 unrelated pre-existing failure).

Walkthrough

Extends sanitizeNarration in screen-table.jsx to strip Markdown HR dividers and DM roll-result-summary headers. Applies this sanitizer to BattleLogLine in screen-combat.jsx. Adds a composeRollMove helper wired into requestRoll. Guards irreversible drops of equipped inventory items behind window.confirm. Adds Python and Node/Babel tests covering all new behavior.

Changes

Narration Sanitization, Roll-Move Composition, and Equipped-Drop Guard

Layer / File(s) Summary
HR and roll-summary regex patterns and scaffolding integration
viewer/openworlds/screen-table.jsx
Adds _HRULE_LINE regex to _isInternalLine to drop divider lines. Introduces roll-summary header regexes to match dice result phrases and chains them into scaffolding classification (_isScaffoldingSentence, _hasScaffolding, _stripScaffoldingSentences). Adds post-cleanup to strip trailing HR glyphs after scaffolding removal.
BattleLogLine sanitization
viewer/openworlds/screen-combat.jsx
Routes l.title/l.text through a sanitize helper backed by window.sanitizeNarration (raw fallback) before rendering; previously displayed raw strings without sanitization.
composeRollMove + requestRoll wiring
viewer/openworlds/screen-table.jsx
Adds composeRollMove(sides, intent, context) generating contextful move payload and echo string; exports on window. Updates requestRoll to use it with draftText as intent and latestStreamedLine as context fallback, clearing input when intent was typed.
Equipped-drop confirmation guard
viewer/openworlds/screen-inventory.jsx
Adds isItemEquipped(item, equipped) to detect worn gear by slug-matching, dropEquippedConfirmMessage(name) for shared confirmation text, and confirmDrop wrapper gating drops behind window.confirm. Updates context-menu and detail-panel Drop actions to use confirmDrop. Exports new helpers on window.
sanitizeNarration unit tests
viewer/tests/test_sanitize_narration.py
Five new test functions: roll-summary header stripping with HR variants, surrounding-prose preservation, standalone HR removal (including em-dash forms), false-positive guard for legitimate fiction phrases, and edge-case roll verbs with in-world quantities.
BattleLogLine integration tests
viewer/tests/test_battle_log_sanitize.py
Node+Babel VM harness and tests rendering BattleLogLine rows to assert wrapper/projection META-TEXT is stripped and genuine combat fields survive.
UX guard unit tests
viewer/tests/test_viewer_ux_guards.py
Node/Babel VM harness and tests for composeRollMove intent/context/default-sides behavior and isItemEquipped/dropEquippedConfirmMessage matching, defensive, and copy-content semantics.

Sequence Diagram(s)

sequenceDiagram
  participant Player
  participant requestRoll
  participant composeRollMove
  participant postMove

  Player->>requestRoll: clicks dN button
  alt intent typed
    requestRoll->>composeRollMove: sides, draftText, latestStreamedLine
  else intent blank
    requestRoll->>composeRollMove: sides, empty string, latestStreamedLine
  end
  composeRollMove-->>requestRoll: move payload and echo text
  requestRoll->>postMove: POST move
  requestRoll-->>Player: display echo, clear if intent was typed
Loading
sequenceDiagram
  participant Player
  participant DropAction
  participant confirmDrop
  participant isItemEquipped

  Player->>DropAction: clicks Drop
  DropAction->>confirmDrop: item
  confirmDrop->>isItemEquipped: item, equipped list
  isItemEquipped-->>confirmDrop: boolean equipped flag
  alt item is equipped
    confirmDrop->>Player: window.confirm message
    Player-->>confirmDrop: OK or Cancel
  end
  alt confirmed or loose item
    confirmDrop->>DropAction: POST drop move
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • electricsheephq/WorldOS#343: Exports sanitizeNarration on window from screen-table.jsx, which this PR consumes in screen-combat.jsx's BattleLogLine sanitizer.
  • electricsheephq/WorldOS#979: Adds _AUTHORING_PREAMBLE whole-sentence scaffolding removal to the same pipeline that this PR extends with roll-summary header and HR stripping.
  • electricsheephq/WorldOS#629: Modifies the same sanitizeNarration scaffolding/sentence-stripping logic in screen-table.jsx and updates test_sanitize_narration.py at the same code paths this PR extends.

Agent Review Notes — Confidence Scores and Adversarial Findings

[95% confidence] window.sanitizeNarration availability race in BattleLogLine

Root cause: screen-combat.jsx reads window.sanitizeNarration at render time with a raw-string fallback (window.sanitizeNarration?.(s) ?? s). If screen-table.jsx is not yet evaluated when BattleLogLine first renders (e.g., bundle split, lazy load ordering, or parallel module execution), every log line silently falls back to raw unsanitized output with no warning.

Code path: BattleLogLine (line ~760) → local sanitize helper → window.sanitizeNarration?.(s) ?? s → returns s unfiltered if function is undefined.

Impact: DM/engine meta-text (roll-summary headers, scaffolding markers) leaks to players in the exact scenario this PR aims to prevent, with zero observable error or console warning.

Fix: Validate that window.sanitizeNarration is registered at screen-combat.jsx module init time (e.g., assert/log a one-time warning at load), rather than silently degrading per render call. Alternatively, import the sanitizer directly from screen-table.jsx or import a stub at bundle time to guarantee presence.


[90% confidence] isItemEquipped slug-matching may produce false positives with modifier suffixes

Root cause: isItemEquipped compares slugged item.name against slugged equipped item names. The test suite asserts that items with "+1" modifiers are not incorrectly matched to bare item names, but the actual slug() implementation is not shown. If the slug function strips punctuation (common in "slug" operations), "Sword +1" and "Sword" could produce the same slug and incorrectly return true, causing a non-equipped item to be gated by the confirmation prompt.

Code path: isItemEquipped (line ~432–452) → slug(item.name) vs slug(equippedItem.name) → item match.

Impact: A player attempting to drop a "+1 Sword" that is not currently equipped could be shown a spurious "drop this equipped item?" confirmation, confusing UX and potentially blocking intended drops.

Fix: Ensure the slug function preserves modifier-distinguishing characters ("+", "−", alphanumeric variants) or compare against a canonical item ID/reference rather than a derived name slug. Add a test case with modifiers to verify non-match.


[85% confidence] _HRULE_LINE regex may over-strip em-dash narrative text

Root cause: _isInternalLine is extended to match standalone HR dividers including em-dash forms (, spaced — — —, etc.). If the regex is anchored too loosely, narrative prose using em-dashes as stylistic pause separators in actual fiction (e.g., "She paused———then spoke.") could match and be dropped as a divider instead of being preserved as narration.

Code path: _isInternalLine (lines ~68–82) → _HRULE_LINE.test(line) → line is removed if internal.

Impact: Legitimate em-dash narrative separators or stylistic punctuation in genuine fiction could silently disappear from the player chronicle, degrading story fidelity.

Fix: Confirm the _HRULE_LINE regex requires the line to consist only of the HR glyphs and whitespace with no surrounding word characters or prose (anchor with ^ and $, exclude word boundaries). Add a regression test with an em-dash glyph immediately followed by prose to verify it is not dropped.


[80% confidence] composeRollMove uses latestStreamedLine as context without sanitizing it first

Root cause: requestRoll (line ~1287–1303) feeds latestStreamedLine as the context argument to composeRollMove when draftText is absent. If latestStreamedLine contains unsanitized DM-internal text (e.g., a freshly-received roll-summary header that hasn't been processed by the chronicle sanitizer yet), that raw text is embedded in the roll move payload sent to the engine and echoed to the player.

Code path: requestRollcomposeRollMove(sides, draftText || "", latestStreamedLine)move.text = "I roll a d${sides} in response to: " + context → context may contain unsanitized meta-text.

Impact: DM/engine internal roll-result summaries, scaffolding stage-direction, or wrapper meta-text could be injected into player-visible roll move payloads and echoed back, leaking DM-internal bookkeeping into the move record.

Fix: Run latestStreamedLine through sanitizeNarration before passing it as context to composeRollMove, or ensure latestStreamedLine is already sanitized at the point of capture.


[75% confidence] confirmDrop does not validate equipped parameter structure

Root cause: confirmDrop (line ~106–118) calls isItemEquipped(item, equipped) without checking that the equipped parameter is a valid list/array. If the caller passes null, undefined, or a non-iterable value, isItemEquipped may throw or silently fail.

Code path: confirmDropisItemEquipped(item, equipped) → depends on equipped being iterable.

Impact: A race condition or state inconsistency where equipped is not populated could cause confirmDrop to throw at runtime when a player clicks Drop, leaving the UI in an inconsistent state or requiring a page reload.

Fix: Add defensive checks in confirmDrop or isItemEquipped to handle null/undefined/non-iterable equipped, defaulting to false (treat as loose item) and optionally logging a warning. Tests show defensive behavior for invalid inputs, so this may already be covered; confirm by reviewing the actual test assertions.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title concisely summarizes four distinct fixes (battle-log sanitize, dice-header strip, roll context, drop-confirm) with clear identifiers; accurately reflects the changeset's scope and purpose.
Description check ✅ Passed Description comprehensively covers all four fixes with technical depth, includes test strategy, provides local validation results, and addresses licensing via the template structure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sat-viewer-ux-fixes

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added enhancement New feature or request screen:inventory Pertains to the inventory screen labels Jun 23, 2026
…ld quantities)

The dice-result-header stripper (_ROLL_RESULT_SUMMARY / _ROLL_SUMMARY_HEADER_LINE)
was verb- AND number-anchored but matched any '<roll verb> at <N>', eating the whole
narration line even when <N> was a real in-world quantity, not a check total
('the line falls at 12 men', 'settles at 5 gold', 'comes in at 40 pounds').

A roll TOTAL always ends its clause ('lands at 18.' / 'lands at 18;'), never
'lands at 18 men'. Require the integer to be CLAUSE-TERMINAL via a shared
_ROLL_VERB_AT_N core: '...at \d+(?=[.;,!?]|\s*$)' on the primary AND every
chained '; ... at N' continuation. Genuine fiction (number-then-noun) now survives
verbatim; the real header (single / chained / triple-chain / trailing-rule) is
still stripped, as is the leading/trailing rule.

Adds test_roll_verb_with_in_world_quantity_survives_verbatim (6 fixtures) and keeps
the existing positive strip + FP-guard tests green (18 tests / 62 subtests pass).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@viewer/openworlds/screen-inventory.jsx`:
- Around line 436-441: The isItemEquipped function relies solely on slugged name
matching to determine if an item is equipped, which causes false positives when
a hero has both an equipped copy and a loose copy with identical names. Update
the function to use stable identity matching first: check if both the item and
equipped entries have an id property and compare those for equality; only fall
back to the current slug-based name comparison when id is not available on
either side. Additionally, add a regression test that covers the scenario where
a hero has the same named item in both equipped and loose inventory to prevent
this behavior from breaking in the future.

In `@viewer/openworlds/screen-table.jsx`:
- Around line 337-343: The echo variable does not include the fallback context
handling that the text variable provides. The text variable uses a nested
ternary to fall back to ctx when want is not provided, but the echo variable
only checks for want and does not include this ctx fallback. Update the echo
constant to mirror the same conditional structure as text, ensuring that when
want is falsy but ctx is truthy, the echo includes the context information in
the format rolls a d${n} (in response to: ${ctx}).
- Line 74: The _HRULE_LINE regex pattern at line 74 should be used consistently
throughout the code to identify horizontal rules. Review the logic at lines 149,
162, 256, and 294 where roll-total boundaries and rule detection occur. Tighten
the "lands at <number>" detection to only match when the pattern represents a
complete roll result, not when it appears within regular prose sentences.
Replace the partial or separate horizontal rule detection logic at lines 162 and
294 with calls to the full _HRULE_LINE pattern to ensure consistent matching of
horizontal rules at the whole-line level, preventing legitimate sentence content
like "The dragon lands at 12 feet from you" from being incorrectly removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 764fddb4-dc2f-4eae-ad2c-0a02fcb68353

📥 Commits

Reviewing files that changed from the base of the PR and between 3093650 and 1b1f6ec.

📒 Files selected for processing (6)
  • viewer/openworlds/screen-combat.jsx
  • viewer/openworlds/screen-inventory.jsx
  • viewer/openworlds/screen-table.jsx
  • viewer/tests/test_battle_log_sanitize.py
  • viewer/tests/test_sanitize_narration.py
  • viewer/tests/test_viewer_ux_guards.py
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.44.0)
viewer/tests/test_viewer_ux_guards.py

[error] 57-59: Command coming from incoming request
Context: subprocess.run(
[_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[info] 42-42: use jsonify instead of json.dumps for JSON output
Context: json.dumps(str(f))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 46-46: use jsonify instead of json.dumps for JSON output
Context: json.dumps(str(BABEL))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 72-72: use jsonify instead of json.dumps for JSON output
Context: json.dumps(sides)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 72-72: use jsonify instead of json.dumps for JSON output
Context: json.dumps(intent)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 72-72: use jsonify instead of json.dumps for JSON output
Context: json.dumps(context)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 123-123: use jsonify instead of json.dumps for JSON output
Context: json.dumps(item)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 123-123: use jsonify instead of json.dumps for JSON output
Context: json.dumps(equipped)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

viewer/tests/test_battle_log_sanitize.py

[error] 70-72: Command coming from incoming request
Context: subprocess.run(
[_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[info] 44-44: use jsonify instead of json.dumps for JSON output
Context: json.dumps(str(BABEL))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 58-58: use jsonify instead of json.dumps for JSON output
Context: json.dumps(str(SCREEN_TABLE))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 59-59: use jsonify instead of json.dumps for JSON output
Context: json.dumps(str(SCREEN_COMBAT))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 66-66: use jsonify instead of json.dumps for JSON output
Context: json.dumps(rows)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

viewer/openworlds/screen-table.jsx

[warning] 157-163: Detects non-literal values in regular expressions
Context: new RegExp(
"^\s*[^.!?]?" +
"\b(?:lands?|comes?(?:\s+in)?|falls?|settles?|resolves?|clears?)\s+(?:in\s+)?at\s+\d+\b" +
"(?:[^.!?]
?\bat\s+\d+\b)" +
"\s
[.!?]?\s*(?:-{3,}|\{3,}|_{3,}|—{2,})?\s$",
"i",
)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

🪛 React Doctor (0.5.8)
viewer/openworlds/screen-combat.jsx

[warning] 764-764: sanitize inside BattleLogLine uses no local state but is rebuilt on every render, so it wastes work & breaks memoized children. Move it to the top of the file, outside the component.

Move the function above the component, at the top of the file. It doesn't use local state, so rebuilding it each update is wasted work.

(prefer-module-scope-pure-function)


[warning] 772-772: Your users strain to read 10px text, so use at least 12px for body text, & 16px is best.

Use at least 12px for body text, and 16px is best. Small text is hard to read, especially on phones.

(no-tiny-text)

🪛 Ruff (0.15.18)
viewer/tests/test_viewer_ux_guards.py

[warning] 43-43: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[warning] 47-47: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[error] 58-58: subprocess call: check for execution of untrusted input

(S603)


[warning] 58-58: subprocess.run without explicit check argument

Add explicit check=False

(PLW1510)


[warning] 62-62: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 71-72: Explicitly concatenated string should be implicitly concatenated

Remove redundant '+' operator to implicitly concatenate

(ISC003)


[warning] 73-73: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[warning] 122-123: Explicitly concatenated string should be implicitly concatenated

Remove redundant '+' operator to implicitly concatenate

(ISC003)


[warning] 124-124: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[warning] 158-159: Explicitly concatenated string should be implicitly concatenated

Remove redundant '+' operator to implicitly concatenate

(ISC003)

viewer/tests/test_sanitize_narration.py

[warning] 311-311: Loop control variable original not used within loop body

Rename unused original to _original

(B007)


[warning] 311-311: When using only the keys of a dict use the keys() method

Replace .items() with .keys()

(PERF102)

viewer/tests/test_battle_log_sanitize.py

[warning] 45-45: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[warning] 59-59: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[warning] 60-60: Use format specifiers instead of percent format

Replace with format specifiers

(UP031)


[error] 71-71: subprocess call: check for execution of untrusted input

(S603)


[warning] 71-71: subprocess.run without explicit check argument

Add explicit check=False

(PLW1510)


[warning] 75-75: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 126-126: Assertion should be broken down into multiple parts

Break down assertion into multiple parts

(PT018)


[warning] 127-127: Assertion should be broken down into multiple parts

Break down assertion into multiple parts

(PT018)

🔇 Additional comments (3)
viewer/openworlds/screen-combat.jsx (1)

757-775: LGTM!

viewer/tests/test_sanitize_narration.py (1)

297-368: LGTM!

viewer/tests/test_battle_log_sanitize.py (1)

1-135: LGTM!

Comment on lines +436 to +441
function isItemEquipped(item, equipped) {
if (!item || !item.name || !Array.isArray(equipped)) return false;
const target = slug(item.name);
if (!target) return false;
return equipped.some((e) => e && e.name && slug(e.name) === target);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Name-based equip detection will false-positive on duplicate item names

Lines 436-440 use slugged-name equality as the equip identity. If a hero has one equipped copy and one loose copy with the same name, dropping the loose copy still triggers the equipped confirm path. That breaks the “confirm only on currently equipped items” contract in both Drop flows.

Use stable item identity (id/instance key) first, and only fall back to name matching when no identity exists on either side. Also add a regression test for “equipped + loose same-name item” to lock this behavior.
Confidence: 95%.

Proposed fix (ID-first matching)
 function isItemEquipped(item, equipped) {
-  if (!item || !item.name || !Array.isArray(equipped)) return false;
-  const target = slug(item.name);
-  if (!target) return false;
-  return equipped.some((e) => e && e.name && slug(e.name) === target);
+  if (!item || !Array.isArray(equipped)) return false;
+
+  const itemId = item.id ?? item.item_id ?? null;
+  if (itemId !== null) {
+    return equipped.some((e) => e && (e.id === itemId || e.item_id === itemId));
+  }
+
+  if (!item.name) return false;
+  const target = slug(item.name);
+  if (!target) return false;
+  return equipped.some((e) => {
+    if (!e || !e.name) return false;
+    const equippedId = e.id ?? e.item_id ?? null;
+    return equippedId === null && slug(e.name) === target;
+  });
 }
🤖 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/openworlds/screen-inventory.jsx` around lines 436 - 441, The
isItemEquipped function relies solely on slugged name matching to determine if
an item is equipped, which causes false positives when a hero has both an
equipped copy and a loose copy with identical names. Update the function to use
stable identity matching first: check if both the item and equipped entries have
an id property and compare those for equality; only fall back to the current
slug-based name comparison when id is not available on either side.
Additionally, add a regression test that covers the scenario where a hero has
the same named item in both equipped and loose inventory to prevent this
behavior from breaking in the future.

Comment thread viewer/openworlds/screen-table.jsx
Comment on lines +337 to +343
const text = want
? `I roll a d${n} to ${want}`
: ctx
? `I roll a d${n} (in response to: ${ctx})`
: `I roll a d${n}`;
const echo = want ? `rolls a d${n} to ${want}` : `rolls a d${n}`;
return { move: { kind: "check", name: `d${n}`, text }, echo };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include fallback context in the optimistic echo.

Confidence: 88%. The fallback path puts latestStreamedLine into move.text, but Line 342 still returns the bare echo rolls a d${n}. That means the Chronicle/pending/retry label remains contextless even though Line 1290 says the echo should never read as a bare die.

Proposed fix
-  const echo = want ? `rolls a d${n} to ${want}` : `rolls a d${n}`;
+  const echo = want
+    ? `rolls a d${n} to ${want}`
+    : ctx
+      ? `rolls a d${n} in response to: ${ctx}`
+      : `rolls a d${n}`;

Also applies to: 1289-1292

🤖 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/openworlds/screen-table.jsx` around lines 337 - 343, The echo variable
does not include the fallback context handling that the text variable provides.
The text variable uses a nested ternary to fall back to ctx when want is not
provided, but the echo variable only checks for want and does not include this
ctx fallback. Update the echo constant to mirror the same conditional structure
as text, ensuring that when want is falsy but ctx is truthy, the echo includes
the context information in the format rolls a d${n} (in response to: ${ctx}).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@viewer/openworlds/screen-table.jsx`:
- Around line 156-171: The regex patterns _ROLL_VERB_AT_N and
_ROLL_SUMMARY_HEADER_LINE contain a comma in their terminal lookahead assertion
(?=[.;,!?]|\\s*$), which causes the unanchored _ROLL_RESULT_SUMMARY test to
incorrectly match genuine prose sentences ending with "at <number>," and
classify them as scaffolding for removal. Remove the comma from the lookahead in
both patterns so the assertion becomes (?=[.;!?]|\\s*$), which will prevent
matching sentences with number-comma terminators while still matching valid
roll-header terminators.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b2dbc3d0-adb5-4e81-98fe-f3636d166bf4

📥 Commits

Reviewing files that changed from the base of the PR and between 1b1f6ec and 2e0733d.

📒 Files selected for processing (2)
  • viewer/openworlds/screen-table.jsx
  • viewer/tests/test_sanitize_narration.py
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.44.0)
viewer/openworlds/screen-table.jsx

[warning] 157-157: Detects non-literal values in regular expressions
Context: new RegExp(_ROLL_VERB_AT_N, "i")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

🔇 Additional comments (2)
viewer/openworlds/screen-table.jsx (1)

1293-1303: LGTM!

viewer/tests/test_sanitize_narration.py (1)

370-389: LGTM!

Comment on lines +156 to +171
const _ROLL_VERB_AT_N =
"\\b(?:lands?|comes?(?:\\s+in)?|falls?|settles?|resolves?|clears?)\\s+(?:in\\s+)?at\\s+\\d+(?=[.;,!?]|\\s*$)";
const _ROLL_RESULT_SUMMARY = new RegExp(_ROLL_VERB_AT_N, "i");
// The FULL roll-summary HEADER LINE — the verb-anchored primary PLUS any "; <thing> at <N>" continuations
// chained onto it ("The intimidation lands at 18; the quiet interpose at 16."). This is matched at the LINE
// level (before the per-sentence splitter fragments it on the ";"), so the whole header — including its
// trailing clauses and an optional trailing rule — is dropped as a unit. It REQUIRES the leading roll verb,
// so it can only extend a genuine roll-summary; a standalone "; we meet at 3" never has the primary verb and
// is never reached. Anchored to the whole line ('^…$') so it only fires on a header, not a mid-prose clause.
// Each "at <N>" (primary AND every chained continuation) is clause-terminal — the same total-ends-the-clause
// rule as `_ROLL_VERB_AT_N` — so a header whose number is followed by a noun ("falls at 12 men …") never
// matches and the genuine-fiction line survives whole.
const _ROLL_SUMMARY_HEADER_LINE = new RegExp(
"^\\s*[^.!?]*?" +
_ROLL_VERB_AT_N +
"(?:[^.!?]*?\\bat\\s+\\d+(?=[.;,!?]|\\s*$))*" +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for roll-summary header formatting in engine/prompt sources to confirm separator style.
rg -nP -C2 '(lands?|comes?|falls?|settles?|resolves?|clears?)\s+(in\s+)?at\s+\d+' -g '!viewer/tests/**'

Repository: electricsheephq/WorldOS

Length of output: 161


🏁 Script executed:

# Verify the file and code at the specified lines
cat -n viewer/openworlds/screen-table.jsx | sed -n '156,171p'

Repository: electricsheephq/WorldOS

Length of output: 1535


🏁 Script executed:

# Find test files related to roll scaffolding and check for comma-terminal tests
fd -e 'jsx' -e 'js' -e 'test' -e 'spec' . viewer/tests/ | head -20

Repository: electricsheephq/WorldOS

Length of output: 161


🏁 Script executed:

# Search for uses of _isScaffoldingSentence and _hasScaffolding to understand how the regex is used
rg -n '_isScaffoldingSentence|_hasScaffolding' viewer/openworlds/

Repository: electricsheephq/WorldOS

Length of output: 643


🏁 Script executed:

# Look for test cases that explicitly test the roll verb patterns
rg -n '_ROLL_VERB_AT_N|_ROLL_RESULT_SUMMARY|_ROLL_SUMMARY_HEADER_LINE' -A5 -B5 viewer/tests/

Repository: electricsheephq/WorldOS

Length of output: 161


🏁 Script executed:

# View the _isScaffoldingSentence function to understand how _ROLL_RESULT_SUMMARY is used
sed -n '262,280p' viewer/openworlds/screen-table.jsx

Repository: electricsheephq/WorldOS

Length of output: 1285


🏁 Script executed:

# View the _hasScaffolding function
sed -n '268,275p' viewer/openworlds/screen-table.jsx

Repository: electricsheephq/WorldOS

Length of output: 675


🏁 Script executed:

# Find test files - may be in different locations
find viewer -type f \( -name '*.test.js' -o -name '*.test.jsx' -o -name '*.spec.js' -o -name '*test*.js' \)

Repository: electricsheephq/WorldOS

Length of output: 161


🏁 Script executed:

# Search more broadly for test-related patterns including "test_roll_verb" mentioned in review
rg -n 'test_roll_verb|roll.*survives|scaffolding' viewer/ --type js --type jsx

Repository: electricsheephq/WorldOS

Length of output: 189


🏁 Script executed:

# Search for test files more broadly without -t flag
find viewer -type f -name '*test*' -o -name '*spec*' 2>/dev/null | head -30

Repository: electricsheephq/WorldOS

Length of output: 1330


🏁 Script executed:

# Search for the specific test mentioned in review: test_roll_verb_with_in_world_quantity_survives_verbatim
rg -n 'test_roll_verb_with_in_world_quantity_survives_verbatim|in_world_quantity' viewer/

Repository: electricsheephq/WorldOS

Length of output: 276


🏁 Script executed:

# Look at the full _stripScaffoldingSentences function to understand the code path
sed -n '276,310p' viewer/openworlds/screen-table.jsx

Repository: electricsheephq/WorldOS

Length of output: 2286


🏁 Script executed:

# View the test that reviewer mentioned
sed -n '370,395p' viewer/tests/test_sanitize_narration.py

Repository: electricsheephq/WorldOS

Length of output: 2111


🏁 Script executed:

# Look for other roll-verb related tests to see what terminals they use
rg -n 'falls.*at|lands.*at|settles.*at|resolves.*at' viewer/tests/test_sanitize_narration.py -A2 -B2

Repository: electricsheephq/WorldOS

Length of output: 5017


Remove comma from roll-verb terminal lookahead in both _ROLL_VERB_AT_N and _ROLL_SUMMARY_HEADER_LINE.

Root cause: The unanchored _ROLL_RESULT_SUMMARY.test() called by _isScaffoldingSentence matches sentences with pattern <roll verb> at <N>, due to the comma in the lookahead (?=[.;,!?]|\\s*$). This misclassifies genuine prose as scaffolding and drops it.

Code path: Line 293 filters sentence fragments with _isScaffoldingSentence(p) → line 267 tests _ROLL_RESULT_SUMMARY.test(s) → line 158 pattern includes comma terminal.

Impact: Sentences like "The hawk settles at 3, then takes flight." or "The price falls at 5, and the crowd cheers." are erased from player-facing narration. Test test_roll_verb_with_in_world_quantity_survives_verbatim guards against similar regressions but only tests patterns with noun+comma ("at 12 men,"), not number+comma ("at 12,"), leaving the comma-terminal path uncovered.

The fix (remove , from both lookahead classes) is safe: all roll-header tests use ; or . terminators; no test depends on comma terminal. Confidence: 92%.

Proposed fix
 const _ROLL_VERB_AT_N =
-  "\\b(?:lands?|comes?(?:\\s+in)?|falls?|settles?|resolves?|clears?)\\s+(?:in\\s+)?at\\s+\\d+(?=[.;,!?]|\\s*$)";
+  "\\b(?:lands?|comes?(?:\\s+in)?|falls?|settles?|resolves?|clears?)\\s+(?:in\\s+)?at\\s+\\d+(?=[.;!?]|\\s*$)";
@@
 const _ROLL_SUMMARY_HEADER_LINE = new RegExp(
   "^\\s*[^.!?]*?" +
     _ROLL_VERB_AT_N +
-    "(?:[^.!?]*?\\bat\\s+\\d+(?=[.;,!?]|\\s*$))*" +
+    "(?:[^.!?]*?\\bat\\s+\\d+(?=[.;!?]|\\s*$))*" +
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 157-157: Detects non-literal values in regular expressions
Context: new RegExp(_ROLL_VERB_AT_N, "i")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

🤖 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/openworlds/screen-table.jsx` around lines 156 - 171, The regex
patterns _ROLL_VERB_AT_N and _ROLL_SUMMARY_HEADER_LINE contain a comma in their
terminal lookahead assertion (?=[.;,!?]|\\s*$), which causes the unanchored
_ROLL_RESULT_SUMMARY test to incorrectly match genuine prose sentences ending
with "at <number>," and classify them as scaffolding for removal. Remove the
comma from the lookahead in both patterns so the assertion becomes
(?=[.;!?]|\\s*$), which will prevent matching sentences with number-comma
terminators while still matching valid roll-header terminators.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request screen:inventory Pertains to the inventory screen

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant