feat(client): one sanitized numeric box for all three amount prompts - #7019
Conversation
…eric box
The amount prompts drove a `<input type="range">` whose value could only ever be
in range, so the client never had to sanitize anything. A range control is also a
poor fit for a 0..1000 LoopCollapse window: naming an exact N by dragging is
impractical, and the slider announced its bounds only through native semantics
that a text box does not inherit.
`AmountInput` is the shared replacement for all three amount prompts
(PayAmountChoice / ChooseXValue / AssistPayment); this commit lands the control
and its first adopter. The `[min, max]` window is ENGINE-OWNED and arrives as
props — the component holds no bound, no default and no fallback of its own.
`parseAmount` is the single sanitization authority and REJECTS rather than
coerces, so a player never submits a number they did not type. Digits-only is
load-bearing: `Number()` alone accepts `""`→0, `" 7 "`→7, `"1.5"`→1.5,
`"1e3"`→1000, `"+2"`→2 and `"0x10"`→16, every one of which lands inside a typical
window. The commit button and the Enter key share one guard, in `handleCommit`.
Accessibility keeps everything the slider exposed: the box and both steppers
carry accessible names, the `[min,max]` hint is permanently associated via
`aria-describedby` (a text box announces no bounds natively), the validation
message is appended to that association while `aria-invalid` is set, and
ArrowUp/ArrowDown step. Only the pointer-drag affordance is gone. The steppers
stay LIVE while the entry is invalid — with the slider deleted they are the only
non-typing way back into the window — and they step from the DIGIT reading of the
raw entry, so 1001 recovers to 1000 rather than collapsing to min.
The three new `mana.*` keys land here rather than with the rest of the i18n
ledger because `react-i18next.d.ts` binds the English catalog as the type oracle:
`t("mana.amountOutOfRange")` is a compile error until the key exists, so
scheduling it later would leave this commit unable to type-check at its own
boundary. All 7 locales move together, so per-locale key parity holds here.
`PayAmountChoiceWaitingForFactory` replaces the hand-rolled `WaitingFor` literal
in the test file, mirroring the ChooseXValue factory pair.
Assisted-by: ClaudeCode:claude-opus-5
…rompts Both remaining amount prompts drove their own `<input type="range">`, and ChooseXValue additionally carried a hand-rolled number box with its own clamping. Three prompts, three sanitization stories, one of which silently rewrote the player's input. All three now share `AmountInput`, so the [min, max] window, the digit-only gate, the keyboard contract and the validation copy have one authority. DELIBERATE BEHAVIOUR CHANGE in the ChooseXValue cast prompt. Out-of-range X is now REJECTED rather than coerced: typing 99 under max=5 used to commit 5, and typing 0 under min=2 used to commit 2 — values the caster never chose, which CR 601.2f makes the caster's decision. Both now disable Confirm and show the range message. A third shipped assertion flips with them: the steppers no longer disable while the entry is out of range, because with the slider gone they are the recovery path back into the window. The two affected tests are rewritten in place and marked DELIBERATE rather than silently edited. The phase-rs#2427 DialogHost guard is RETARGETED, not deleted. Its subject was the range control, but it never asserted hit-testing — `fireEvent.change` dispatches on the node and happy-dom performs no layout — so it was always a mount-integration guard. It now drives the box and a stepper, which are the surfaces a phase-rs#2427-class regression would break next. Each prompt gains an Enter test. Enter bypasses the commit button's `disabled` attribute, so it is the only route on which `handleCommit`'s null-guard is observable at all; without these the guard was covered only by the attribute and a coercion regression would have shipped green. Both are matched pairs, so a component that simply never dispatches cannot satisfy them. `mana.chooseXAria` and `mana.xEquals` are deleted across all 7 locales: the slider carried the first and both readouts carried the second, and a bare substring census finds no remaining use, dynamic key construction included. CR 702.132a verified against docs/MagicCompRules.txt: "the player you chose may pay for any amount of the generic mana in the spell's total cost" — the assist domain is 0..max_generic, which is what the engine's own `number_projection` synthesizes as `min: 0`. The client mirrors the engine; it invents no bound. Assisted-by: ClaudeCode:claude-opus-5
Three review-impl rounds, eight findings, each resolved against a measurement rather than against the finding's own reasoning. The out-of-range copy was associated but never ANNOUNCED. `aria-describedby` and `aria-invalid` are resolved by a screen reader when focus ARRIVES at the control, but both flip here while focus is already inside the box, so a blind player got no feedback and would have to tab away and back to learn the entry was refused. The node is now a permanently-mounted `role="status" aria-live="polite"` region whose text mutates — mounting a node INTO a live region is equally unreliable, so the region must pre-exist its message. This reuses the idiom already carried by 16 files in this client rather than inventing one. The box also lost the VALUE half of its accessibility. An earlier note in this file rejected `role="spinbutton"` for having no in-repo precedent and forcing aria-value* upkeep; both premises were wrong. `ManaCurve.tsx` already uses aria-valuenow, and all three controls this box replaced announced their value natively (`type="range"` is a slider, `type="number"` a spinbutton). Dropping to a bare `type="text"` therefore made the ACCEPTED amount inaudible: +/− and the arrow keys mutated a value nothing exposed, since the live region carries only the refusal. ARIA-in-HTML lists `spinbutton` among the roles allowed on `input type=text`, and Core-AAM names that exact construction, so the override is conformant rather than a workaround. The role is descriptive because the box implements the pattern's CORE keyboard interaction — arrows step, clamped to the window — but NOT the full APG list: Home/End are deliberately left to their native caret semantics, since the host is a real editable text field and `<input type="number">` does not remap them either. `aria-valuenow` reports the VALIDATED amount and is absent while out of range, so it never contradicts aria-valuemin/max. `ChooseXValueUI`'s reset effect omitted `max`, so re-entering with a narrower max but an unchanged min left `defaultValue` identical, never fired the effect, and stranded an out-of-range entry with no way to self-heal. Both sibling prompts already key their reset on the full window. While the entry was invalid, all three prompts labelled the commit button with a value the player never typed — an over-max Assist entry read "Pay nothing", the OPPOSITE of the pending intent, and 1001 tokens read "Create 0 tokens" — and ChooseX previewed the mana cost of min. That is the same defect this branch exists to remove, displaced from the dispatch to the label. The labels now name the action without a value (new `mana.confirmAmount`, all 7 locales) and the cost preview is suppressed while there is no chosen X. Four negative dispatch assertions were dominated by their `toBeDisabled` sibling. MEASURED: deleting `handleCommit`'s null-guard reds ONLY the Enter row, and the suggested remedy — clicking the disabled button — adds no signal either, because React does not dispatch `onClick` to a disabled `button` (`getListener` returns null for it). That suppression is React-level, not a property of the test environment (this suite runs happy-dom; an earlier draft of these comments misattributed it to jsdom, which is not even loaded here). The inert click already sitting in T5 is removed rather than replicated, and each negative is labelled DOMINATED per this file's existing convention with its discriminating row named. Deleting the slider also invalidated a cross-file reference: `DialogHost`'s phase-rs#2427 residual-transform guard cited ChooseXValueUI's `<input type="range">` as its live example, so a maintainer checking it would find no slider and could conclude the guard was obsolete — regressing phase-rs#2427 for the ± steppers the retargeted mount-integration test now protects. The comment is repointed at the surviving subjects. Four new tests, each proven discriminating by isolated mutants that red it BY NAME with no collateral failure: conditional-mount (live region), removed and raw-sourced aria-valuenow (value semantics, one mutant per half so neither rides on the other), min-held-constant re-entry (the one shape the pre-existing re-entry test cannot see, since it narrows min too and so fires the effect either way), and restored `amount ?? min` (cost preview). Every arm reverted by byte-copy plus `touch` and md5-verified back to pristine. A CR citation this branch added was wrong, and the same error was already present twice in the file. CR 601.2f is total-cost determination; the rule that the caster chooses X is CR 107.3a ("the controller of that spell or ability chooses and announces the value of X as part of casting the spell"), with CR 601.2b placing that announcement in the casting sequence. All three sites in `ChooseXValueUI` now cite the rule that actually governs the claim they make, and CR 107.1b is attached to the lower bound it genuinely supports (a negative number can't be chosen) rather than to cost determination. Verified by grepping `docs/MagicCompRules.txt` before writing, per the repo's CR-annotation protocol. Assisted-by: ClaudeCode:claude-opus-5
📝 WalkthroughWalkthroughChangesThe PR adds a shared bounded Mana amount entry
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Player
participant AmountInput
participant ManaPaymentUI
participant GameState
Player->>AmountInput: Enter or adjust amount
AmountInput->>ManaPaymentUI: Report raw value
ManaPaymentUI->>AmountInput: Validate against min and max
Player->>ManaPaymentUI: Confirm valid amount
ManaPaymentUI->>GameState: Dispatch validated payment
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
client/src/components/mana/__tests__/AmountInput.test.tsx (1)
205-237: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a test for Enter submission and for the
min > 0hint branch.Two behaviors in
AmountInputhave no coverage here.
- Enter submission.
onSubmitis passed asvi.fn()in every test but never asserted. The Enter branch is the submission entry point, andAmountInputdeliberately does not re-guard the amount there. Removing the Enter handler leaves this suite green.- The
min > 0hint. Every test usesmin={0}, so onlymana.maxOnlyrenders. Themana.minMaxbranch is unreached.💚 Proposed tests
+ it("submits on Enter, leaving the null guard to the caller", () => { + const onSubmit = vi.fn(); + render( + <AmountInput + raw="9" + onRawChange={vi.fn()} + min={0} + max={5} + onSubmit={onSubmit} + labels={LABELS} + />, + ); + + fireEvent.keyDown(screen.getByLabelText("Enter amount"), { key: "Enter" }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("announces both bounds when min > 0", () => { + render( + <AmountInput + raw="5" + onRawChange={vi.fn()} + min={2} + max={5} + onSubmit={vi.fn()} + labels={LABELS} + />, + ); + + const box = screen.getByLabelText("Enter amount"); + const hintId = box.getAttribute("aria-describedby") ?? ""; + expect(document.getElementById(hintId)?.textContent).toBe("min 2 / max 5"); + });🤖 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 `@client/src/components/mana/__tests__/AmountInput.test.tsx` around lines 205 - 237, Add coverage in the AmountInput tests for the Enter key path by providing a named onSubmit mock, firing Enter on the labelled input, and asserting submission is invoked. Add a case with min greater than zero and max set so the mana.minMax hint branch is rendered and asserted, while preserving the existing min=0 coverage for mana.maxOnly.
🤖 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 `@client/src/components/mana/AmountInput.tsx`:
- Around line 93-106: Increase the touch target for both stepper buttons in
AmountInput while preserving their 36px visual box: update the decrease button
near step(-1) and the corresponding increase button near step(1) to provide at
least 44pt of clickable area using padding or an equivalent hit-area technique.
Keep the compact visual styling unchanged.
In `@client/src/components/mana/AssistPaymentUI.tsx`:
- Around line 27-29: Reset input when the engine replaces a prompt with the same
bounds by adding each prompt’s stable identity to the relevant useEffect
dependencies: update AssistPaymentUI.tsx lines 27-29, ChooseXValueUI.tsx lines
57-64, and PayAmountChoiceUI.tsx lines 23-25. Add regression tests replacing
each prompt directly with a same-bounds successor, covering back-to-back prompts
and confirming raw input resets.
---
Nitpick comments:
In `@client/src/components/mana/__tests__/AmountInput.test.tsx`:
- Around line 205-237: Add coverage in the AmountInput tests for the Enter key
path by providing a named onSubmit mock, firing Enter on the labelled input, and
asserting submission is invoked. Add a case with min greater than zero and max
set so the mana.minMax hint branch is rendered and asserted, while preserving
the existing min=0 coverage for mana.maxOnly.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6fdacc8f-b886-4532-8de4-831ce1cceb6c
📒 Files selected for processing (17)
client/src/components/mana/AmountInput.tsxclient/src/components/mana/AssistPaymentUI.tsxclient/src/components/mana/ChooseXValueUI.tsxclient/src/components/mana/PayAmountChoiceUI.tsxclient/src/components/mana/__tests__/AmountInput.test.tsxclient/src/components/mana/__tests__/AssistPaymentUI.test.tsxclient/src/components/mana/__tests__/ChooseXValueUI.test.tsxclient/src/components/mana/__tests__/PayAmountChoiceUI.test.tsxclient/src/components/modal/DialogHost.tsxclient/src/i18n/locales/de/game.jsonclient/src/i18n/locales/en/game.jsonclient/src/i18n/locales/es/game.jsonclient/src/i18n/locales/fr/game.jsonclient/src/i18n/locales/it/game.jsonclient/src/i18n/locales/pl/game.jsonclient/src/i18n/locales/pt/game.jsonclient/src/test/factories/gameStateFactory.ts
…he stepper touch target Two CodeRabbit findings on phase-rs#7019, both valid. The three reset effects observed only prompt type and bounds, so a SUCCESSOR prompt with the same window left `raw` holding the previous decision — the player could submit an amount they chose for a different prompt. Each reset now also keys on the identity its wire type carries: `source_id` for PayAmountChoice, `caster` for AssistPayment, and the pending cast's `object_id` for ChooseXValue. Each of the three regression tests holds the WINDOW CONSTANT so the identity field is the only changing dependency — the one shape in which the bug is observable — and each is proven by a mutant that removes just that dependency and reds that row by name. Residual, disclosed rather than papered over: a successor with the SAME identity and the same bounds is still indistinguishable here, because `WaitingFor` carries no prompt id or sequence. Closing that needs an identity emitted by the engine; deriving one in the display layer would be exactly the "frontend computes game state" mistake CLAUDE.md prohibits, so it is left to a follow-up rather than guessed at here. The steppers were 36px against the repo's 44pt touch-target rule (`.coderabbit.yaml`, `index.css`), and they are the only non-typing recovery path out of an invalid entry — the control a coarse pointer needs most. The 36px visual box is KEPT, because every other small square control in this client is `h-9 w-9` and resizing would both break that consistency and move the panel layout; the touch target is widened to 44px with the codebase's existing hit-area idiom (`ManualManaToggle`): a transparent `::before` at `-inset-1`, adding 4px per side. No test asserts the rendered hit box — happy-dom performs no layout — so this is a CSS-only change verified by reading, consistent with the existing phase-rs#2427 disclaimer in this suite. Assisted-by: ClaudeCode:claude-opus-5
Review of the previous commit found both of its fixes incomplete. Both are mine. The stepper hit area measured 42px, not the 44px its own comment claimed. `gameButtonClass` includes `border`, and an absolutely positioned pseudo-element resolves against its ancestor's PADDING box (36 − 2×1 = 34), so `before:-inset-1` yields 34 + 8 = 42. The idiom works in `board/ManualManaToggle` only because that control uses `ring-1`, which adds no layout border — so the precedent I cited did not transfer. Rather than retune the inset, the pseudo-element is DELETED and the controls are simply `h-11` (44px). A size that has to be derived through two CSS rules to be checked is a size that will silently regress; this one already had. The numeric box gets `h-11` too — it is the primary tap target, and fixing only the steppers left the main control short. It could not have used the same trick anyway: `<input>` is a replaced element and renders no pseudo-elements. The identity deps omitted the acting seat, which is exactly the axis the engine varies. `effects/pay.rs` drives `PlayerFilter::All` and its own test asserts consecutive PayAmountChoice states with a constant `source_id` and `player` 0 then 1; on the life arm, two seats at equal life produce successive prompts whose `min`, `max` and `source_id` are all identical. `AssistPayment` likewise carries `chosen` — the seat actually asked to pay — alongside `caster`, so the previous comment's claim that `caster` was "the only identity this wire type carries" was false. Both dep arrays now include the seat. Both new rows use the CR 723.1a control shape (the local seat stays `turn_decision_controller` while the prompt's semantic player moves), which is what lets ONE client answer both prompts in a row and is therefore what makes the bug reachable. That shape was not guessed: setting the controller to the new seat as well hides the panel and the row fails, which is how it was pinned. Each row reds under a mutant removing only its own dependency. This also narrows the previous commit's residual claim: for PayAmountChoice the engine already emits a per-prompt-constant field that closes the reachable case, so "needs an engine-emitted identity" was too broad. What remains unclosable in the display layer is a successor identical in seat, source and bounds. Assisted-by: ClaudeCode:claude-opus-5
Four review findings, all mine. The CR citation was wrong. I cited CR 723.1a for "the controlling seat makes the controlled seat's decisions", but 723.1a is "multiple player-controlling effects that affect the same player overwrite each other". The rule that governs the claim is CR 723.5 — "while controlling another player, a player makes all choices and decisions the controlled player is allowed to make" — paired with CR 723.3, "a player who's being controlled during their turn is still the active player", which is why the active player moves while the controller does not. Both were grepped from `docs/MagicCompRules.txt` before writing, and CR 723.5 is already what the rest of this repo uses for this exact seam (`game/autoPass.ts`, `adapter/p2p-adapter.ts`, `engine/src/game/turns.rs`). This is the sixth time in this workstream that a real rule has been cited for a claim it does not govern, and the second time I have done it while fixing an instance of it. The recurring shape is that the citation is checked for existence, not for whether it reaches the case. A note on the sibling assist row still said a same-caster successor was indistinguishable and that this wire type carries no prompt identity — which the previous commit disproved by adding `chosen`. Deleting code and adding fields both invalidate comments that point at them; the stale note is corrected rather than left to mislead someone into dropping the dep. The residual claim is narrowed a second time. `PayAmountChoice` also carries `accumulated` (stamped on every successor by `finish_pay_amount_choice`) and `resource`; neither is in the dep array because no reachable path needs them, but "identical in seat, source and bounds" overstated what is actually indistinguishable. `px-0` on the steppers was dead: `SIZE_CLASSES.xs` emits `px-2.5` later in the compiled sheet and wins. Removed rather than left reading as load-bearing; `w-11` pins the 44px border box regardless of padding. Assisted-by: ClaudeCode:claude-opus-5
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — two current-head correctness/accessibility blockers at 501d94c248aecd3a03226d52e5074cecc75eca8e.
🔴 Blockers
[MED] A raw amount can carry into a distinct same-bounds prompt. Evidence: client/src/components/mana/AssistPaymentUI.tsx:27-29, ChooseXValueUI.tsx:57-64, and PayAmountChoiceUI.tsx:23-25 reset only on prompt kind/window; client/src/stores/gameStore.ts:451-455 replaces waitingFor whenever an accepted engine snapshot arrives. Why it matters: two back-to-back decisions with identical bounds can submit the first prompt’s value for the second decision. Suggested fix: key each reset to the engine prompt’s stable identity as well as its bounds, and add a regression for a direct same-bounds successor for all three prompt variants.
[MED] The recovery steppers are below the project’s coarse-pointer target size. Evidence: client/src/components/mana/AmountInput.tsx:93-103,145-155 forces each button to h-9 w-9 (36px), and client/src/components/ui/buttonStyles.ts:19-26 supplies only min-h-9; client/src/index.css:243-247 documents the 44pt rule. Why it matters: when typed input is invalid, these are the only non-typing recovery controls and are too small for reliable touch interaction. Suggested fix: provide at least a 44px hit target for both steppers while retaining the compact visual treatment if desired.
🟡 Non-blocking
The current CodeRabbit review also correctly notes that AmountInput still lacks direct coverage of Enter submission and the min > 0 hint branch. Please add these while extending the successor-prompt regressions if practical; they are not the reason this review blocks.
Recommendation: address the two blockers on a new head, then request re-review.
CodeRabbit's review of phase-rs#7019 named two uncovered behaviors in `AmountInput`. One was real; the other was not, and the difference was settled by mutation rather than by reading. The real one: `onSubmit` is handed in as `vi.fn()` by every row in the file and never asserted. The caller suites (T6b, AP/enter) do cover Enter, but only through the assertion that an invalid entry never reaches the engine — which is satisfied EQUALLY by "onSubmit was never called" and by "onSubmit was called and the caller's guard rejected it". Nothing in the suite separated those, so the `onSubmit` prop comment ("MUST itself reject an invalid amount — AmountInput deliberately does not re-guard") was an assertion in prose only. Re-adding the forbidden guard inside the component now reds exactly one row, this new one. The one that was not real: a `min > 0` hint row. Both legs are already dominated. Collapsing the ternary to the max-only string reds `ChooseXValueUI`'s "min 1 / max 10"; collapsing it the other way reds three rows, because each of them asserts the hint's FULL text rather than a substring. The candidate row added no fourth failure. It is left out, with the measurement recorded where the next reader (or the next bot) will look for it — a row that cannot fail is worse than no row, because it reads as coverage. Assisted-by: ClaudeCode:claude-opus-5
…shows it Comment-only. Three review findings, all of them "the claim is broader than the measurement behind it". The `player` dep was justified by an equal-life case on the pay arm that I never demonstrated. `effects/pay.rs` does drive `PlayerFilter::All`, and its own test does assert consecutive `PayAmountChoice` states with a constant `source_id` and `player` 0 then 1 — but `max` and `accumulated` move in that case too, so keying on `[min, max]` alone would already have fired. The test I cited does not show what I said it showed, and the word "PROVABLY" was carrying the gap. The case that does show it is the LoopCollapse arm in `game/turns.rs` — which is the prompt this component's own tests already model. It mints one prompt per controller in APNAP order with `min: 0`, `accumulated: 0`, `source_id: ObjectId(0)` and `pending_mana_ability: None` written as literals: constant by construction, not conditional on board state. Only `max` and the collapse axis vary per controller, so two controllers with equal counts on the same axis differ in `player` alone. Read off the field initializers rather than inferred. The sibling parenthetical in `ChooseXValueUI` enumerated the two other dep arrays as `[min, max]` and `[max]` — which the immediately preceding commit falsified by adding the seat fields to both. An enumeration that reads as exhaustive and is not is worse than naming the shape, so it now names the shape. Assisted-by: ClaudeCode:claude-opus-5
Comment-only. The previous commit fixed two claims in the component files and left the identical claims standing in their test files — so for one commit the repo asserted both that the equal-life pay-arm case proves the seat dependency and that it does not. Both swept now, by the defect's mechanism rather than by the file the review named: T11's justification repoints to the LoopCollapse arm of `game/turns.rs` (four of the seven prompt fields written as literals, so only `max` and the axis vary), and the sibling dep-array enumeration in the ChooseX suite names the shape instead of listing members that a later commit can falsify — which is exactly what happened to it. The third correction is to my own measurement write-up. The note explaining why the `min > 0` hint row was left out said a candidate row "failed to add a fourth" failing test. It did add one; what it failed to add was a DETECTION — every mutant it reds was already red elsewhere. The distinction is the whole point of calling a row dominated, and stating it as a failure count was both wrong and, worse, checkable-looking. Reworded to say what was measured. Assisted-by: ClaudeCode:claude-opus-5
|
🤖 AI text below 🤖 @matthewevans — both blockers were already fixed in commits I hadn't pushed when you reviewed, so the head you read ( Your non-blocking note lands half and half: the Enter gap was real and now has a row; the Touch targets. Fixed, but not the way the proposed diff had it. The suggestion keeps Prompt-identity resets. Fixed in all three files, and the identity had to be wider than the finding described. Keying on On the Nitpick — one of the two was real, and the other I'd ask you to drop. The The A few things this branch corrected on its own that are worth surfacing, since they change claims the PR previously made:
One disclosure on CI: across the last five full-suite runs, three failed with a single test failing — |
There was a problem hiding this comment.
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 `@client/src/components/mana/__tests__/AssistPaymentUI.test.tsx`:
- Around line 72-100: Add a per-prompt sequence identifier to the assist-payment
waiting-for data, expose it symmetrically through the engine, WASM, WebSocket,
Tauri, and P2P adapters with round-trip coverage, and include it in
AssistPaymentUI’s reset dependencies. Extend the existing AssistPaymentUI tests
with a successor prompt whose caster, chosen, type, and max_generic are
unchanged but whose identity differs, verifying the raw amount resets.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 0692c1c7-b148-4e37-bcc4-9d81ffaf52d6
📒 Files selected for processing (8)
client/src/components/mana/AmountInput.tsxclient/src/components/mana/AssistPaymentUI.tsxclient/src/components/mana/ChooseXValueUI.tsxclient/src/components/mana/PayAmountChoiceUI.tsxclient/src/components/mana/__tests__/AmountInput.test.tsxclient/src/components/mana/__tests__/AssistPaymentUI.test.tsxclient/src/components/mana/__tests__/ChooseXValueUI.test.tsxclient/src/components/mana/__tests__/PayAmountChoiceUI.test.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
- client/src/components/mana/AmountInput.tsx
- client/src/components/mana/PayAmountChoiceUI.tsx
- client/src/components/mana/AssistPaymentUI.tsx
- client/src/components/mana/tests/ChooseXValueUI.test.tsx
- client/src/components/mana/tests/PayAmountChoiceUI.test.tsx
- client/src/components/mana/ChooseXValueUI.tsx
| // `max_generic` is held CONSTANT so the caster is the only changing dep: keyed on the bound | ||
| // alone the effect would not fire, and the amount chosen for the previous assist prompt would | ||
| // carry into this one. The PAYER axis is covered separately by AP/successor-seat below — an | ||
| // earlier version of this note claimed a same-caster successor was indistinguishable, which | ||
| // `chosen` disproves. | ||
| it("AP/successor: a same-bound prompt for a different caster resets the entry", () => { | ||
| const promptFor = (caster: number) => | ||
| buildAssistPaymentWaitingFor({ data: { caster, chosen: 0, max_generic: 4 } }); | ||
|
|
||
| const first = promptFor(1); | ||
| setGameStoreForTest({ | ||
| gameState: createGameState({ waiting_for: first }), | ||
| waitingFor: first, | ||
| }); | ||
|
|
||
| const { rerender } = render(<AssistPaymentUI />); | ||
| const box = screen.getByLabelText("Assist: Pay Generic Mana"); | ||
| fireEvent.change(box, { target: { value: "3" } }); | ||
| expect(box).toHaveValue("3"); | ||
|
|
||
| const successor = promptFor(2); | ||
| setGameStoreForTest({ | ||
| gameState: createGameState({ waiting_for: successor }), | ||
| waitingFor: successor, | ||
| }); | ||
| rerender(<AssistPaymentUI />); | ||
|
|
||
| expect(screen.getByLabelText("Assist: Pay Generic Mana")).toHaveValue("0"); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Add a per-prompt sequence identifier before shipping this reset flow.
These tests cover only caster and chosen changes. AssistPaymentUI retains raw when a successor prompt has the same type, max, caster, and chosen. The prior valid amount can then dispatch for a different payment decision.
Have the engine emit a prompt identity. Wire it through WASM, WebSocket, Tauri, and P2P. Include it in the reset dependencies. Add a regression test with identical current fields and a new prompt identity.
As per path instructions, “New engine fields exposed to the UI must be wired symmetrically through every adapter (WASM, WebSocket, Tauri, P2P) with a round-trip test.”
Also applies to: 102-133
🤖 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 `@client/src/components/mana/__tests__/AssistPaymentUI.test.tsx` around lines
72 - 100, Add a per-prompt sequence identifier to the assist-payment waiting-for
data, expose it symmetrically through the engine, WASM, WebSocket, Tauri, and
P2P adapters with round-trip coverage, and include it in AssistPaymentUI’s reset
dependencies. Extend the existing AssistPaymentUI tests with a successor prompt
whose caster, chosen, type, and max_generic are unchanged but whose identity
differs, verifying the raw amount resets.
Source: Path instructions
matthewevans
left a comment
There was a problem hiding this comment.
Approved at current head 895582315bcb1b640371c867b9c385d9bf20c700.
The prior two blockers are addressed: each amount prompt resets on its relevant semantic successor fields, and the recovery controls use explicit 44px sizing. I also checked the remaining sequence-ID suggestion against the engine construction path: WaitingFor::AssistPayment is constructed from the selected assist payer with its existing caster/chosen/max fields; no demonstrated distinct successor retains the relevant decision identity. Adding a new engine-to-adapter identity solely for this UI reset would be unrelated cross-surface scope.
Quality Gate: PASS — this is at the existing prompt-display seam; the current-head tests cover same-window successors and fail under the corresponding dependency mutations; current required CI is green.
🤖 AI text below 🤖
Summary
Amount prompts (PayAmountChoice, ChooseXValue, Assist payment) currently commit whatever the widget yields, so a player can neither type a bound directly nor see why an out-of-range entry is refused. This replaces the slider/ad-hoc inputs with one shared, sanitized numeric box (
AmountInput+parseAmount) adopted by all three prompts, with the engine-supplied[min, max]window announced rather than silently clamped.Files changed
client/src/components/mana/AmountInput.tsx— new; the shared box +parseAmount/digitsOfclient/src/components/mana/__tests__/AmountInput.test.tsx— newclient/src/components/mana/PayAmountChoiceUI.tsx— adopts the box (replaces the slider)client/src/components/mana/ChooseXValueUI.tsx— adopts the boxclient/src/components/mana/AssistPaymentUI.tsx— adopts the boxclient/src/components/mana/__tests__/PayAmountChoiceUI.test.tsx— rewritten for the boxclient/src/components/mana/__tests__/ChooseXValueUI.test.tsx— rewritten; three deliberate behaviour flips (below)client/src/components/mana/__tests__/AssistPaymentUI.test.tsx— rewritten for the boxclient/src/test/factories/gameStateFactory.ts— addsPayAmountChoiceWaitingForFactory(additive;buildGameStateuntouched)client/src/components/modal/DialogHost.tsx— comment only; repoints the Frontend: mana payment X-value input box unresponsive until clicking elsewhere on board #2427 guard's example at the surviving controls (see below)client/src/i18n/locales/{de,en,es,fr,it,pl,pt}/game.json— 4mana.*additions (3 in the first commit,confirmAmountin the review-fix commit), 2 orphaned keys deletedTrack
Developer
LLM
Model: claude-opus-5
Tier: Frontier
Thinking: high
Implementation method (required)
Method: not-applicable — client-only. No
crates/engine/file is touched; there is no parser, effect, resolver, targeting or rules-behaviour change in this PR.CR references
CR 702.132a— grep-verified verbatim againstdocs/MagicCompRules.txtbefore it was written; it authorises the Assist[0, max_generic]domain ("may pay for any amount of the generic mana in the spell's total cost"), which is why the absence of aminfield is a real lower bound rather than a missing value.Three CR citations in
ChooseXValueUI.tsxare corrected, one of them mine. The final review round caught that the inline comment I added cited CR 601.2f for "a value the caster never chose" — but 601.2f is total cost determination. The rule that actually governs the claim is CR 107.3a: "the controller of that spell or ability chooses and announces the value of X as part of casting the spell", with CR 601.2b placing that announcement in the casting sequence. Grepping the rules text showed the same wrong citation already existed at two pre-existing sites in the same file (the JSDoc header and the guard comment above the early return), so all three are fixed rather than just mine —CLAUDE.mdrequires verifying existing CR annotations when modifying a file, and leaving the template in place is how the error propagates to the next reader. CR 107.1b is re-pointed at the bound it genuinely supports (a negative number can't be chosen ⇒ the lower bound is never below 0) instead of at cost determination.CR 601.2fsurvives only where the claim really is about the total cost being locked in.Every rule number above was verified by grepping
docs/MagicCompRules.txtbefore being written, and noMagicCompRules.txtline number is cited anywhere.Verification
Every figure below is bound to the head it was measured at, with the command and the counting unit stated, so each can be re-derived rather than trusted.
All figures in this section were measured at head
895582315bcb1b640371c867b9c385d9bf20c700, baseacfeaf0786c7f1b39d0226ed965981348a2a5dd3, withDIRTY_TRACKED=0recorded inside the gate log itself rather than asserted afterwards — except the probe row, whose different binding is stated in that row.npx vitest run(cwdclient, no path filter; unit = one executedit()) — 2567 passed / 0 failed / 12 todo (2579) across 286 files passed / 3 skipped (289); exit 0src/hooks/__tests__/useCardImage.test.tsx > uses imported source printing art by default when no art chain is configured, failing asError: Test timed out in 5000ms— an elapsed-time failure, not an assertion. The third was a gate-script run whose log filters the test name, so I am not claiming it was the same test; I am saying I do not know. That file passes 3/3 in isolation, is not among this PR's 17 paths, and this PR touches nothing underhooks/or any image path — so I believe it is a pre-existing load-sensitive flake on a busy box, not a regression here. I have not proven that on the base commit, so it is stated as a belief with its evidence, not as a finding. Recorded because quoting whichever run happened to be green is the same defect as quoting a stale SHA — and because if CI trips on this, the reviewer should already know it exists.npx tsc -b --noEmit --force(unit = exit code;-b, not-p, which is vacuous on this solution config) — exit 0parseAmount's return typenumber | null→string | null, one unique anchor) — exit 2, 9error TS(5× TS2322, 2× TS2365, 2× TS2367), landing inAmountInput.tsxand all three adopting prompts, which is the evidence the gate reaches every call site and not merely the definition; one error lands onaria-valuenow, so the new value semantics are type-checked too. Reverted by byte-copy andtouch, md5-verified back to pristinebcdaa03e3f5c1839bc1c981617373e08. (The md5 recorded here in an earlier revision wase11b7eb5…, measured beforepx-0was removed from the file — re-run rather than relabelled, because carrying a control's figure across a change to the file it mutates is exactly the false attribution this PR keeps correcting.)npx eslint .(unit = one error / one warning) — exit 0; 28 problems, 0 errors, 28 warnings. Two of those warnings are this PR's (react-refresh/only-export-components, fromAmountInput.tsxexportingparseAmountbeside the component); the other 26 pre-date the branch. Disclosed rather than reported as a bare "lint clean".manakeyset;json.load+ set equality per locale) — 7 locales, 48manakeys, identical across all 7touch, md5-verified back to pristine. These are bound toda694dd44and I am NOT carrying them forward as current evidence. An earlier revision of this body argued they still held because onlyacfeaf078sat between that head and this one, intersecting none of the 17 paths. That argument is dead, and my first attempt at retracting it also understated the drift — the review that caught the stale claim caught the correction too. Measured:git rev-list --count da694dd44..895582315= 10 (seven new PR commits, plusacfeaf078, plus two originals the rebase rewrote), andgit diff --name-only da694dd44..895582315returns 17 paths — 16 of this PR's 17, every file excepttest/factories/gameStateFactory.ts.AmountInput.tsxalone is +63/−11 over that range and the content is not cosmetic:role="spinbutton",aria-valuenow/min/max, therole="status"live region and the 44px sizing all landed after the probes ran. The four probe-carrying test files changed by +429 lines. "Measurably inert" was true of thepx-0removal and of nothing else, and I should not have let it characterise the whole delta. Re-running them exactly is not possible — they were executed interactively and no harness was kept, which is my error and is why the rule I wrote down at the time ("a probe row is not evidence until it has been RUN") now has to be applied against my own earlier row. What is current-head evidence instead: the mutant table below (six rows, each re-measured at a recent head, each naming the row it reds) and the tsc must-fail control above, which was re-run at this exact head. If you want the original 22 reconstructed and re-run before merge, say so — it is a real cost, so I am not spending it unasked, but I am also not claiming coverage I did not re-measure.Engine legs (
cargo clippy --workspace,cargo test -p phase-engine) are not run and are not applicable: this PR touches no Rust file. Stated rather than silently omitted.Gate A
Gate A PASS head=895582315bcb1b640371c867b9c385d9bf20c700 base=acfeaf0786c7f1b39d0226ed965981348a2a5dd3
Run as
./scripts/check-parser-combinators.sh acfeaf0786c7f1b39d0226ed965981348a2a5dd3(Gate G also PASS in the same output). Why this PASS is honest rather than impressive: the range is non-empty — 9 commits, 17 files — but contains 0 Rust files, so the parser-combinator gate has nothing to bite on here. It is a real run over a real range, not a vacuous empty-range PASS, and equally it is not evidence that parser scrutiny occurred.Anchored on
client/src/components/mana/ManaPaymentUI.tsx:41— the same prompt seam this PR's three components follow, and untouched by this PR, so it can be checked independently of the diff:useTranslation("game")+useGameStore((s) => s.waitingFor)+useGameStore((s) => s.dispatch), with the prompt discriminated onwaitingFor?.type. The new box is factored out along that existing seam rather than invented beside it.client/src/test/factories/gameStateFactory.ts:270— the pre-existingChooseXValueWaitingForFactory(present in the base at:269, so it is genuinely prior art and not something this PR introduced) that the newPayAmountChoiceWaitingForFactoryat:288mirrors exactly, including the pairedbuild*helper and the builder methods.Final review-impl
Final review-impl head=895582315bcb1b640371c867b9c385d9bf20c700 — committed diff PASS; 7 findings, every one of them in this document.
Read-only, and it took eight rounds — 4 findings, then 2, 2, 1, 4, 4, 6, and finally 7. I am not recording that as a bare PASS, because the last round was not one: it found the committed diff clean (3 files, 26 lines, 0 non-comment lines) and returned 7 findings entirely against this PR description — a stale evidence claim, a corrected sentence that had been fixed in the code and not here, and five figures quoted without their head. Those are fixed above. The pattern worth naming is that once the code settled, every remaining defect was in the prose about the code, including in the paragraph retracting an earlier bad claim — the retraction understated the drift it was retracting. Rounds 2 and 4 each found a defect in the previous round's fix, which is the honest reason for the count: the accessibility fix in round 1 made the refusal audible and left the value silent, and my write-up of the round-1 measurement named the wrong mechanism. Both are described above rather than folded in quietly.
Claimed parse impact
None. No parser code is touched.
Scope Expansion
None.
Validation Failures
None.
CI Failures
None.
Deliberate behaviour flips, disclosed
ChooseXValueUIchanges in three ways, each with its test updated deliberately rather than adjusted to fit:What the final review-impl changed, disclosed
The six items below came out of the first five rounds — 4 findings, then 2, then 2, then 1, then a closing verification; the series went on to seven rounds in total, and the later ones are described where they land. All six are fixed in the third commit, so the diff you are reading is not the one that was reviewed first. Rounds 2 and 4 both found defects in the fixes from the round before, which is the main reason this went past a single pass:
aria-describedbybut never announced: those attributes are resolved by a screen reader when focus arrives at the control, and here both flip while focus is already inside the box — so a blind player got no feedback and would have to tab away and back to discover the entry was refused. The node is now a permanently-mountedrole="status" aria-live="polite"region whose text mutates (mounting a node into a live region is equally unreliable). This reuses the idiom already present in 14 files of this client rather than inventing one — 14 is the count at the base commit; the grep returns 16 at head because it now includes this PR's own two files, which would be circular to cite.<input type="range">(roleslider) and<input type="number">(rolespinbutton), both of which expose their value natively, with a baretype="text". The accepted amount therefore became inaudible:+/−and the arrow keys mutated a value nothing announced, since the live region carries only the refusal. A note in the file had rejectedrole="spinbutton"for having "zero in-repo precedent" and forcing aria-value upkeep — both premises were false (ManaCurve.tsx:59already usesaria-valuenow, and the replaced controls carried these roles natively). The box now carriesrole="spinbutton"witharia-valuenow/valuemin/valuemax; it already implemented the entire spinbutton keyboard contract, so the role is descriptive rather than decorative.aria-valuenowreports the validated amount and is absent while out of range, so it never contradictsaria-valuemax.mana.confirmAmountkey across all 7 locales;mana.paywas rejected as a reuse because the loop-collapse button says "Create", not "Pay") and the cost preview is suppressed while there is no chosen X.ChooseXValueUI's reset effect omittedmax, so re-entry that narrows max while leaving min unchanged leftdefaultValueidentical, never fired the effect, and stranded an out-of-range entry with no way to self-heal. Both sibling prompts already keyed on the full window.toBeDisabled()sibling — see the next section, because the fix is not the one that was suggested.DialogHost's Frontend: mana payment X-value input box unresponsive until clicking elsewhere on board #2427 residual-transformguard cited ChooseXValueUI's<input type="range">as its live example. That slider no longer exists, so a maintainer checking the comment would find nothing, conclude the guard was obsolete, and delete it — regressing Frontend: mana payment X-value input box unresponsive until clicking elsewhere on board #2427 for the ± steppers that this branch's own retargeted mount-integration test exists to protect. The comment is repointed at the surviving subjects. This is the only component file outsidecomponents/mana/that the PR touches (the others aretest/factories/gameStateFactory.tsand the 7 locale JSONs), and the change is comment-only.<input type="number">— whose implicit role is alreadyspinbutton— does not remap them either. Left as-is deliberately, and now said so.One reviewer remedy measured and rejected, stated plainly
For finding 4 the review suggested clicking the disabled button so
expect(dispatch).not.toHaveBeenCalled()would gain signal. I measured that remedy and it does not work. DeletinghandleCommit'sif (amount === null) return;reds only T6b — the Enter row — while T2/T3/T4/T5 stay green with the click, because React does not dispatchonClickto a disabledbuttonat all. Enter bypassesdisabledand is the only route on which that guard is observable, which this branch's own earlier commit message already said.So the rows are labelled
DOMINATEDper the convention this test file already uses, the discriminating row is named in each label, and the pre-existing inert click in T5 was removed rather than replicated three more times — leaving it would imply coverage the row does not have. Reporting this because a silently-different fix reads as a silently-ignored finding.And my first write-up of that measurement was itself wrong, which round 2 caught: I attributed the suppression to jsdom, but this suite runs happy-dom (
client/vitest.config.ts:64) — jsdom is never loaded. The behaviour and the conclusion were right, the mechanism was not; it is React-level and therefore invariant under a change of test environment, which is the opposite of what my wording implied. Corrected in all three comments and in the commit message. Recording it because the finding is exactly the failure mode this PR's review kept surfacing: an authority named for something it does not govern.Each new test is proven discriminating by an isolated mutant that reds it, by name, with no collateral failure:
A11Y/live-region: the validation copy lives in a polite region that pre-exists itaria-valuenowA11Y/value: the accepted amount is exposed, and withheld while out of rangearia-valuenowfrom the raw digits instead of the validated amountA11Y/value(the withheld half — a distinct mutant, so both halves are pinned independently)maxfrom the reset dep arrayresets an entry stranded above a narrowed max when min is unchangedamount ?? minin the cost-preview memohides the pending cost preview while the entry is invalidif (amount !== null) onSubmit())enter: Enter calls onSubmit, and calls it EVEN when the entry is out of rangeThe
maxrow holds min constant deliberately: the pre-existing re-entry test narrows min as well, sodefaultValuechanges and the effect fires either way — it cannot discriminate the missing dependency. Every arm was reverted by byte-copy andtouch, md5-verified back to pristine.CodeRabbit round, and one fix deliberately left incomplete
Two Major findings, both valid, both fixed in the fourth commit.
Touch targets — and my first fix for it was wrong, by 2px. The controls were 36px against this repo's 44pt rule (
.coderabbit.yaml,client/src/index.css). I first tried to keep the 36px visual box and widen only the hit area with the::beforeidiom fromboard/ManualManaToggle. That measured 42px, not the 44px my own comment claimed:gameButtonClassincludesborder, and an absolutely positioned pseudo-element resolves against its ancestor's padding box (36 − 2×1 = 34), so-inset-1gives 34 + 8. The precedent did not transfer becauseManualManaToggleusesring-1, which adds no layout border.Rather than retune the inset, the pseudo-element is deleted and the controls are plainly
h-11(44px). A size that must be derived through two CSS rules to be checked is a size that will silently regress — this one already had, within a single commit. The numeric box ish-11too: it is the primary tap target, and fixing only the steppers left the main control short (it could not have used the trick anyway —<input>is a replaced element and renders no pseudo-elements). Correcting a figure I gave earlier:h-9 w-9appears 16 times in this client, not 33 — I had conflated three grep patterns. That 16 is bound to35effbdc8; at this head it is 12 (14 at the base, +2 added and then −4 removed by theh-11fix). Quoting a count without its head is the same defect as quoting a gate figure without one.Prompt-identity resets — fixed as far as the display layer legitimately can, and no further. The three reset effects keyed only on prompt type and bounds, so a successor prompt with the same window left the entry holding the previous decision. This is the same defect class as the
max-dependency bug above: a reset keyed on an incomplete identity. Each effect now also keys on the identity its wire type carries —source_id,caster, and the pending cast'sobject_id. Each regression test holds the window constant so the identity field is the only changing dependency, and each is proven by a mutant removing exactly that dependency, redding its own row by name.The identity had to be widened once more, and my first residual claim was too broad. Keying on the source alone missed the axis the engine actually varies. The case that shows it is the LoopCollapse arm in
game/turns.rs— the prompt this component's own tests model — which mints one prompt per controller in APNAP order withmin: 0,accumulated: 0,source_id: ObjectId(0)andpending_mana_ability: Nonewritten as literals, so onlymaxand the collapse axis derive from state: two controllers with equal counts on the same axis give successive prompts differing inplayeralone. (I first justified this with an equal-life case oneffects/pay.rs. That does not hold:pay.rsdoes drivePlayerFilter::Alland its test does show a constantsource_idwithplayer0 then 1, butmax2 → 3 andaccumulated0 → 2 move as well, so[min, max]alone would already have fired — and it is the mana-X arm, not the life arm I described. The test I cited did not show what I said it showed.)AssistPaymentlikewise carrieschosen, the seat actually asked to pay, so my earlier comment claimingcasterwas "the only identity this wire type carries" was simply false. Both dep arrays now include the seat.Both seat rows use the CR 723.5 + CR 723.3 control shape — the local seat stays
turn_decision_controllerwhile the prompt's semantic player moves — which is what lets one client answer both prompts in a row, and therefore what makes the bug reachable at all. That shape was not guessed: setting the controller to the new seat as well hides the panel and the row fails, which is how it was pinned.I first cited CR 723.1a here and in both test comments. That is the wrong subrule: 723.1a is "multiple player-controlling effects that affect the same player overwrite each other," an overwrite rule that says nothing about who makes the decisions. The rules that govern the claim are CR 723.5 ("while controlling another player, a player makes all choices and decisions the controlled player is allowed to make") and CR 723.3 ("a player who's being controlled during their turn is still the active player") — the latter being why the active player moves while the controller does not. Both were grepped from
docs/MagicCompRules.txtbefore rewriting, and CR 723.5 is already what this repo cites at this exact seam ingame/autoPass.ts,adapter/p2p-adapter.tsandengine/src/game/turns.rs. This is the sixth citation-that-does-not-reach-its-claim in this workstream and the second I introduced while fixing one; the recurring shape is that a citation gets checked for existence and never for reach.What genuinely remains is narrower still, and I have now had to narrow it three times — worth saying, because each time the claim was broader than the measurement behind it, and each narrowing came from someone counting the fields I had not. The
PayAmountChoicevariant has seven:player, resource, min, max, accumulated, source_id, pending_mana_ability(types/game_state.rs, mirrored in full atadapter/types.ts). Four are in the dep array.accumulatedandresourceare not, because I could not demonstrate a reachable path needing them — unlike the seat axis, whichturns.rs's LoopCollapse arm proves by construction.pending_mana_abilityis not, and cannot be: it crosses the wire asunknown, so the display layer has nothing to compare. So the honest statement is the one measured off the dep array rather than off the variant: the effect keys four of the seven (player,min,max,source_id), so any successor matching those four is unreset — including one differing only inaccumulatedorresource, andfinish_pay_amount_choicedoes stampaccumulatedon a chained successor. No such path is demonstrated, which is why those two are not deps; but "identical in all seven" was the wrong bound to quote, because it describes a stricter successor than the code actually requires. Stating the weaker true bound rather than the stronger convenient one. Closing even that would need an engine-emitted prompt identity; deriving one client-side is the "frontend infers game state" mistakeCLAUDE.mdprohibits. Happy to do that engine change on request rather than smuggle it into a client-only PR.The reachability evidence was also repointed. The comment in
PayAmountChoiceUI.tsxoriginally leaned on an equal-life case on the pay arm, which I never demonstrated:pay.rs's own test does show consecutive prompts with constantsource_idandplayer0 → 1, butmaxandaccumulatedmove there too, so[min, max]alone would already have fired. The case that actually carries the claim is the LoopCollapse arm ingame/turns.rs— the very prompt this component's tests model — which writesmin: 0,accumulated: 0,source_id: ObjectId(0)andpending_mana_ability: Noneas literals, constant by construction rather than conditional on board state; onlymaxand the axis vary per controller. Two controllers with equal counts on the same axis therefore differ inplayeralone. Recording the swap because "provably" was doing work in the old comment that the cited test did not support.CodeRabbit's second round — one finding real, one refuted by mutation
The review also filed a Nitpick naming two behaviors in
AmountInputas uncovered. One was; one was not, and the difference was settled by mutating rather than by reading.Real:
onSubmitwas passed asvi.fn()by every row and never asserted. The caller suites (T6b, AP/enter) do exercise Enter, but only through "an invalid entry never reaches the engine" — which is satisfied equally by "onSubmit was never called" and by "onSubmit was called and the caller's guard rejected it". Nothing separated those, so theonSubmitprop comment ("MUST itself reject an invalid amount — AmountInput deliberately does not re-guard") was an assertion in prose only. The new row pins it: re-adding the forbidden guard inside the box reds exactly one row, this one — no collateral, which also confirms nothing else was covering it.Refuted: the
min > 0hint branch is already covered, twice over. Collapsing the ternary to the max-only string redsChooseXValueUI's existingmin 1 / max 10; collapsing it to the two-sided string reds three rows, because each asserts the hint's full text rather than a substring. A candidate row asserting both legs does red alongside them — what it fails to grow is the two mutants' set of detections, not their failure count, which is exactly what "dominated" means. So it is left out and the measurement is recorded in the file where the next reader — or the next bot — will look for it. A row that cannot fail is worse than no row, because it reads as coverage. The two mutants are re-runnable from that comment.Predecessor context
This is the first of two PRs from one plan. The second (
scheduled_collapse→ the∞→Nbadge affordance) depends on #7002 and will follow it; the split is at a measured dependency boundary, not a size boundary — the badge half has no rendered ∞ badge to attach to until #7002 lands. The two halves were verified non-interfering in both directions before splitting: a plan-text scan for 18 cross-half identifiers returned 0 with a positive control proving the instrument returns non-zero.Not done, disclosed
No in-browser playtest of a live 4p game. Acceptance here is component- and suite-level.
Summary by CodeRabbit