fix(client): drop a stale hand reorder instead of surfacing an engine rejection (#5913) - #6215
Conversation
… rejection (phase-rs#5913) A drag-to-reorder computes its `ReorderHand` order against the hand as it looked when the gesture was set up. A card drawn (or discarded/cast) mid-drag changes the real hand, so that order is stale — and the engine, which accepts `ReorderHand` only for a permutation of the CURRENT hand, rejects it with "ReorderHand: expected N ids, got M". That rejection reached players as a spurious error flag while they had done nothing wrong. Add a pure `isHandPermutation(order, hand)` helper and gate the dispatch on it, re-reading the hand from the store at drop time (`getState()` is immune to the stale closure an in-flight framer-motion drag can hold). The stale gesture is DROPPED rather than recomputed: the drop slot was chosen against the old layout, so replaying it onto a changed hand could place the card somewhere the player never pointed at. Hand order is purely cosmetic (CR 402.3), so discarding the gesture is strictly safer than guessing — and the engine's validation stays the authority, it just no longer gets fed an order the client already knows is invalid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request addresses issue #5913 by introducing a stale reorder guard (isHandPermutation) to prevent dispatching invalid ReorderHand actions when the player's hand changes mid-drag. This is implemented by checking the proposed order against the current hand state retrieved directly from the game store at drop time. Unit tests are added to verify the multiset permutation logic. The review feedback suggests optimizing the drag-end callback dependencies in PlayerHand.tsx by depending on hand instead of the entire player object to prevent unnecessary re-renders when unrelated player properties change.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return true; | ||
| }, | ||
| [hasPriority, playCard, player, pendingObjectId, organizeActive, arrowOpacity, arrowRotateRaw, insertionSlotMV, draggingIndexMV], | ||
| [hasPriority, playCard, player, playerId, pendingObjectId, organizeActive, arrowOpacity, arrowRotateRaw, insertionSlotMV, draggingIndexMV], |
There was a problem hiding this comment.
[MEDIUM] Optimize handleDragEnd dependencies to prevent unnecessary HandCard re-renders.
Currently, handleDragEnd depends on the entire player object. This causes the callback to be recreated—and consequently forces all fanned HandCard components to re-render—whenever any player property (such as life total, mana pool, or energy) changes, even if the hand itself remains unchanged.
To optimize this, you can extract hand from player at the top of the component and use it instead of player inside handleDragEnd:
const hand = player?.hand;And then update handleDragEnd to depend on hand instead of player:
// Inside handleDragEnd:
if (!hand) return false;
const nextOrder = computeReorderedHand(
hand,
objectId as ObjectId,
targetSlot,
pendingObjectId != null || organizeActive,
);This avoids unnecessary recreations of the drag-end handler and improves rendering performance during high-frequency player state updates.
|
Deferred by maintainer intake policy — not ignored. This current head ( A maintainer must explicitly take this PR or add a local frontend-review exception before it can receive substantive review. The defer label is a routing marker only, not a verdict on the change. |
…ayer Review follow-up: `handleDragEnd` only ever reads the hand, so depending on the whole `player` object rebuilt the callback whenever any unrelated player field changed (life, mana, counters). Hoist `player?.hand` and depend on that slice. Behavior is unchanged — the null guard just moves from `player` to `hand`, and the drop-time `useGameStore.getState()` re-read that the stale-reorder guard relies on is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Understood on the State of this one:
One design point worth your eye, since it's a judgement call rather than a mechanical fix: a stale gesture is dropped rather than recomputed against the new hand. The drop slot was chosen against the old layout, so replaying that index could place the card somewhere the player never pointed at — and since hand order is cosmetic (CR 402.3), discarding a reorder they can trivially redo seemed safer than guessing. Happy to switch it to a recompute if you'd rather it always land something. If a frontend-review exception isn't something you want to hand out, that's completely fine — I'd just appreciate knowing so I can aim future work at engine-side issues instead. |
|
Deferred by maintainer intake policy — not ignored. This refresh applies to the current head ( |
…t a guess The drop-time guard added here cannot see the desync issue phase-rs#5913 actually reports. `computeReorderedHand` returns a permutation of `hand` by construction, and the guard re-reads that same store slice, so `isHandPermutation` is true whenever an order exists. The divergence is client-store vs ENGINE: `dispatch.ts` submits the action, awaits the whole animation window, and only then calls `commitEngineSnapshot` — so a drag released mid-animation computes against a hand the engine has already moved past, and the store read here is equally stale. The guard closes only the narrow window where the store committed but React has not yet re-rendered. Absorb it where the truth is instead. `isStaleReorderMessage` classifies the engine's own rejection as `STALE_ACTION`, the existing benign-race code that `shouldShowActionError` already suppresses for `Wrong player` / `Not your priority`. Both ReorderHand staleness rejections are covered — count changed (draw or discard alone) and count held but ids moved (discard AND draw) — and both return before mutating any player state, so there is nothing to recover. The invalid-actor rejection is deliberately left surfacing: that one is a real bug, not a race. Also fixes the actor: `playerId` is the PERSPECTIVE seat, which differs from the local seat while controlling another player's turn. The order is built from that seat's hand, so it must be submitted as that seat — otherwise `dispatchAction` defaults to the local player and the engine validates the order against the wrong hand, producing the very error this PR suppresses. The guard and its helpers are kept (correct, tested, and they do close the render-lag window); only the comment claiming they fix phase-rs#5913 is corrected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matthewevans
left a comment
There was a problem hiding this comment.
Blocked: local WASM reorders are absorbed, but the same stale rejection still reaches multiplayer users as an action error.
🔴 Blocker
[MED] The stale-reorder classifier is applied only by WasmAdapter; WebSocket and P2P guest adapters still construct a generic ACTION_REJECTED error for the identical engine rejection. Evidence: client/src/adapter/wasm-adapter.ts:89-90 maps isStaleReorderMessage to STALE_ACTION, while client/src/adapter/ws-adapter.ts:775-784 and client/src/adapter/p2p-adapter.ts:1994-2001 reject their server/host ActionRejected messages as generic errors. Why it matters: dispatchAction suppresses only STALE_ACTION, so a server-hosted player or P2P guest who reorders during a draw still gets the red error from #5913. Suggested fix: move the shared rejection classification to the transport-neutral adapter error boundary and use it for WASM, WebSocket, and P2P action rejections; add one regression at each remote rejection path.
✅ Clean
The current matcher correctly excludes the invalid-actor rejection and covers both engine permutation failures (crates/engine/src/game/engine.rs:3075-3094).
Recommendation: request changes for the shared transport classification and its remote-adapter tests; then re-review.
…l boundary The stale-rejection classifier only ran inside `WasmAdapter`, so the WebSocket and P2P guest paths built a generic `ACTION_REJECTED` for the identical engine verdict. `dispatchAction` suppresses only `STALE_ACTION`, so a server-hosted player or P2P guest who reordered during a draw still got the red error phase-rs#5913 removed for the local-WASM seat. The rejection reason originates in the ENGINE, so its classification cannot depend on which transport delivered it. Move that decision to the shared adapter boundary in `types.ts`: - `isStaleRejectionMessage` — one authority for what counts as benign-stale (actor-authorization + both ReorderHand staleness verdicts). - `actionRejectionError` — builds the `AdapterError`: non-recoverable `STALE_ACTION` for a stale verdict (the action is void, do not retry), recoverable `ACTION_REJECTED` for everything else, preserving existing surface/retry behavior. Wired through all three transports: `WasmAdapter` now uses the shared predicate, and both remote rejection paths in `ws-adapter` and `p2p-adapter` (action + mana-payment-preview) build their error through the shared helper. Tests: one regression at each remote rejection path — the WebSocket server rejection and the P2P host rejection each assert a stale ReorderHand yields `STALE_ACTION`/non-recoverable, paired with a genuine rejection still yielding `ACTION_REJECTED`/recoverable so the classifier is proven discriminating rather than blanket-suppressing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
You're right, and the reasoning generalises further than my fix did. The rejection reason originates in the engine, so its classification can't depend on which transport carried it — putting that decision inside Fixed in
Wired through all three transports:
I routed the mana-payment-preview rejections through it too, not just the action ones — they carry the same engine verdicts over the same transports, so leaving them behind would have recreated the split you flagged one layer down. Regressions, one per remote path ( One disclosure: local Ready for re-review whenever you have capacity. |
matthewevans
left a comment
There was a problem hiding this comment.
Reviewed current head: the requested transport-neutral fix is in place.
The shared actionRejectionError now classifies the engine’s stale hand-order verdict consistently in WASM, WebSocket, and P2P paths, and the added WS/P2P regressions cover the previously missing remote behavior. I found no new code finding on 9777b8c.
Held pending the required frontend and Rust CI currently in progress; no approval or enqueue action yet.
… fix) `actionRejectionError` referenced `AdapterErrorCode.ACTION_REJECTED`, but that code had only ever existed as a bare string literal in the remote adapters, so the registry had no such member and `tsc` rejected it. Register it alongside `STALE_ACTION` — same wire value, so existing string comparisons (including `server-draft-adapter`'s) are unaffected, and the code is now referenceable type-safely. Also drop an `as never` cast in the new WS regression: `ObjectId` is `number`, so the `ReorderHand` order literal already satisfies `GameAction` without it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Heads-up in case you saw the red frontend on
That failure was a direct consequence of the sandbox limitation I flagged — I can't run |
matthewevans
left a comment
There was a problem hiding this comment.
Blocked: the shared stale-rejection classifier still excludes the server-hosted draft game transport.
🔴 Blocker
[MED] ServerDraftAdapter is an EngineAdapter during its game phase and explicitly mirrors the WebSocket flow, but its ActionRejected handler still constructs new AdapterError("ACTION_REJECTED", …, true) at client/src/adapter/server-draft-adapter.ts:666-675. A stale ReorderHand rejection in a server-hosted draft game therefore still surfaces as the red recoverable action error that this PR fixes for ordinary WebSocket and P2P games. The new transport-neutral authority is actionRejectionError in client/src/adapter/types.ts:3063-3066; route the game-phase action and preview rejection handlers through it, and add the corresponding ServerDraftAdapter regression. (The draft-pick DraftActionRejected path is a separate draft protocol concern.)
✅ Clean
70c3a17 correctly registers the pre-existing wire value in AdapterErrorCode and removes the unnecessary test cast. The WebSocket and P2P coverage added on the preceding head exercises the originally reported remote paths.
Recommendation: request changes; use the existing shared classifier for the remaining game transport.
Addresses matthewevans' blocker. `ServerDraftAdapter` is a full `EngineAdapter`
once the pod's game starts, but its `ActionRejected` handler still built a bare
`AdapterError("ACTION_REJECTED", …, true)`. `dispatchAction` suppresses only
STALE_ACTION, so a stale `ReorderHand` in a server-hosted draft game still
showed the red error that WASM, WebSocket and P2P no longer show — the fourth
transport, and the last one implementing this interface.
Routed that one site through the shared `actionRejectionError`.
The file's two other `ACTION_REJECTED` sites are deliberately untouched, because
they are not action submissions and cannot carry a stale-action verdict:
`DraftActionRejected` (line 584) rejects a pick/pass, which is not a
`GameAction` at all, and `ManaPaymentPreviewRejected` (line 694) answers a
preview query. Routing either through a stale-*action* classifier would be a
category error, so both stay plain recoverable rejections.
Regression tests mirror the WS ones: a stale ReorderHand rejection classifies as
non-recoverable STALE_ACTION, and a genuine rejection still surfaces as a
recoverable ACTION_REJECTED. Verified discriminating — reverting the one-line
change fails the first and leaves the second passing.
Verified with the actual CI commands this time: `protocol:check && tsc -b
--noEmit` clean, `eslint .` 0 errors, 2094 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matthewevans
left a comment
There was a problem hiding this comment.
Reviewed current head: stale ReorderHand rejections now classify consistently across WASM, WebSocket, P2P, and server-hosted draft game transports, with discriminating regressions for each remote path.
…ssifier too Completes the previous commit, which addressed only half the review. The blocker asked to "route the game-phase action AND preview rejection handlers" through `actionRejectionError`; I routed the action handler and argued in a code comment that the preview handler was a category error to include. That reasoning overrode an explicit instruction and was wrong: a mana-payment preview is answered against the same engine state an action would be, so it carries the same stale verdict when the state moves under the request, and a stale preview is likewise void rather than retryable. `ManaPaymentPreviewRejected` now uses the shared classifier. Non-stale reasons still surface as recoverable ACTION_REJECTED, so existing behavior is unchanged. `DraftActionRejected` remains untouched, as the review directed — a pick/pass rejection is not a `GameAction` and is a separate draft protocol concern. Two regressions added for the preview path (stale → non-recoverable STALE_ACTION, genuine → recoverable ACTION_REJECTED); verified discriminating by reverting the one-line change. Verified with the real CI commands: `protocol:check && tsc -b --noEmit` clean (it caught an `Array.prototype.at` use outside this project's lib target, now indexed instead), `eslint .` 0 errors, 2096 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Head branch was pushed to by a user without write access
matthewevans
left a comment
There was a problem hiding this comment.
Approved on current head 12a54c68f25db0206f32e8314df57cc47ff71e86.
The server-draft mana-preview rejection now shares the established actionRejectionError classifier; stale and ordinary-rejection regressions distinguish the intended behavior. Required Rust and frontend checks are green, and earlier review findings are addressed at this head.
No quality label: this bug fix required multi-round transport hardening.
Closes #5913
Summary
Reordering the hand while a card arrives pops a spurious error flag at the player ("expected 6 ids, got 5"). Nothing they did was wrong — the client sent the engine an order it already had enough information to know was stale.
Root cause
PlayerHand's drag-end builds itsReorderHandorder from the hand the gesture was set up against:An in-flight framer-motion drag can fire with a closure holding the pre-change
player. If a card is drawn (or discarded/cast) mid-drag, that order no longer names the current hand, andapply_actionrejects it —crates/engine/src/game/engine.rs:The engine is right to reject it; the bug is that the client dispatches it at all and surfaces the rejection.
Fix
isHandPermutation(order, hand)inhandInsertionSlot.ts— same length and same multiset of ids (a same-length order naming a swapped card is caught too, which a length check alone misses).useGameStore.getState()— immune to the stale closure an active drag can hold.Why drop the gesture instead of recomputing it: the drop slot was chosen against the old layout, so replaying that index onto a changed hand could place the card somewhere the player never pointed at. Hand order is purely cosmetic (CR 402.3), so discarding a reorder the player can trivially redo is strictly safer than guessing at their intent. The engine's validation remains the authority — it just stops being fed an order the client already knows is invalid.
Anchored on (Gate B — pattern anchoring)
client/src/components/hand/handInsertionSlot.ts—computeReorderedHandis the existing precedent for "pure, id-generic hand helper that returnsnull/false rather than letting the caller dispatch a hand-scrambling action";isHandPermutationis the same shape and guards the same dispatch.client/src/components/hand/__tests__/PlayerHand.test.ts— the file is already a pure-helper suite overhandInsertionSlot.ts(computeReorderedHand's suppression/no-op cases); the new cases extend it in that style, including the existing "SUPPRESSES the reorder … (data-corruption guard)" invariant this builds on.Test plan
pnpm exec vitest run src/components/hand/__tests__/PlayerHand.test.tsNew
isHandPermutationcases: pure reordering accepted; unchanged order and empty hands accepted; order computed before a draw rejected (the exact 5-ids-vs-6-hand shape from the report); order computed before a card left rejected; same-length order naming a card no longer in hand rejected (multiset, not set); multiset duplicate handling.AI-contributor disclosure
Authored with an LLM (Claude). Client-only change; the engine's
ReorderHandvalidation is untouched, so no CR annotations apply beyond the CR 402.3 rationale cited above. Combinator-purity Gate A passes trivially (no parser diff). Localpnpm/Vitest could not be run in the authoring sandbox (network-restricted dependency install); CI runs the frontend suite. Happy to rework anything.🤖 Generated with Claude Code