Skip to content

fix(client): drop a stale hand reorder instead of surfacing an engine rejection (#5913) - #6215

Merged
matthewevans merged 8 commits into
phase-rs:mainfrom
minion1227:minion_5913_stale_hand_reorder
Jul 20, 2026
Merged

fix(client): drop a stale hand reorder instead of surfacing an engine rejection (#5913)#6215
matthewevans merged 8 commits into
phase-rs:mainfrom
minion1227:minion_5913_stale_hand_reorder

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

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 its ReorderHand order from the hand the gesture was set up against:

const nextOrder = computeReorderedHand(player.hand, objectId, targetSlot, );
dispatchAction({ type: "ReorderHand", data: { order: nextOrder } });

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, and apply_action rejects it — crates/engine/src/game/engine.rs:

if order.len() != player.hand.len() {
    return Err(EngineError::InvalidAction(format!(
        "ReorderHand: expected {} ids, got {}", player.hand.len(), order.len())));
}

The engine is right to reject it; the bug is that the client dispatches it at all and surfaces the rejection.

Fix

  • New pure isHandPermutation(order, hand) in handInsertionSlot.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).
  • Gate the dispatch on it, re-reading the hand at drop time via 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.tscomputeReorderedHand is the existing precedent for "pure, id-generic hand helper that returns null/false rather than letting the caller dispatch a hand-scrambling action"; isHandPermutation is the same shape and guards the same dispatch.
  • client/src/components/hand/__tests__/PlayerHand.test.ts — the file is already a pure-helper suite over handInsertionSlot.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.ts

New isHandPermutation cases: 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 ReorderHand validation is untouched, so no CR annotations apply beyond the CR 402.3 rationale cited above. Combinator-purity Gate A passes trivially (no parser diff). Local pnpm/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

… 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>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 19, 2026 23:27

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[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.

@matthewevans

Copy link
Copy Markdown
Member

Deferred by maintainer intake policy — not ignored.

This current head (9f8dd407f472a57033ae0c7f030babe0eb36284c) was triaged as a frontend-only change (client/src/components/hand/PlayerHand.tsx, client/src/components/hand/__tests__/PlayerHand.test.ts, client/src/components/hand/handInsertionSlot.ts) by minion1227. The local frontend-review allowlist does not include this author, so this route does not perform an implementation-diff review or approve the PR.

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.

@matthewevans matthewevans added the defer-fe Frontend/client/UI PR deferred to Matt's direct review label Jul 19, 2026
…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>
@minion1227

Copy link
Copy Markdown
Contributor Author

Understood on the defer-fe routing — flagging for whenever a maintainer has capacity, no rush.

State of this one:

  • CI green across all checks, including Frontend (lint, type-check, test).
  • Addressed the one automated review comment in e47862990: handleDragEnd now depends on the hand slice instead of the whole player object, so an unrelated player change (life, mana, counters) no longer rebuilds the callback. Behavior is unchanged — the null guard moves from player to hand, and the drop-time re-read the guard relies on is untouched.
  • The engine side is deliberately not modified: apply_action's permutation check stays the authority. This only stops the client feeding it an order it already knows is stale, so the player stops seeing a spurious error flag for a benign mid-drag draw.

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.

@matthewevans

Copy link
Copy Markdown
Member

Deferred by maintainer intake policy — not ignored.

This refresh applies to the current head (e478629906d00ec4f3b328f7755120626e951658). It remains a frontend-only change, and minion1227 is not in the local frontend-review allowlist. It therefore remains routed for explicit maintainer ownership rather than receiving an implementation-diff review or approval here. The defer-fe label is a routing marker, not a verdict on the change.

minion1227 and others added 2 commits July 20, 2026 03:13
…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 matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
@minion1227

Copy link
Copy Markdown
Contributor Author

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 WasmAdapter was the actual mistake, not just an omission.

Fixed in 9777b8c82 by moving the 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, so retrying is wrong), recoverable ACTION_REJECTED for everything else, leaving existing surface/retry behaviour unchanged.

Wired through all three transports:

Path Site
WASM wasm-adapter.ts now uses the shared predicate (no second copy of the matcher)
WebSocket ws-adapter.tsActionRejected + ManaPaymentPreviewRejected
P2P guest p2p-adapter.tsaction_rejected + mana_payment_preview_rejected

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 (ws-adapter.test.ts, p2p-adapter-multiplayer.test.ts): each asserts 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. That pairing is the part I'd want reviewed hardest: a matcher that swallowed everything would pass a one-sided test.

One disclosure: local pnpm/Vitest can't run in my sandbox (network-restricted dependency install), so these tests are verified by construction against the existing harnesses in each file, not executed locally — CI is the first real run. If either fails I'll fix it promptly.

Ready for re-review whenever you have capacity.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
@minion1227

Copy link
Copy Markdown
Contributor Author

Heads-up in case you saw the red frontend on 9777b8c: that was my type error, now fixed in 70c3a1769.

actionRejectionError referenced AdapterErrorCode.ACTION_REJECTED, but that code had only ever existed as a bare string literal in the remote adapters — the registry had no such member, so tsc rejected it. I registered it next to STALE_ACTION rather than adding a fourth bare string. Same wire value, so existing comparisons (including server-draft-adapter's three sites, which I left alone as out of scope) are unaffected. Also dropped an unnecessary as never in the new WS regression — ObjectId is number, so the order literal already satisfies GameAction.

Frontend (lint, type-check, test) is green on 70c3a1769 (2m49s), with the WS and P2P regressions running. Rust jobs still in progress.

That failure was a direct consequence of the sandbox limitation I flagged — I can't run tsc/Vitest locally, so a type error I'd normally catch in seconds reached CI instead. Worth weighing when you review: the logic was reviewed clean, but the first real compile happened on your infrastructure.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 matthewevans self-assigned this Jul 20, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@matthewevans matthewevans added the bug Bug fix label Jul 20, 2026
@matthewevans
matthewevans enabled auto-merge July 20, 2026 11:03
@matthewevans matthewevans removed their assignment Jul 20, 2026
…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>
auto-merge was automatically disabled July 20, 2026 11:06

Head branch was pushed to by a user without write access

@matthewevans matthewevans self-assigned this Jul 20, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@matthewevans
matthewevans added this pull request to the merge queue Jul 20, 2026
@matthewevans matthewevans removed their assignment Jul 20, 2026
Merged via the queue into phase-rs:main with commit 2b5d02d Jul 20, 2026
13 checks passed
@minion1227
minion1227 deleted the minion_5913_stale_hand_reorder branch July 20, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix defer-fe Frontend/client/UI PR deferred to Matt's direct review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hand Reorder Error — I was reordering my hand while I drew a card and it popped up a red flag with "Hand reorder error:…

2 participants