Skip to content

fix(multiplayer): route host seat mutations to the active lobby backend - #1745

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
jsdevninja:fix/multiplayer-host-start-kick
Jun 1, 2026
Merged

fix(multiplayer): route host seat mutations to the active lobby backend#1745
matthewevans merged 4 commits into
phase-rs:mainfrom
jsdevninja:fix/multiplayer-host-start-kick

Conversation

@jsdevninja

Copy link
Copy Markdown
Contributor

Summary

  • Route host seatMutate to the server WebSocket when a waiting server lobby is active, instead of always preferring a stale P2P adapter left over from an earlier session.
  • Clear P2P/broker state when starting server hosting; close the host WebSocket when starting P2P hosting.
  • Keep the pregame lobby open on recoverable server errors while hostingStatus === "waiting" (failed Start shows a toast instead of calling cancelHosting()).
  • Add seatMutateAsync and startLobbyWithCurrentPlayers so P2P "Start now" and "Fill with AI & start" run seat mutations in order.
  • On the server, only auto-start after seat mutations when seat_state.is_full(), matching manual Start and avoiding spurious starts when start_when_full is enabled.

Fixes #1506

Test plan

  • Host a server game (2+ players), fill all seats, click Start game — game starts.
  • Trigger a failed start (e.g. lobby not actually full) — toast appears and the host lobby remains open.
  • Host P2P, then host a server game without a full reload — Start and Kick affect the server lobby.
  • P2P lobby with extra empty seats: Start now removes open seats and starts; Fill with AI works.
  • With Start when full enabled, Kick does not auto-start; replacing a player with AI on a full lobby still auto-starts when intended.
  • pnpm exec vitest run src/stores/__tests__/multiplayerStore.test.ts passes.

@jsdevninja
jsdevninja requested a review from matthewevans as a code owner June 1, 2026 11:08

@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 refactors multiplayer lobby actions to prevent race conditions during game start. It introduces asynchronous seat mutations (seatMutateAsync) and sequential lobby initialization (startLobbyWithCurrentPlayers), alongside cleaner WebSocket and P2P connection teardown helpers. Feedback focuses on improving state-transition robustness by handling connection loss correctly in seatMutateAsync to avoid misleading P2P errors, and consistently selecting store actions via the React hook in HostControlTile instead of bypassing reactivity with getState().

Comment on lines +1049 to +1058
seatMutateAsync: async (mutation) => {
if (isServerHostingActive(get)) {
hostWs!.send(JSON.stringify({
type: "SeatMutate",
data: { mutation },
}));
return;
}
await runP2PSeatMutation(mutation, set);
},

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.

high

[HIGH] Misleading error message on server-hosted lobby connection loss.
Evidence: client/src/stores/multiplayerStore.ts:1049-1058.
Why it matters: When a server-hosted lobby loses connection, isServerHostingActive returns false, causing the mutation to fall through to P2P handling and throw a confusing "P2P host is not active" error instead of "Host connection is not active".
Suggested fix: Check for activeP2PHostAdapter first, and if absent, verify that hostWs is open before sending the mutation.

      seatMutateAsync: async (mutation) => {
        if (activeP2PHostAdapter) {
          await runP2PSeatMutation(mutation, set);
          return;
        }
        if (!isServerHostingActive(get)) {
          throw new Error("Host connection is not active.");
        }
        hostWs!.send(JSON.stringify({
          type: "SeatMutate",
          data: { mutation },
        }));
      },
References
  1. L4. Edge cases: Ensure robust handling of connection loss and other edge cases in multiplayer state transitions. (link)

Comment on lines +257 to +259
const startLobbyWithCurrentPlayers = useMultiplayerStore(
(s) => s.startLobbyWithCurrentPlayers,
);

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] Inconsistent state access and action dispatching.
Evidence: client/src/components/chrome/HostControlTile.tsx:257-259.
Why it matters: Bypassing the selected store actions and using useMultiplayerStore.getState() directly inside fillWithAiAndStart is inconsistent with the rest of the component, which selects actions like startLobbyWithCurrentPlayers and seatMutate via the hook.
Suggested fix: Select seatMutateAsync and showToast at the top of the component using the useMultiplayerStore hook, and use them directly.

Suggested change
const startLobbyWithCurrentPlayers = useMultiplayerStore(
(s) => s.startLobbyWithCurrentPlayers,
);
const startLobbyWithCurrentPlayers = useMultiplayerStore(
(s) => s.startLobbyWithCurrentPlayers,
);
const seatMutateAsync = useMultiplayerStore((s) => s.seatMutateAsync);
const showToast = useMultiplayerStore((s) => s.showToast);
References
  1. L5. Idiomatic code: Adhere to consistent and idiomatic React/Zustand state management patterns across the codebase. (link)

