Skip to content

fix(client): resolve the local seat from the mode census (#7524) - #7525

Open
cuinhellcat wants to merge 1 commit into
phase-rs:mainfrom
cuinhellcat:fix/draft-match-guest-seat
Open

fix(client): resolve the local seat from the mode census (#7524)#7525
cuinhellcat wants to merge 1 commit into
phase-rs:mainfrom
cuinhellcat:fix/draft-match-guest-seat

Conversation

@cuinhellcat

@cuinhellcat cuinhellcat commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #7524.

A pod-draft guest was answered seat 0 by usePlayerId()/getPlayerId() no matter which seat the pod had assigned it, because both resolved the answer from a hand-typed mode list written before "draft-match" existed. Its own hand rendered redacted, GamePage's mulligan pending.find searched the host's seat, and once the host kept, both clients sat on "Opponent is deciding their opening hand…" with nobody able to answer for seat 1.

The change

GAME_MODE_TRAITS gains a third axis:

export type SeatSource = "seat-zero" | "wire-assigned" | "no-seat";

seatSource() reads it, and resolveLocalSeat() is the single resolver both entry points now share. The census is a Record<GameMode, …>, so a mode added later cannot compile without declaring where its seat comes from — which is exactly what the two lists could not do, and why this bug existed.

mode seat
ai, local, native-ai seat-zero
online, p2p-host, p2p-join, draft-match wire-assigned
spectate no-seat

native-ai is measured, not assumed: handleNativeEvent (GameProvider.tsx) never calls setActivePlayerId, so declaring it wire-assigned would read a stale seat left by an earlier online game.

The spectator split — 255 from getPlayerId(), 0 from usePlayerId() — is unchanged, but is now a named parameter rather than a difference between two copies. HudBadges.tsx documents its dependency on the hook's 0.

Measurements

call sites reading the seat authority 50, across 36 files
tsc -b --noEmit / eslint . 0 / 0
vitest run 2902 passed, 0 failed, 3 files skipped

Counter-probe: flipping only the draft-match census entry back to "seat-zero" drops both new rows — expected +0 to be 1.

useConcedeHandler.test.tsx mocked the whole gameStore module, which left the new import undefined. It now importActuals the module and overrides only the store handle, so it concedes as the seat the real census resolves rather than as one the mock asserts.

Not covered

The guest's seat still reaches the store via setupDraftMatchAvatars, which derives it from matchPairing.type rather than from the assignedPlayerId the host actually sent in game_setup. The two agree today; unifying them on the wire value is a separate change.

Not evidenced by the new tests

They do not render GamePage, so they do not show the mulligan modal appearing for the guest — they pin the seat authority that modal reads. The solo-mode and spectator rows are green with and without the fix and are labelled in the file as nail-downs, not evidence.

Summary by CodeRabbit

  • Bug Fixes

    • Improved local player seat identification across multiplayer, solo, draft, and spectator game modes.
    • Correctly handles assigned seats, host and guest seats, and stale multiplayer seat data.
    • Preserves appropriate player identity behavior for spectators.
  • Tests

    • Added regression coverage for seat resolution across all supported game modes.

`usePlayerId()`/`getPlayerId()` are the single authority for "which seat is
this client's own" — 50 production call sites across 36 files read one of
them. Both resolved it from a hand-typed list of game modes that predates
`"draft-match"`, so a pod-draft guest was answered seat 0 no matter what the
pod had assigned it. Its own hand came back redacted, `GamePage`'s mulligan
`pending.find` searched the host's seat, and once the host kept, both clients
sat on "Opponent is deciding their opening hand…" with nobody able to answer
for seat 1.

`activePlayerId` was already correct (`setupDraftMatchAvatars`); only the
resolver discarded it.

Give `GAME_MODE_TRAITS` a third axis — `seat: "seat-zero" | "wire-assigned" |
"no-seat"` — and read it through the new `seatSource()`. The census is a
`Record<GameMode, …>`, so a mode added later cannot compile without declaring
where its seat comes from; that is what the two lists could not do. Both entry
points now share one `resolveLocalSeat`, with the spectator split passed in as
a parameter so it stays a stated decision rather than a drift.

`native-ai` is declared `"seat-zero"`, measured rather than assumed:
`handleNativeEvent` (`GameProvider.tsx`) never calls `setActivePlayerId`, so
reading the wire there would read a stale value from an earlier online game.

Counter-probe: flipping only the `draft-match` census entry back to
`"seat-zero"` drops both new rows — `expected +0 to be 1`.

Does not cover: the pod-draft guest's seat still reaches the store via
`setupDraftMatchAvatars`, which derives it from `matchPairing.type` rather
than from the `assignedPlayerId` the host actually sent in `game_setup`. The
two agree today; unifying them on the wire value is a separate change.

Not evidenced by the new tests: they do not render `GamePage`, so they do not
show the mulligan modal appearing for the guest. They pin the seat authority
that modal reads. The solo-mode and spectator rows are green with and without
the fix and are labelled in the file as nail-downs, not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 20f159a2-4917-4a61-b1d6-c3cc09a3fe14

📥 Commits

Reviewing files that changed from the base of the PR and between 75f2d54 and 0d758a1.

📒 Files selected for processing (4)
  • client/src/hooks/__tests__/useConcedeHandler.test.tsx
  • client/src/hooks/__tests__/usePlayerId.draftMatch.test.tsx
  • client/src/hooks/usePlayerId.ts
  • client/src/stores/gameStore.ts

Included review availability: Your plan includes up to 2 reviews per rolling hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The change adds seat-source metadata to game modes and uses it to resolve local player IDs. The getter and hook retain distinct spectator behavior. Tests cover pod-draft, multiplayer, solo, and spectator modes.

Changes

Seat resolution

Layer / File(s) Summary
Seat-source taxonomy
client/src/stores/gameStore.ts
SeatSource and GAME_MODE_TRAITS now describe seat-zero, wire-assigned, and no-seat modes. seatSource() resolves the configured source.
Shared local seat resolver
client/src/hooks/usePlayerId.ts
currentLocalPlayerId() and usePlayerId() now resolve seats through seatSource, including wire-assigned seats and separate spectator behavior.
Seat resolution validation
client/src/hooks/__tests__/usePlayerId.draftMatch.test.tsx, client/src/hooks/__tests__/useConcedeHandler.test.tsx
Tests cover assigned seats, pod-draft host and guest seats, stale multiplayer data in solo modes, spectator IDs, and preserved store helper behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 0d758

The change centralizes local-seat resolution and adds coverage for draft-match behavior; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the client seat-resolution fix and references the linked issue.
Linked Issues check ✅ Passed The changes add mode-based seat resolution and regression coverage for the draft-match guest issue in #7524.
Out of Scope Changes check ✅ Passed All changes support the seat-resolution fix, including mode metadata, shared resolver behavior, and focused regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

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

@matthewevans matthewevans self-assigned this Aug 18, 2026
@matthewevans

Copy link
Copy Markdown
Member

Deferred by maintainer intake policy — not ignored.

This current head (0d758a1cdd9a2421a2cdc4cf367cf5bb0d829e2c) was triaged as a frontend-only change (client/src/hooks/__tests__/useConcedeHandler.test.tsx, client/src/hooks/__tests__/usePlayerId.draftMatch.test.tsx, client/src/hooks/usePlayerId.ts, client/src/stores/gameStore.ts) by cuinhellcat. 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 Aug 18, 2026
@matthewevans matthewevans removed their assignment Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

Pod-draft guest is seated at 0, so a human-vs-human draft match deadlocks on the opening hand

2 participants