Comment on lines 327 to 347
const fillWithAiAndStart = () => {
if (!haveAnyDeck) return;
for (const slot of waitingSeats) {
const deck = pickRandomAiDeck();
if (!deck) return;
seatMutate({
type: "SetKind",
data: {
seatIndex: slot.playerId,
kind: { type: "Ai", data: { difficulty: "Medium", deck } },
},
});
}
seatMutate({ type: "Start" });
void (async () => {
for (const slot of waitingSeats) {
const deck = pickRandomAiDeck();
if (!deck) return;
await useMultiplayerStore.getState().seatMutateAsync({
type: "SetKind",
data: {
seatIndex: slot.playerId,
kind: { type: "Ai", data: { difficulty: "Medium", deck } },
},
});
}
await useMultiplayerStore.getState().seatMutateAsync({ type: "Start" });
})().catch((err) => {
useMultiplayerStore.getState().showToast(
err instanceof Error ? err.message : String(err),
);
});
};

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] Inconsistent state access and action dispatching.
Evidence: client/src/components/chrome/HostControlTile.tsx:327-347.
Why it matters: Bypassing the selected store actions and using useMultiplayerStore.getState() directly inside fillWithAiAndStart is inconsistent with the rest of the component, which selects actions like startLobbyWithCurrentPlayers and seatMutate via the hook.
Suggested fix: Select seatMutateAsync and showToast at the top of the component using the useMultiplayerStore hook, and use them directly.

  const fillWithAiAndStart = () => {
    if (!haveAnyDeck) return;
    void (async () => {
      for (const slot of waitingSeats) {
        const deck = pickRandomAiDeck();
        if (!deck) return;
        await seatMutateAsync({
          type: "SetKind",
          data: {
            seatIndex: slot.playerId,
            kind: { type: "Ai", data: { difficulty: "Medium", deck } },
          },
        });
      }
      await seatMutateAsync({ type: "Start" });
    })().catch((err) => {
      showToast(err instanceof Error ? err.message : String(err));
    });
  };
References
  1. L5. Idiomatic code: Adhere to consistent and idiomatic React/Zustand state management patterns across the codebase. (link)

Comment on lines +438 to +444
onClick={() => {
void startLobbyWithCurrentPlayers().catch((err) => {
useMultiplayerStore.getState().showToast(
err instanceof Error ? err.message : String(err),
);
});
}}

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] Inconsistent state access and action dispatching.
Evidence: client/src/components/chrome/HostControlTile.tsx:438-444.
Why it matters: Bypassing the selected store actions and using useMultiplayerStore.getState() directly inside fillWithAiAndStart is inconsistent with the rest of the component, which selects actions like startLobbyWithCurrentPlayers and seatMutate via the hook.
Suggested fix: Select seatMutateAsync and showToast at the top of the component using the useMultiplayerStore hook, and use them directly.

Suggested change
onClick={() => {
void startLobbyWithCurrentPlayers().catch((err) => {
useMultiplayerStore.getState().showToast(
err instanceof Error ? err.message : String(err),
);
});
}}
onClick={() => {
void startLobbyWithCurrentPlayers().catch((err) => {
showToast(err instanceof Error ? err.message : String(err));
});
}}
References
  1. L5. Idiomatic code: Adhere to consistent and idiomatic React/Zustand state management patterns across the codebase. (link)

@matthewevans

Copy link
Copy Markdown
Member

Pushed maintainer review fixes on top of current main.

What changed:

  • Addressed Gemini's actionable review feedback without reintroducing the stale-P2P routing bug: server-host sockets still take precedence when active, but a waiting server lobby with no live socket now reports Host connection is not active. instead of falling through to a P2P error.
  • Switched HostControlTile async error handling to use selected Zustand actions (seatMutateAsync, showToast) instead of ad-hoc getState() reads in the click paths.
  • Added store regression coverage for ordered P2P Start now mutations and the disconnected server-host error path.

Local verification:

  • cargo fmt --all
  • git diff --check
  • pnpm --dir client exec vitest run src/stores/__tests__/multiplayerStore.test.ts
  • pnpm --dir client run type-check
  • pnpm --dir client exec eslint src/stores/__tests__/multiplayerStore.test.ts src/stores/multiplayerStore.ts src/components/chrome/HostControlTile.tsx

@matthewevans matthewevans added bug Bug fix ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow labels Jun 1, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jun 1, 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.

Implementation review is clean. The PR now keeps server-host seat mutations routed through the active server socket, reports disconnected server-host state without falling through to P2P, preserves ordered P2P start mutations, and has focused regression coverage plus green CI on head 2097a6e.

Merged via the queue into phase-rs:main with commit 6ba71e1 Jun 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiplayer bugs — You need to "Kick" a player to start the game as the start game button doesn't work (kicking player…

2 participants