diff --git a/client/src/adapter/__tests__/draftPodAdapter.test.ts b/client/src/adapter/__tests__/draftPodAdapter.test.ts index 881e8d4c55..79079a318e 100644 --- a/client/src/adapter/__tests__/draftPodAdapter.test.ts +++ b/client/src/adapter/__tests__/draftPodAdapter.test.ts @@ -88,6 +88,7 @@ const mockGuestSubmitPick = vi.fn(async () => {}); const mockGuestSubmitPickWithDraftEffect = vi.fn(async () => {}); const mockGuestSubmitDeck = vi.fn(async () => {}); const mockGuestLeave = vi.fn(async () => {}); +const mockGuestDispose = vi.fn(); vi.mock("../p2p-draft-guest", () => ({ P2PDraftGuest: vi.fn().mockImplementation(function () { @@ -98,6 +99,7 @@ vi.mock("../p2p-draft-guest", () => ({ submitPickWithDraftEffect: mockGuestSubmitPickWithDraftEffect, submitDeck: mockGuestSubmitDeck, leave: mockGuestLeave, + dispose: mockGuestDispose, view: null, seat: null, token: null, @@ -178,6 +180,7 @@ describe("DraftPodHostAdapter", () => { }); afterEach(async () => { + vi.useRealTimers(); await adapter.dispose(); }); @@ -288,6 +291,154 @@ describe("DraftPodHostAdapter", () => { expect(events).toContainEqual({ type: "viewUpdated", view: restoredView }); }); + it("destroys a post-hostRoom host when its restore is aborted", async () => { + const { hostRoom } = await import("../../network/connection"); + const hostResult = mockHostResult(); + (hostRoom as ReturnType).mockResolvedValue(hostResult); + vi.mocked(loadDraftHostSession).mockResolvedValue({ + persistenceId: "draft-1", + roomCode: "ABCDE", + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + seatTokens: { 0: "host" }, + seatNames: { 0: "Host" }, + kickedTokens: [], + draftStarted: true, + draftCode: "draft-1", + draftSessionJson: "{}", + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + }); + let resolveRestore!: (view: DraftPlayerView | null) => void; + mockHostRestoreFromPersisted.mockImplementationOnce(() => new Promise((resolve) => { + resolveRestore = resolve; + })); + const controller = new AbortController(); + const initializing = adapter.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + persistenceId: "draft-1", + signal: controller.signal, + }); + + await Promise.resolve(); + await Promise.resolve(); + controller.abort(); + resolveRestore(mockView("Drafting")); + + await expect(initializing).rejects.toThrow("initialization aborted"); + expect(mockHostDispose).toHaveBeenCalledOnce(); + expect(hostResult.destroy).toHaveBeenCalledOnce(); + expect(mockHostInitialize).not.toHaveBeenCalled(); + expect(adapter.roomCode).toBeNull(); + }); + + it("cleans a pending local host when the adapter is disposed during restore", async () => { + const { hostRoom } = await import("../../network/connection"); + const hostResult = mockHostResult(); + (hostRoom as ReturnType).mockResolvedValue(hostResult); + let resolveSession!: (session: null) => void; + vi.mocked(loadDraftHostSession).mockImplementationOnce(() => new Promise((resolve) => { + resolveSession = resolve; + })); + const initializing = adapter.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + persistenceId: "draft-1", + }); + + await Promise.resolve(); + await Promise.resolve(); + const disposing = adapter.dispose({ preserveSession: true }); + resolveSession(null); + + await disposing; + await expect(initializing).rejects.toThrow("initialization aborted"); + expect(hostResult.destroy).toHaveBeenCalledOnce(); + expect(mockHostDispose).toHaveBeenCalledOnce(); + expect(mockHostInitialize).not.toHaveBeenCalled(); + }); + + it("destroys a late hostRoom result before the same room code is rehosted", async () => { + const { hostRoom } = await import("../../network/connection"); + const staleHostResult = mockHostResult(); + let resolveHostRoom!: (result: ReturnType) => void; + (hostRoom as ReturnType).mockImplementationOnce( + () => new Promise>((resolve) => { + resolveHostRoom = resolve; + }), + ); + const initializing = adapter.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + preferredRoomCode: "ABCDE", + }); + + await Promise.resolve(); + const disposing = adapter.dispose({ preserveSession: true }); + resolveHostRoom(staleHostResult); + + await disposing; + await expect(initializing).rejects.toThrow("initialization aborted"); + expect(staleHostResult.destroy).toHaveBeenCalledOnce(); + + const replacement = new DraftPodHostAdapter(); + const replacementResult = mockHostResult(); + (hostRoom as ReturnType).mockResolvedValueOnce(replacementResult); + await replacement.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + preferredRoomCode: "ABCDE", + }); + + expect(replacement.roomCode).toBe("ABCDE"); + await replacement.dispose({ preserveSession: true }); + }); + + it("does not publish a host if cancellation wins its local initialize race", async () => { + let resolveInitialize!: () => void; + mockHostInitialize.mockImplementationOnce(() => new Promise((resolve) => { + resolveInitialize = resolve; + })); + const controller = new AbortController(); + const initializing = adapter.initialize({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + signal: controller.signal, + }); + + await Promise.resolve(); + await Promise.resolve(); + controller.abort(); + resolveInitialize(); + + await expect(initializing).rejects.toThrow("initialization aborted"); + expect(mockHostDispose).toHaveBeenCalledOnce(); + await expect(adapter.startDraft()).rejects.toThrow("Host not initialized"); + }); + it("delegates submitPick and returns view", async () => { await adapter.initialize({ poolInput: { type: "Set", data: { set_pool_json: "{}" } }, @@ -467,6 +618,7 @@ describe("DraftPodGuestAdapter", () => { afterEach(async () => { await adapter.dispose(); + vi.useRealTimers(); }); it("starts in idle status", () => { @@ -478,6 +630,7 @@ describe("DraftPodGuestAdapter", () => { it("transitions to lobby after initialization", async () => { await adapter.initialize({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -489,15 +642,104 @@ describe("DraftPodGuestAdapter", () => { expect(statusEvents).toContainEqual({ type: "statusChanged", status: "lobby" }); }); - it("looks up reconnect tokens by host peer id", async () => { + it("does not look up a reconnect token for a new join", async () => { const { loadDraftGuestSession } = await import("../../services/draftPersistence"); + await adapter.initialize({ + kind: "new", + roomCode: "ABCDE", + displayName: "Alice", + }); + + expect(loadDraftGuestSession).not.toHaveBeenCalled(); + }); + it("refuses to send a reconnect capability to a different host peer", async () => { + const { joinRoom } = await import("../../network/connection"); + const mismatched = { + ...mockJoinResult(), + conn: { peer: "phase2-OTHER" }, + }; + (joinRoom as ReturnType).mockResolvedValueOnce(mismatched); + + await expect(adapter.initialize({ + kind: "reconnect", + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", + })).rejects.toThrow("host changed"); + expect(mockGuestInitialize).not.toHaveBeenCalled(); + expect(mismatched.destroyPeer).toHaveBeenCalledOnce(); + }); + + it("retries only credentialed reconnect room joins within a bounded budget", async () => { + vi.useFakeTimers(); + const { joinRoom } = await import("../../network/connection"); + (joinRoom as ReturnType) + .mockRejectedValueOnce(new Error("first transport failure")) + .mockRejectedValueOnce(new Error("second transport failure")) + .mockResolvedValueOnce(mockJoinResult()); + + const reconnecting = adapter.initialize({ + kind: "reconnect", + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", + }); + await vi.runAllTimersAsync(); + await expect(reconnecting).resolves.toBeUndefined(); + expect(joinRoom).toHaveBeenCalledTimes(3); + }); + + it("aborts a credentialed reconnect join without starting another attempt", async () => { + vi.useFakeTimers(); + const { joinRoom } = await import("../../network/connection"); + (joinRoom as ReturnType).mockRejectedValue(new Error("transport failure")); + const controller = new AbortController(); + + const reconnecting = adapter.initialize({ + kind: "reconnect", + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", + signal: controller.signal, + }); + await Promise.resolve(); + controller.abort(); + await expect(reconnecting).rejects.toMatchObject({ name: "AbortError" }); + expect(joinRoom).toHaveBeenCalledTimes(1); + }); + + it("passes a reconnect config through without a join fallback", async () => { await adapter.initialize({ + kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", }); - expect(loadDraftGuestSession).toHaveBeenCalledWith("phase2-ABCDE"); + const { P2PDraftGuest } = await import("../p2p-draft-guest"); + expect(P2PDraftGuest).toHaveBeenLastCalledWith( + expect.anything(), + "phase2-ABCDE", + expect.anything(), + expect.objectContaining({ kind: "reconnect", draftToken: "opaque-token" }), + ); + }); + + it("preserves recovery credentials for lifecycle disposal but clears them on explicit leave", async () => { + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); + await adapter.dispose(); + expect(mockGuestDispose).toHaveBeenCalled(); + expect(mockGuestLeave).not.toHaveBeenCalled(); + + adapter = new DraftPodGuestAdapter(); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); + await adapter.dispose({ preserveRecovery: false }); + expect(mockGuestLeave).toHaveBeenCalled(); }); it("emits error on connection failure", async () => { @@ -507,7 +749,7 @@ describe("DraftPodGuestAdapter", () => { ); await expect( - adapter.initialize({ roomCode: "ZZZZZ", displayName: "Bob" }), + adapter.initialize({ kind: "new", roomCode: "ZZZZZ", displayName: "Bob" }), ).rejects.toThrow("Connection timed out"); expect(adapter.status).toBe("error"); @@ -518,21 +760,21 @@ describe("DraftPodGuestAdapter", () => { }); it("delegates submitPick to P2PDraftGuest", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); await adapter.submitPick("card-456"); expect(mockGuestSubmitPick).toHaveBeenCalledWith("card-456"); }); it("delegates draft-effect picks to P2PDraftGuest", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]); expect(mockGuestSubmitPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]); }); it("delegates submitDeck to P2PDraftGuest", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); await adapter.submitDeck(["Swamp", "Mountain"]); expect(mockGuestSubmitDeck).toHaveBeenCalledWith(["Swamp", "Mountain"]); @@ -544,7 +786,7 @@ describe("DraftPodGuestAdapter", () => { }); it("maps P2PDraftGuest events to DraftPodGuestEvents", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); const guestEventHandler = mockGuestOnEvent.mock.calls[0][0]; @@ -575,6 +817,15 @@ describe("DraftPodGuestAdapter", () => { guestEventHandler({ type: "draftResumed" }); expect(events).toContainEqual({ type: "draftResumed" }); + guestEventHandler({ + type: "reconnectFailed", + failure: { kind: "retryable", message: "Host is restarting" }, + }); + expect(events).toContainEqual({ + type: "reconnectFailed", + failure: { kind: "retryable", message: "Host is restarting" }, + }); + // Simulate kicked guestEventHandler({ type: "kicked", reason: "Host kicked you" }); expect(adapter.status).toBe("kicked"); @@ -599,7 +850,7 @@ describe("DraftPodGuestAdapter", () => { }); it("updates status based on DraftPlayerView status", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); const guestEventHandler = mockGuestOnEvent.mock.calls[0][0]; guestEventHandler({ type: "viewUpdated", view: mockView("Drafting") }); @@ -613,10 +864,11 @@ describe("DraftPodGuestAdapter", () => { }); it("cleans up on dispose", async () => { - await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" }); + await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" }); await adapter.dispose(); - expect(mockGuestLeave).toHaveBeenCalledOnce(); + expect(mockGuestDispose).toHaveBeenCalledOnce(); + expect(mockGuestLeave).not.toHaveBeenCalled(); expect(adapter.status).toBe("idle"); expect(adapter.currentView).toBeNull(); expect(adapter.seatIndex).toBeNull(); diff --git a/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts b/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts index ecff81901d..d64b66aa54 100644 --- a/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts +++ b/client/src/adapter/__tests__/p2pDraftEffectPick.test.ts @@ -9,7 +9,7 @@ describe("P2P draft-effect picks", () => { {} as never, "host-peer", {} as never, - "Alice", + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, ); const send = vi.fn(async () => {}); (guest as unknown as { session: { send: typeof send } }).session = { send }; diff --git a/client/src/adapter/__tests__/p2pDraftFirstContact.test.ts b/client/src/adapter/__tests__/p2pDraftFirstContact.test.ts new file mode 100644 index 0000000000..b0b73666f5 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftFirstContact.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import { P2PDraftGuest } from "../p2p-draft-guest"; +import { P2PDraftHost } from "../p2p-draft-host"; +import { DRAFT_PROTOCOL_VERSION } from "../../network/draftProtocol"; + +const sessionState = vi.hoisted(() => ({ + firstContact: null as ((message: unknown) => void | Promise) | null, + send: vi.fn(async () => {}), + close: vi.fn(), +})); + +vi.mock("../../network/draftPeerSession", () => ({ + createDraftPeerSession: vi.fn(() => ({ + onMessage: vi.fn((handler: (message: unknown) => void) => { + sessionState.firstContact = handler; + return vi.fn(); + }), + onDisconnect: vi.fn(() => vi.fn()), + send: sessionState.send, + close: sessionState.close, + })), +})); + +describe("P2P draft first-contact gate", () => { + it("puts the exact draft version on both join and reconnect frames", async () => { + const send = vi.fn(async () => {}); + const newGuest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const reconnectingGuest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", draftToken: "opaque-token" }, + ); + (newGuest as unknown as { session: { send: typeof send } }).session = { send }; + await (newGuest as unknown as { sendFirstContact: (session: unknown, reconnect: boolean) => Promise }) + .sendFirstContact({ send }, false); + expect(send).toHaveBeenCalledWith({ + type: "draft_join", + displayName: "Alice", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + }); + + send.mockClear(); + (reconnectingGuest as unknown as { session: { send: typeof send } }).session = { send }; + await (reconnectingGuest as unknown as { sendFirstContact: (session: unknown, reconnect: boolean) => Promise }) + .sendFirstContact({ send }, true); + expect(send).toHaveBeenCalledWith({ + type: "draft_reconnect", + draftToken: "opaque-token", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + }); + }); + + it("rejects an old first frame before it can allocate a seat", async () => { + sessionState.firstContact = null; + sessionState.send.mockClear(); + sessionState.close.mockClear(); + let releaseSend!: () => void; + sessionState.send.mockImplementationOnce(() => new Promise((resolve) => { + releaseSend = resolve; + })); + const host = new P2PDraftHost( + { id: "phase2-ABCDE" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + "Host", + "Swiss", + "Competitive", + ); + const allocate = vi.fn(); + (host as unknown as { handleNewGuest: typeof allocate }).handleNewGuest = allocate; + + (host as unknown as { handleNewConnection: (connection: unknown) => void }) + .handleNewConnection({} as never); + const rejected = sessionState.firstContact!({ + type: "draft_join", + displayName: "Alice", + } as never); + + expect(allocate).not.toHaveBeenCalled(); + expect(sessionState.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_reconnect_rejected", + kind: "ProtocolMismatch", + })); + // `DraftPeerSession.send()` queues asynchronous encoding. Closing before + // that promise settles suppresses its delivery, so the host must await it. + expect(sessionState.close).not.toHaveBeenCalled(); + releaseSend(); + await rejected; + expect(sessionState.close).toHaveBeenCalledWith("Draft protocol mismatch"); + }); +}); diff --git a/client/src/adapter/__tests__/p2pDraftGuestHandshake.test.ts b/client/src/adapter/__tests__/p2pDraftGuestHandshake.test.ts new file mode 100644 index 0000000000..480673a835 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftGuestHandshake.test.ts @@ -0,0 +1,531 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type SavedDeckSubmission = { + hostPeerId: string; + draftCode: string; + roomCode: string; + draftToken: string; + submissionId: string; + mainDeck: string[]; + timestamp: number; +}; + +const sessionState = vi.hoisted(() => ({ + sessions: [] as Array<{ + handler: ((message: unknown) => void) | null; + send: ReturnType; + close: ReturnType; + }>, +})); + +const persistenceState = vi.hoisted(() => ({ + clearDraftGuestRecovery: vi.fn(async () => {}), + clearDraftDeckSubmission: vi.fn(async () => {}), + loadDraftDeckSubmission: vi.fn< + (hostPeerId: string, identity?: { roomCode: string; draftToken: string }) => Promise + >(async () => null), + saveDraftDeckSubmission: vi.fn(async () => {}), + saveActiveDraftGuest: vi.fn(), + saveDraftGuestSession: vi.fn(async () => {}), +})); + +vi.mock("../../network/draftPeerSession", () => ({ + createDraftPeerSession: vi.fn(() => { + const session = { + handler: null as ((message: unknown) => void) | null, + send: vi.fn(async () => {}), + close: vi.fn(), + }; + sessionState.sessions.push(session); + return { + onMessage: vi.fn((handler: (message: unknown) => void) => { + session.handler = handler; + return vi.fn(); + }), + onDisconnect: vi.fn(() => vi.fn()), + send: session.send, + close: session.close, + }; + }), +})); + +vi.mock("../../services/draftPersistence", () => persistenceState); + +import { P2PDraftGuest } from "../p2p-draft-guest"; +import { DRAFT_PROTOCOL_VERSION, validateDraftMessage } from "../../network/draftProtocol"; + +const reconnectAck = { + type: "draft_reconnect_ack", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + seatIndex: 2, + draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, +}; + +describe("P2P draft guest handshake attempts", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + }); + + it("keeps a deck submission in the participant outbox until its matching receipt", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", + seatIndex: 2, + draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await handshake; + + const submitted = guest.submitDeck(["Island"]); + await vi.waitFor(() => expect(persistenceState.saveDraftDeckSubmission).toHaveBeenCalledOnce()); + const sent = sessionState.sessions[0]!.send.mock.calls.find( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )?.[0] as { submissionId: string; mainDeck: string[] }; + expect(sent).toMatchObject({ mainDeck: ["Island"] }); + const sendIndex = sessionState.sessions[0]!.send.mock.calls.findIndex( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + ); + expect(persistenceState.saveDraftDeckSubmission.mock.invocationCallOrder[ + persistenceState.saveDraftDeckSubmission.mock.invocationCallOrder.length - 1 + ]!) + .toBeLessThan(sessionState.sessions[0]!.send.mock.invocationCallOrder[sendIndex]!); + + sessionState.sessions[0]!.handler!({ + type: "draft_deck_submit_ack", + submissionId: sent.submissionId, + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await submitted; + expect(persistenceState.clearDraftDeckSubmission).toHaveBeenCalledWith( + "phase2-ABCDE", + sent.submissionId, + ); + }); + + it("serializes two rapid deck submits into one outbox command and acknowledgement", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + const guest = new P2PDraftGuest( + {} as never, "phase2-ABCDE", {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", seatIndex: 2, draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await handshake; + await Promise.resolve(); + + let releaseLoad!: () => void; + persistenceState.loadDraftDeckSubmission.mockClear(); + persistenceState.loadDraftDeckSubmission.mockImplementationOnce(() => new Promise((resolve) => { + releaseLoad = () => resolve(null); + })); + + const first = guest.submitDeck(["Island"]); + const second = guest.submitDeck(["Island"]); + expect(second).toBe(first); + expect(persistenceState.loadDraftDeckSubmission).toHaveBeenCalledTimes(1); + releaseLoad(); + await vi.waitFor(() => expect(persistenceState.saveDraftDeckSubmission).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(sessionState.sessions[0]!.send.mock.calls.some( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )).toBe(true)); + const commands = sessionState.sessions[0]!.send.mock.calls.filter( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + ); + expect(commands).toHaveLength(1); + const command = commands[0]![0] as { submissionId: string }; + sessionState.sessions[0]!.handler!({ + type: "draft_deck_submit_ack", submissionId: command.submissionId, + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + }); + + it("releases only the rejected deck command so a corrected deck gets a new id", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + const guest = new P2PDraftGuest( + {} as never, "phase2-ABCDE", {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", seatIndex: 2, draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await handshake; + const first = guest.submitDeck(["Island"]); + await vi.waitFor(() => expect(sessionState.sessions[0]!.send.mock.calls.some( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )).toBe(true)); + const firstCommand = sessionState.sessions[0]!.send.mock.calls.find( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )?.[0] as { submissionId: string }; + sessionState.sessions[0]!.handler!({ + type: "draft_error", submissionId: firstCommand.submissionId, reason: "Deck too small", + submissionDisposition: "Rejected", + }); + await expect(first).rejects.toThrow("Deck too small"); + expect(persistenceState.clearDraftDeckSubmission).toHaveBeenCalledWith( + "phase2-ABCDE", firstCommand.submissionId, + ); + }); + + it("retains a deck outbox when the host reports a retryable durable failure", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + persistenceState.clearDraftDeckSubmission.mockClear(); + const guest = new P2PDraftGuest( + {} as never, "phase2-ABCDE", {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", seatIndex: 2, draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await handshake; + const submitted = guest.submitDeck(["Island"]); + await vi.waitFor(() => expect(sessionState.sessions[0]!.send.mock.calls.some( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )).toBe(true)); + const command = sessionState.sessions[0]!.send.mock.calls.find( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )?.[0] as { submissionId: string }; + sessionState.sessions[0]!.handler!({ + type: "draft_error", submissionId: command.submissionId, reason: "IDB unavailable", + submissionDisposition: "Retryable", + }); + await expect(submitted).rejects.toThrow("IDB unavailable"); + expect(persistenceState.clearDraftDeckSubmission).not.toHaveBeenCalledWith( + "phase2-ABCDE", command.submissionId, + ); + }); + + it("reconnect replay resolves the original deck submission promise", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockResolvedValue(null); + const guest = new P2PDraftGuest( + {} as never, "phase2-ABCDE", {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const initialHandshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", seatIndex: 2, draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await initialHandshake; + const submitted = guest.submitDeck(["Island"]); + await vi.waitFor(() => expect(sessionState.sessions[0]!.send.mock.calls.some( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )).toBe(true)); + const command = sessionState.sessions[0]!.send.mock.calls.find( + ([message]) => (message as { type?: string }).type === "draft_submit_deck", + )?.[0] as { submissionId: string }; + persistenceState.loadDraftDeckSubmission.mockResolvedValue({ + hostPeerId: "phase2-ABCDE", draftCode: "draft-xyz", roomCode: "ABCDE", + draftToken: "opaque-token", submissionId: command.submissionId, + mainDeck: ["Island"], timestamp: Date.now(), + }); + + const reconnect = privateGuest.handshakeOn({} as never, undefined, true); + await Promise.resolve(); + sessionState.sessions[1]!.handler!(reconnectAck); + await reconnect; + await vi.waitFor(() => expect(sessionState.sessions[1]!.send).toHaveBeenCalledWith( + expect.objectContaining({ type: "draft_submit_deck", submissionId: command.submissionId }), + )); + sessionState.sessions[1]!.handler!({ + type: "draft_deck_submit_ack", submissionId: command.submissionId, + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await submitted; + }); + + it("does not replay an outbox belonging to a different pod on the same host peer", async () => { + sessionState.sessions.length = 0; + persistenceState.loadDraftDeckSubmission.mockImplementation(async (_hostPeerId: string, identity?: { + roomCode: string; + draftToken: string; + }) => { + if (identity?.roomCode === "OLD12" && identity.draftToken === "old-token") { + return { + hostPeerId: "phase2-ABCDE", + draftCode: "old-pod", + roomCode: "OLD12", + draftToken: "old-token", + submissionId: "old-submission", + mainDeck: ["Island"], + timestamp: Date.now(), + }; + } + return null; + }); + const guest = new P2PDraftGuest( + {} as never, "phase2-ABCDE", {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "new-token", seatIndex: 2, draftCode: "new-pod", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await handshake; + await vi.waitFor(() => expect(persistenceState.loadDraftDeckSubmission).toHaveBeenCalledWith( + "phase2-ABCDE", { roomCode: "ABCDE", draftToken: "new-token" }, + )); + expect(sessionState.sessions[0]!.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_submit_deck", + })); + }); + + it("does not publish a reload locator until the guest token has committed", async () => { + sessionState.sessions.length = 0; + persistenceState.saveActiveDraftGuest.mockClear(); + persistenceState.saveDraftGuestSession.mockClear(); + let finishIdbWrite!: () => void; + persistenceState.saveDraftGuestSession.mockImplementationOnce(() => new Promise((resolve) => { + finishIdbWrite = resolve; + })); + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + + let settled = false; + const handshake = privateGuest.handshakeOn({} as never, undefined, false) + .then(() => { settled = true; }); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", + seatIndex: 2, + draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + await Promise.resolve(); + + // A reload in this gap sees no locator and therefore cannot attempt a + // reconnect that lacks a committed token. + expect(persistenceState.saveActiveDraftGuest).not.toHaveBeenCalled(); + expect(settled).toBe(false); + + finishIdbWrite(); + await handshake; + expect(persistenceState.saveDraftGuestSession).toHaveBeenCalledBefore( + persistenceState.saveActiveDraftGuest, + ); + expect(persistenceState.saveActiveDraftGuest).toHaveBeenCalledWith({ + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + }); + }); + + it("waits for token persistence before completing a reconnect acknowledgement", async () => { + sessionState.sessions.length = 0; + persistenceState.saveActiveDraftGuest.mockClear(); + persistenceState.saveDraftGuestSession.mockClear(); + let finishIdbWrite!: () => void; + persistenceState.saveDraftGuestSession.mockImplementationOnce(() => new Promise((resolve) => { + finishIdbWrite = resolve; + })); + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", draftToken: "opaque-token" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + + let settled = false; + const handshake = privateGuest.handshakeOn({} as never, undefined, true) + .then(() => { settled = true; }); + await Promise.resolve(); + sessionState.sessions[0]!.handler!(reconnectAck); + await Promise.resolve(); + + expect(persistenceState.saveActiveDraftGuest).not.toHaveBeenCalled(); + expect(settled).toBe(false); + + finishIdbWrite(); + await handshake; + expect(persistenceState.saveDraftGuestSession).toHaveBeenCalledBefore( + persistenceState.saveActiveDraftGuest, + ); + }); + + it("does not publish a locator when the token write fails", async () => { + sessionState.sessions.length = 0; + persistenceState.saveActiveDraftGuest.mockClear(); + persistenceState.saveDraftGuestSession.mockRejectedValueOnce(new Error("IDB unavailable")); + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "new", roomCode: "ABCDE", displayName: "Alice" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + + const handshake = privateGuest.handshakeOn({} as never, undefined, false); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_welcome", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + draftToken: "opaque-token", + seatIndex: 2, + draftCode: "draft-xyz", + view: { status: "Deckbuilding", draft_effects: [], seats: [] }, + }); + + await expect(handshake).rejects.toThrow("IDB unavailable"); + expect(persistenceState.saveActiveDraftGuest).not.toHaveBeenCalled(); + }); + + it("rejects an incompatible welcome immediately with typed non-retryable recovery", async () => { + sessionState.sessions.length = 0; + const events: unknown[] = []; + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", draftToken: "opaque-token" }, + ); + guest.onEvent((event) => events.push(event)); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + + const handshake = privateGuest.handshakeOn({} as never, undefined, true); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + ...reconnectAck, + draftProtocolVersion: DRAFT_PROTOCOL_VERSION - 1, + }); + + await expect(handshake).rejects.toThrow("Draft protocol mismatch"); + expect(events).toContainEqual(expect.objectContaining({ + type: "reconnectFailed", + failure: expect.objectContaining({ kind: "incompatible" }), + })); + }); + + it("treats a v14 rejection as terminal without revoking recovery credentials", async () => { + sessionState.sessions.length = 0; + persistenceState.clearDraftGuestRecovery.mockClear(); + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", draftToken: "opaque-token" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + terminated: boolean; + }; + + const attempt = privateGuest.handshakeOn({} as never, undefined, true); + await Promise.resolve(); + sessionState.sessions[0]!.handler!(validateDraftMessage({ + type: "draft_reconnect_rejected", + reason: "Refresh to reconnect", + })); + + await expect(attempt).rejects.toThrow("Refresh to reconnect"); + expect(privateGuest.terminated).toBe(true); + expect(persistenceState.clearDraftGuestRecovery).not.toHaveBeenCalled(); + }); + + it("cannot let a delayed acknowledgement from a retired NoReconnectWindow attempt promote a newer attempt", async () => { + sessionState.sessions.length = 0; + const guest = new P2PDraftGuest( + {} as never, + "phase2-ABCDE", + {} as never, + { kind: "reconnect", roomCode: "ABCDE", displayName: "Alice", draftToken: "opaque-token" }, + ); + const privateGuest = guest as unknown as { + handshakeOn: (connection: unknown, signal: AbortSignal | undefined, reconnect: boolean) => Promise; + }; + + const first = privateGuest.handshakeOn({} as never, undefined, true); + await Promise.resolve(); + sessionState.sessions[0]!.handler!({ + type: "draft_reconnect_rejected", + kind: "NoReconnectWindow", + reason: "Another connection still owns this seat", + }); + await expect(first).rejects.toThrow("Another connection"); + expect(sessionState.sessions[0]!.close).toHaveBeenCalled(); + + let secondSettled = false; + const second = privateGuest.handshakeOn({} as never, undefined, true) + .then(() => { secondSettled = true; }); + await Promise.resolve(); + + // A late ack from the retired transport is ignored by the active-session + // identity gate rather than resolving the newer handshake. + sessionState.sessions[0]!.handler!(reconnectAck); + await Promise.resolve(); + expect(secondSettled).toBe(false); + + sessionState.sessions[1]!.handler!(reconnectAck); + await expect(second).resolves.toBeUndefined(); + expect(secondSettled).toBe(true); + }); +}); diff --git a/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts b/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts index 98fbc65f3b..271069fd0c 100644 --- a/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts +++ b/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts @@ -32,7 +32,7 @@ describe("P2PDraftHost Bo3", () => { }; } - it("authorizes both Traditional sideboards only after both held commands arrive", () => { + it("authorizes both Traditional sideboards only after both held commands arrive", async () => { const host = new P2PDraftHost( { id: "host" } as never, () => () => {}, { type: "Set", data: { set_pool_json: "{}" } } as never, @@ -64,7 +64,7 @@ describe("P2PDraftHost Bo3", () => { host.submitAuthorized(1, command(1, launch1)); expect(sent.get(1)).toEqual([]); host.submitAuthorized(2, command(2, launch2)); - expect(sent.get(1)?.[0]?.type).toBe("draft_bo3_intergame_authorized"); + await vi.waitFor(() => expect(sent.get(1)?.[0]?.type).toBe("draft_bo3_intergame_authorized")); expect(sent.get(2)?.[0]?.type).toBe("draft_bo3_intergame_authorized"); expect(privateHost.intergameCommands.snapshot().every((entry) => entry.status === "Executing")).toBe(true); }); @@ -137,7 +137,7 @@ describe("P2PDraftHost Bo3", () => { }); describe("BO3-02: sideboard timer auto-submit", () => { - it("issues unchanged deck defaults through the authorized intergame ledger", () => { + it("issues unchanged deck defaults through the authorized intergame ledger", async () => { vi.useFakeTimers(); try { const host = new P2PDraftHost( @@ -198,7 +198,7 @@ describe("P2PDraftHost Bo3", () => { vi.advanceTimersByTime(60_000); for (const seat of [1, 2]) { - expect(sent.get(seat)).toContainEqual(expect.objectContaining({ + await vi.waitFor(() => expect(sent.get(seat)).toContainEqual(expect.objectContaining({ type: "draft_bo3_intergame_authorized", command: expect.objectContaining({ seat, @@ -208,7 +208,7 @@ describe("P2PDraftHost Bo3", () => { sideboard: [{ name: "Negate", count: 1 }], }, }), - })); + }))); } expect(privateHost.intergameCommands.snapshot().every((command) => command.status === "Executing")).toBe(true); host.dispose(); @@ -217,7 +217,7 @@ describe("P2PDraftHost Bo3", () => { } }); - it("issues the play-first default through the same ledger when the choice timer expires", () => { + it("issues the play-first default through the same ledger when the choice timer expires", async () => { vi.useFakeTimers(); try { const host = new P2PDraftHost( @@ -257,10 +257,10 @@ describe("P2PDraftHost Bo3", () => { privateHost.startPlayDrawTimer("bo3-1"); vi.advanceTimersByTime(10_000); - expect(sent).toContainEqual(expect.objectContaining({ + await vi.waitFor(() => expect(sent).toContainEqual(expect.objectContaining({ type: "draft_bo3_intergame_authorized", command: expect.objectContaining({ payload: { type: "ChoosePlayDraw", playFirst: true } }), - })); + }))); host.dispose(); } finally { vi.useRealTimers(); @@ -269,7 +269,7 @@ describe("P2PDraftHost Bo3", () => { }); describe("BO3-03: no timer in Casual", () => { - it("publishes an untimed sideboard prompt without arming the production timer", () => { + it("publishes an untimed sideboard prompt without arming the production timer", async () => { const host = new P2PDraftHost( { id: "host" } as never, () => () => {}, { type: "Set", data: { set_pool_json: "{}" } } as never, @@ -283,10 +283,10 @@ describe("P2PDraftHost Bo3", () => { ); expect(host.activeTimerContext).toBeNull(); - expect(events).toContainEqual(expect.objectContaining({ + await vi.waitFor(() => expect(events).toContainEqual(expect.objectContaining({ type: "bo3SideboardPrompt", timerMs: 0, - })); + }))); host.dispose(); }); }); diff --git a/client/src/adapter/__tests__/p2pDraftHostFirstContactFlush.test.ts b/client/src/adapter/__tests__/p2pDraftHostFirstContactFlush.test.ts new file mode 100644 index 0000000000..6047d1e7c9 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftHostFirstContactFlush.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { P2PDraftHost } from "../p2p-draft-host"; +import { createDraftPeerSession } from "../../network/draftPeerSession"; +import type { DraftPeerSession } from "../../network/draftPeerSession"; +import { decodeDraftWireMessage } from "../../network/draftProtocol"; +import type { DraftReconnectRejectionKind } from "../../network/draftProtocol"; + +describe("P2P draft host reconnect rejection", () => { + it.each([ + ["ProtocolMismatch", "Refresh both windows", "Draft protocol mismatch"], + ["Kicked", "Player kicked", "Kicked"], + ["UnknownToken", "Unknown token", "Unknown token"], + ["NoReconnectWindow", "No grace window active for this seat", "Not in grace"], + ] as const)("flushes %s through the real DraftPeerSession before closing", async (kind, reason, closeReason) => { + const order: string[] = []; + const send = vi.fn((_: Uint8Array) => order.push("send")); + const conn = { + open: true, + on: vi.fn(), + send, + close: vi.fn(() => order.push("close")), + }; + const session = createDraftPeerSession(conn as never); + const host = new P2PDraftHost( + { id: "phase2-ABCDE" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + "Host", + "Swiss", + "Competitive", + ); + + await (host as unknown as { + rejectAndClose: (session: DraftPeerSession, kind: DraftReconnectRejectionKind, reason: string, closeReason: string) => Promise; + }).rejectAndClose(session, kind, reason, closeReason); + + expect(send).toHaveBeenCalledOnce(); + expect(conn.close).toHaveBeenCalledOnce(); + expect(order).toEqual(["send", "close"]); + await expect(decodeDraftWireMessage(send.mock.calls[0]![0])).resolves.toMatchObject({ + type: "draft_reconnect_rejected", + kind, + reason, + }); + }); +}); diff --git a/client/src/adapter/__tests__/p2pDraftHostPersistence.test.ts b/client/src/adapter/__tests__/p2pDraftHostPersistence.test.ts new file mode 100644 index 0000000000..b306034495 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftHostPersistence.test.ts @@ -0,0 +1,685 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { clearDraftHostSession, saveDraftHostSession } = vi.hoisted(() => ({ + clearDraftHostSession: vi.fn(async () => {}), + saveDraftHostSession: vi.fn<(id: string, session: unknown) => Promise>(async () => {}), +})); + +vi.mock("../draft-adapter", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DraftAdapter: vi.fn().mockImplementation(function () { + return {}; + }), + }; +}); + +vi.mock("../../services/draftPersistence", () => ({ + clearDraftHostSession, + saveDraftHostSession, +})); + +import { P2PDraftHost } from "../p2p-draft-host"; + +type PersistenceHost = { + adapter: { exportSession: () => Promise }; + draftStarted: boolean; + persistQueue: Promise; + persistSession: () => void; +}; + +type AdmissionHost = PersistenceHost & { + procedure: { packs_per_player: number; min_deck_size: number }; + handleNewGuest: (session: unknown, displayName: string) => Promise; + handleReconnect: (session: unknown, draftToken: string) => Promise; + guestSessions: Map; + seatTokens: Map; +}; + +function recoveredHost(hostDisplayName: string): P2PDraftHost { + return new P2PDraftHost( + { id: hostDisplayName } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + hostDisplayName, + "Swiss", + "Casual", + undefined, + "shared-recovery", + "ABCDE", + ); +} + +function ephemeralHost(hostDisplayName: string): P2PDraftHost { + return new P2PDraftHost( + { id: hostDisplayName } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 8, + hostDisplayName, + "Swiss", + "Casual", + ); +} + +describe("P2PDraftHost persistence disposal", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("fences a disposed recovery's queued save before a newer recovery can persist", async () => { + const stale = recoveredHost("Stale host"); + const stalePrivate = stale as unknown as PersistenceHost; + stalePrivate.draftStarted = true; + let resolveStaleExport!: (session: string) => void; + stalePrivate.adapter.exportSession = vi.fn(() => new Promise((resolve) => { + resolveStaleExport = resolve; + })); + stalePrivate.persistSession(); + await Promise.resolve(); + expect(stalePrivate.adapter.exportSession).toHaveBeenCalledOnce(); + + // Route cancellation disposes the stale recovery while its queued snapshot + // is still loading. The fence must be set before this promise can continue. + const disposeStale = stale.dispose(); + + const current = recoveredHost("Current host"); + const currentPrivate = current as unknown as PersistenceHost; + currentPrivate.draftStarted = false; + currentPrivate.persistSession(); + await currentPrivate.persistQueue; + + expect(saveDraftHostSession).toHaveBeenCalledTimes(1); + expect(saveDraftHostSession).toHaveBeenLastCalledWith( + "shared-recovery", + expect.objectContaining({ hostDisplayName: "Current host" }), + ); + + resolveStaleExport("{\"status\":\"Drafting\"}"); + await disposeStale; + + expect(saveDraftHostSession).toHaveBeenCalledTimes(1); + await current.dispose(); + }); + + it("releases pause and reconnect state when a disconnected seat becomes a bot or is kicked", async () => { + const host = ephemeralHost("Host"); + const privateHost = host as unknown as { + adapter: { replaceSeatWithBot: ReturnType; getViewForSeat: ReturnType }; + disconnectedSeats: Map | null }>; + expiredDisconnectedSeats: Set; + seatTokens: Map; + seatNames: Map; + paused: boolean; + replaceSeatWithBot: (seat: number) => Promise; + kickPlayerDurably: (seat: number, reason: string) => Promise; + }; + privateHost.adapter.replaceSeatWithBot = vi.fn(async () => ({})); + privateHost.adapter.getViewForSeat = vi.fn(async () => ({ status: "Lobby" })); + privateHost.disconnectedSeats.set(1, { disconnectedAt: Date.now(), timer: setTimeout(() => {}, 60_000) }); + privateHost.expiredDisconnectedSeats.add(1); + privateHost.seatTokens.set(1, "guest-token"); + privateHost.seatNames.set(1, "Guest"); + privateHost.paused = true; + + await privateHost.replaceSeatWithBot(1); + + expect(privateHost.disconnectedSeats.has(1)).toBe(false); + expect(privateHost.expiredDisconnectedSeats.has(1)).toBe(false); + expect(privateHost.seatTokens.has(1)).toBe(false); + expect(privateHost.seatNames.has(1)).toBe(false); + expect(privateHost.paused).toBe(false); + + privateHost.disconnectedSeats.set(2, { disconnectedAt: Date.now(), timer: null }); + privateHost.paused = true; + await privateHost.kickPlayerDurably(2, "Kicked"); + expect(privateHost.paused).toBe(false); + await host.dispose(); + }); + + it("persists a deck receipt before ack and treats its exact retry as idempotent", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + guestSessions: Map }>; + handleDeckSubmission: (seat: number, cards: string[], submissionId: string) => Promise; + }; + const view = { + status: "Deckbuilding", + seats: [{ has_submitted_deck: false, is_bot: false }], + }; + privateHost.draftStarted = true; + privateHost.adapter.submitDeckForSeat = vi.fn(async () => view); + privateHost.adapter.getViewForSeat = vi.fn(async () => view); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Deckbuilding\"}"); + const session = { send: vi.fn(async () => {}) }; + privateHost.guestSessions.set(1, session); + + await privateHost.handleDeckSubmission(1, ["Island"], "submission-1"); + expect(saveDraftHostSession).toHaveBeenCalledWith("shared-recovery", expect.objectContaining({ + deckSubmissionReceipts: [{ seat: 1, submissionId: "submission-1", payloadFingerprint: expect.any(String) }], + })); + expect(saveDraftHostSession).toHaveBeenCalledBefore(session.send); + expect(session.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_deck_submit_ack", + submissionId: "submission-1", + })); + + await privateHost.handleDeckSubmission(1, ["Island"], "submission-1"); + expect(privateHost.adapter.submitDeckForSeat).toHaveBeenCalledOnce(); + }); + + it("reuses the host deck receipt after a failed immutable snapshot", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + }; + const view = { + status: "Deckbuilding", + seats: [{ has_submitted_deck: false, is_bot: false }], + }; + privateHost.draftStarted = true; + privateHost.adapter.submitDeckForSeat = vi.fn(async () => view); + privateHost.adapter.getViewForSeat = vi.fn(async () => view); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Deckbuilding\"}"); + saveDraftHostSession + .mockRejectedValueOnce(new Error("IDB unavailable")) + .mockResolvedValue(undefined); + + await expect(host.submitHostDeck(["Island"])).rejects.toThrow("IDB unavailable"); + await host.submitHostDeck(["Island"]); + + // Retrying invokes the durable receipt path and flushes its captured + // snapshot; it does not invoke the deck reducer twice. + expect(privateHost.adapter.submitDeckForSeat).toHaveBeenCalledOnce(); + }); + + it("serializes concurrent host deck submits into one reducer command", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + }; + const view = { status: "Deckbuilding", seats: [{ has_submitted_deck: false, is_bot: false }] }; + privateHost.draftStarted = true; + privateHost.adapter.submitDeckForSeat = vi.fn(async () => view); + privateHost.adapter.getViewForSeat = vi.fn(async () => view); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Deckbuilding\"}"); + + await Promise.all([host.submitHostDeck(["Island"]), host.submitHostDeck(["Island"])]); + + expect(privateHost.adapter.submitDeckForSeat).toHaveBeenCalledOnce(); + expect(saveDraftHostSession).toHaveBeenCalledWith("shared-recovery", expect.objectContaining({ + deckSubmissionReceipts: [expect.objectContaining({ seat: 0 })], + })); + }); + + it("rejects a host deck before draft start without invoking the reducer", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { submitDeckForSeat: ReturnType }; + }; + privateHost.adapter.submitDeckForSeat = vi.fn(); + + await expect(host.submitHostDeck(["Island"])).rejects.toThrow("Draft not started"); + + expect(privateHost.adapter.submitDeckForSeat).not.toHaveBeenCalled(); + }); + + it("retains a guest deck command after a failed save and replays it once after host recovery", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + guestSessions: Map }>; + generatePairingsInner: ReturnType; + handleDeckSubmission: (seat: number, cards: string[], submissionId: string) => Promise; + }; + const view = { status: "Pairing", seats: [{ has_submitted_deck: true, is_bot: false }] }; + privateHost.draftStarted = true; + privateHost.adapter.submitDeckForSeat = vi.fn(async () => view); + privateHost.adapter.getViewForSeat = vi.fn(async () => view); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Deckbuilding\"}"); + privateHost.generatePairingsInner = vi.fn(async () => {}); + const session = { send: vi.fn(async () => {}) }; + privateHost.guestSessions.set(1, session); + saveDraftHostSession.mockRejectedValueOnce(new Error("IDB unavailable")); + + await expect(privateHost.handleDeckSubmission(1, ["Island"], "submission-1")) + .rejects.toThrow("IDB unavailable"); + expect(session.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_error", submissionId: "submission-1", submissionDisposition: "Retryable", + })); + + await privateHost.handleDeckSubmission(1, ["Island"], "submission-1"); + expect(privateHost.generatePairingsInner).toHaveBeenCalledOnce(); + const recoveredSnapshot = saveDraftHostSession.mock.calls[ + saveDraftHostSession.mock.calls.length - 1 + ]?.[1] as Record; + const recovered = recoveredHost("Host"); + await recovered.restoreFromPersisted({ ...recoveredSnapshot, draftSessionJson: null } as never); + const recoveredPrivate = recovered as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + guestSessions: Map }>; + generatePairingsInner: ReturnType; + handleDeckSubmission: (seat: number, cards: string[], submissionId: string) => Promise; + }; + recoveredPrivate.adapter.submitDeckForSeat = vi.fn(async () => view); + recoveredPrivate.adapter.getViewForSeat = vi.fn(async () => ({ ...view, status: "MatchInProgress" })); + recoveredPrivate.adapter.exportSession = vi.fn(async () => "{\"status\":\"Deckbuilding\"}"); + recoveredPrivate.generatePairingsInner = vi.fn(async () => {}); + recoveredPrivate.guestSessions.set(1, { send: vi.fn(async () => {}) }); + await recoveredPrivate.handleDeckSubmission(1, ["Island"], "submission-1"); + + expect(privateHost.adapter.submitDeckForSeat).toHaveBeenCalledOnce(); + expect(recoveredPrivate.adapter.submitDeckForSeat).not.toHaveBeenCalled(); + expect(recoveredPrivate.generatePairingsInner).not.toHaveBeenCalled(); + }); + + it("generates pairings once for an ordinarily durable final deck submission", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { + submitDeckForSeat: ReturnType; + getViewForSeat: ReturnType; + exportSession: ReturnType; + }; + draftStarted: boolean; + generatePairingsInner: ReturnType; + }; + const view = { status: "Pairing", seats: [{ has_submitted_deck: true, is_bot: false }] }; + privateHost.draftStarted = true; + privateHost.adapter.submitDeckForSeat = vi.fn(async () => view); + privateHost.adapter.getViewForSeat = vi.fn(async () => view); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Pairing\"}"); + privateHost.generatePairingsInner = vi.fn(async () => {}); + + await host.submitHostDeck(["Island"]); + + expect(privateHost.adapter.submitDeckForSeat).toHaveBeenCalledOnce(); + expect(privateHost.generatePairingsInner).toHaveBeenCalledOnce(); + }); + + it("keeps round advancement invisible until its first durable snapshot commits", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { advanceRound: ReturnType; exportSession: ReturnType }; + draftStarted: boolean; + generatePairingsInner: ReturnType; + }; + privateHost.draftStarted = true; + privateHost.adapter.advanceRound = vi.fn(async () => {}); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Pairing\"}"); + privateHost.generatePairingsInner = vi.fn(async () => {}); + const events = vi.fn(); + host.onEvent(events); + let releaseSave!: () => void; + saveDraftHostSession.mockImplementationOnce(() => new Promise((resolve) => { + releaseSave = resolve; + })); + + const advance = host.advanceRound(); + await vi.waitFor(() => expect(saveDraftHostSession).toHaveBeenCalledOnce()); + expect(events).not.toHaveBeenCalledWith({ type: "roundAdvanced" }); + expect(privateHost.generatePairingsInner).not.toHaveBeenCalled(); + + releaseSave(); + await advance; + expect(events).toHaveBeenCalledWith({ type: "roundAdvanced" }); + expect(privateHost.generatePairingsInner).toHaveBeenCalledOnce(); + }); + + it("persists recovered grace expiry once and never rearms an expired seat", async () => { + vi.useFakeTimers(); + try { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + adapter: { setSeatConnected: ReturnType; exportSession: ReturnType }; + draftStarted: boolean; + seatTokens: Map; + armRecoveredGuestGrace: () => void; + expiredDisconnectedSeats: Set; + disconnectedSeats: Map; + }; + privateHost.draftStarted = true; + privateHost.seatTokens.set(1, "guest-token"); + privateHost.adapter.setSeatConnected = vi.fn(async () => {}); + privateHost.adapter.exportSession = vi.fn(async () => "{\"status\":\"Drafting\"}"); + privateHost.armRecoveredGuestGrace(); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(privateHost.expiredDisconnectedSeats.has(1)).toBe(true); + const expiredSnapshot = saveDraftHostSession.mock.calls[ + saveDraftHostSession.mock.calls.length - 1 + ]?.[1] as Record; + expect(expiredSnapshot).toMatchObject({ expiredDisconnectedSeats: [1] }); + + const recovered = recoveredHost("Host"); + await recovered.restoreFromPersisted({ + ...expiredSnapshot, + draftSessionJson: null, + } as never); + const recoveredPrivate = recovered as unknown as { + expiredDisconnectedSeats: Set; + disconnectedSeats: Map; + handleReconnect: (session: unknown, token: string) => Promise; + }; + expect(recoveredPrivate.expiredDisconnectedSeats.has(1)).toBe(true); + expect(recoveredPrivate.disconnectedSeats.has(1)).toBe(false); + const reconnectSession = { + send: vi.fn(async () => {}), + close: vi.fn(), + }; + await recoveredPrivate.handleReconnect(reconnectSession, "guest-token"); + expect(reconnectSession.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_reconnect_rejected", + kind: "NoReconnectWindow", + })); + const savesBeforeAdvance = saveDraftHostSession.mock.calls.length; + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(saveDraftHostSession).toHaveBeenCalledTimes(savesBeforeAdvance); + } finally { + vi.useRealTimers(); + } + }); + + it("commits a joining guest's token before welcome and preserves it for recovery", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + let initialSnapshot: Record | undefined; + let snapshot: Record | undefined; + let finishSave!: () => void; + saveDraftHostSession + .mockImplementationOnce((_id, value) => { + initialSnapshot = value as Record; + return Promise.resolve(); + }) + .mockImplementationOnce((_id, value) => { + snapshot = value as Record; + return new Promise((resolve) => { finishSave = resolve; }); + }); + // `initialize()` queues this lobby save before accepting connections. + // It must remain a pre-admission snapshot even if the join reaches the + // queue before its earlier write gets CPU time. + privateHost.persistSession(); + const session = { + onMessage: vi.fn(), + onDisconnect: vi.fn(() => vi.fn()), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + + const admission = privateHost.handleNewGuest(session, "Alice"); + await vi.waitFor(() => expect(saveDraftHostSession).toHaveBeenCalledTimes(2)); + + expect(initialSnapshot).toMatchObject({ + seatTokens: {}, + seatNames: { 0: "Host" }, + }); + + // A reload while the strict admission write is pending sees neither an + // acknowledged guest nor a live guest transport. The capability cannot + // escape this fence. + expect(session.send).not.toHaveBeenCalled(); + expect(privateHost.guestSessions.size).toBe(0); + + finishSave(); + await admission; + + const token = privateHost.seatTokens.get(1); + expect(token).toBeDefined(); + expect(snapshot).toMatchObject({ + seatTokens: { 1: token }, + seatNames: { 1: "Alice" }, + }); + expect(session.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_welcome", + draftToken: token, + })); + + const recovered = recoveredHost("Host"); + await recovered.restoreFromPersisted(snapshot as never); + const recoveredPrivate = recovered as unknown as AdmissionHost; + recoveredPrivate.procedure = { packs_per_player: 3, min_deck_size: 40 }; + const reconnectSession = { + onMessage: vi.fn(), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + await recoveredPrivate.handleReconnect(reconnectSession, token!); + await Promise.resolve(); + expect(reconnectSession.send).toHaveBeenCalledWith(expect.objectContaining({ + type: "draft_reconnect_ack", + seatIndex: 1, + })); + }); + + it("does not attach or acknowledge a guest when admission persistence fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + saveDraftHostSession.mockRejectedValueOnce(new Error("IDB unavailable")); + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + const session = { + onMessage: vi.fn(), + onDisconnect: vi.fn(() => vi.fn()), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + + await privateHost.handleNewGuest(session, "Alice"); + + expect(privateHost.seatTokens.has(1)).toBe(false); + expect(privateHost.guestSessions.size).toBe(0); + expect(session.onMessage).not.toHaveBeenCalled(); + expect(session.send).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledWith("Guest admission persistence failed"); + }); + + it("rejects a pre-start deck submission with its command identity", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as { + guestSessions: Map }>; + handleGuestMessage: (seat: number, message: unknown) => Promise; + }; + const session = { send: vi.fn(async () => {}) }; + privateHost.guestSessions.set(1, session); + + await privateHost.handleGuestMessage(1, { + type: "draft_submit_deck", submissionId: "submission-1", mainDeck: ["Island"], + }); + + expect(session.send).toHaveBeenCalledWith({ + type: "draft_error", + reason: "Draft not started", + submissionId: "submission-1", + submissionDisposition: "Rejected", + }); + }); + + it("rolls back a first-contact disconnect that happens during durable admission", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + let finishAdmissionWrite!: () => void; + let rollbackSnapshot: Record | undefined; + saveDraftHostSession + .mockImplementationOnce(() => new Promise((resolve) => { finishAdmissionWrite = resolve; })) + .mockImplementationOnce((_id, snapshot) => { + rollbackSnapshot = snapshot as Record; + return Promise.resolve(); + }); + let disconnect!: () => void; + const session = { + onMessage: vi.fn(), + onDisconnect: vi.fn((handler: () => void) => { + disconnect = handler; + return vi.fn(); + }), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + + const admission = privateHost.handleNewGuest(session, "Alice"); + await vi.waitFor(() => expect(saveDraftHostSession).toHaveBeenCalledOnce()); + disconnect(); + finishAdmissionWrite(); + await admission; + + expect(privateHost.seatTokens.has(1)).toBe(false); + expect(privateHost.guestSessions.size).toBe(0); + expect(session.onMessage).not.toHaveBeenCalled(); + expect(session.send).not.toHaveBeenCalled(); + expect(rollbackSnapshot).toMatchObject({ seatTokens: {}, seatNames: { 0: "Host" } }); + }); + + it("does not let a queued admission persist a failed predecessor's token", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + let secondSnapshot: Record | undefined; + saveDraftHostSession + .mockRejectedValueOnce(new Error("first write failed")) + .mockImplementationOnce((_id, snapshot) => { + secondSnapshot = snapshot as Record; + return Promise.resolve(); + }); + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + const firstSession = { + onMessage: vi.fn(), + onDisconnect: vi.fn(() => vi.fn()), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + const secondSession = { + onMessage: vi.fn(), + onDisconnect: vi.fn(() => vi.fn()), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + + await Promise.all([ + privateHost.handleNewGuest(firstSession, "Alice"), + privateHost.handleNewGuest(secondSession, "Bea"), + ]); + + expect(firstSession.send).not.toHaveBeenCalled(); + expect(secondSession.send).toHaveBeenCalledWith(expect.objectContaining({ type: "draft_welcome" })); + expect(secondSnapshot).toMatchObject({ + seatNames: { 0: "Host", 1: "Bea" }, + }); + expect(Object.keys(secondSnapshot!.seatTokens as Record)).toEqual(["1"]); + }); + + it("does not admit a queued guest that disconnects before its transaction begins", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + const events = vi.fn(); + host.onEvent(events); + let finishFirstAdmission!: () => void; + let snapshot: Record | undefined; + saveDraftHostSession.mockImplementationOnce((_id, value) => { + snapshot = value as Record; + return new Promise((resolve) => { finishFirstAdmission = resolve; }); + }); + const firstSession = { + onMessage: vi.fn(), + onDisconnect: vi.fn(() => vi.fn()), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + let disconnectSecond!: () => void; + const secondSession = { + onMessage: vi.fn(), + onDisconnect: vi.fn((handler: () => void) => { + disconnectSecond = handler; + return vi.fn(); + }), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + + const first = privateHost.handleNewGuest(firstSession, "Alice"); + const second = privateHost.handleNewGuest(secondSession, "Bea"); + await vi.waitFor(() => expect(saveDraftHostSession).toHaveBeenCalledOnce()); + disconnectSecond(); + finishFirstAdmission(); + await Promise.all([first, second]); + + expect(snapshot).toMatchObject({ + seatNames: { 0: "Host", 1: "Alice" }, + }); + expect(privateHost.seatTokens.size).toBe(1); + expect(secondSession.onMessage).not.toHaveBeenCalled(); + expect(secondSession.send).not.toHaveBeenCalled(); + expect(events).toHaveBeenCalledTimes(2); + expect(events).toHaveBeenNthCalledWith(1, { type: "seatJoined", seatIndex: 1, displayName: "Alice" }); + expect(events).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "lobbyUpdate" })); + }); + + it("rolls back a close injected during guest-session registration", async () => { + const host = recoveredHost("Host"); + const privateHost = host as unknown as AdmissionHost; + privateHost.procedure = { packs_per_player: 3, min_deck_size: 40 }; + let rollbackSnapshot: Record | undefined; + saveDraftHostSession + .mockResolvedValueOnce() + .mockImplementationOnce((_id, snapshot) => { + rollbackSnapshot = snapshot as Record; + return Promise.resolve(); + }); + let disconnect!: () => void; + const session = { + onMessage: vi.fn(), + onDisconnect: vi.fn((handler: () => void) => { + disconnect = handler; + return vi.fn(); + }), + send: vi.fn(async () => {}), + close: vi.fn(), + }; + const originalSet = privateHost.guestSessions.set.bind(privateHost.guestSessions); + vi.spyOn(privateHost.guestSessions, "set").mockImplementation((seat, guestSession) => { + const result = originalSet(seat, guestSession); + disconnect(); + return result; + }); + + await privateHost.handleNewGuest(session, "Alice"); + + expect(privateHost.seatTokens.size).toBe(0); + expect(privateHost.guestSessions.size).toBe(0); + expect(session.onMessage).not.toHaveBeenCalled(); + expect(session.send).not.toHaveBeenCalled(); + expect(rollbackSnapshot).toMatchObject({ seatTokens: {}, seatNames: { 0: "Host" } }); + }); +}); diff --git a/client/src/adapter/draftPodGuestAdapter.ts b/client/src/adapter/draftPodGuestAdapter.ts index dea5868e99..b1b71792be 100644 --- a/client/src/adapter/draftPodGuestAdapter.ts +++ b/client/src/adapter/draftPodGuestAdapter.ts @@ -11,11 +11,15 @@ */ import type { DraftPlayerView, SeatPublicView } from "./draft-adapter"; -import { P2PDraftGuest, type DraftGuestEvent } from "./p2p-draft-guest"; +import { + P2PDraftGuest, + type DraftGuestConnection, + type DraftGuestEvent, + type DraftGuestRecoveryFailure, +} from "./p2p-draft-guest"; import type { DraftMatchLaunch, DraftMatchSettlement, DraftPauseReason } from "../network/draftProtocol"; import type { DraftIntergameCommand, DraftIntergameCommandAck } from "../services/intergameCommandLedger"; import { joinRoom, type JoinResult } from "../network/connection"; -import { loadDraftGuestSession } from "../services/draftPersistence"; // ── Types ────────────────────────────────────────────────────────────── @@ -37,6 +41,7 @@ export type DraftPodGuestEvent = | { type: "reconnected"; seatIndex: number } | { type: "viewUpdated"; view: DraftPlayerView } | { type: "pickAcknowledged"; view: DraftPlayerView } + | { type: "deckSubmissionAcknowledged"; submissionId: string; view: DraftPlayerView } | { type: "lobbyUpdate"; seats: SeatPublicView[]; joined: number; total: number } | { type: "draftPaused"; reason: DraftPauseReason } | { type: "draftResumed" } @@ -61,11 +66,25 @@ export type DraftPodGuestEvent = | { type: "hostLeft"; reason: string } | { type: "error"; message: string } | { type: "reconnecting"; attempt: number } - | { type: "reconnectFailed"; reason: string }; + | { type: "reconnectFailed"; failure: DraftGuestRecoveryFailure }; type DraftPodGuestEventListener = (event: DraftPodGuestEvent) => void; -export interface DraftPodGuestConfig { +const INITIAL_RECONNECT_JOIN_ATTEMPTS = 3; +const INITIAL_RECONNECT_JOIN_BACKOFF_MS = [500, 1_000] as const; + +class HostIdentityMismatchError extends Error { + constructor() { + super("Draft pod host changed; reconnect credentials were not sent"); + this.name = "HostIdentityMismatchError"; + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +interface DraftPodGuestConnectionBase { roomCode: string; displayName: string; /** Abort signal for cancellation during connection. */ @@ -74,6 +93,11 @@ export interface DraftPodGuestConfig { timeoutMs?: number; } +/** New joins and credentialed reconnects are intentionally disjoint. */ +export type DraftPodGuestConfig = + | ({ kind: "new" } & DraftPodGuestConnectionBase) + | ({ kind: "reconnect"; hostPeerId: string; draftToken: string } & DraftPodGuestConnectionBase); + // ── DraftPodGuestAdapter ─────────────────────────────────────────────── export class DraftPodGuestAdapter { @@ -85,6 +109,7 @@ export class DraftPodGuestAdapter { private _seatIndex: number | null = null; private _draftCode: string | null = null; private _currentView: DraftPlayerView | null = null; + private recoveryFailure: DraftGuestRecoveryFailure | null = null; onEvent(listener: DraftPodGuestEventListener): () => void { this.listeners.push(listener); @@ -122,55 +147,102 @@ export class DraftPodGuestAdapter { // ── Initialization ───────────────────────────────────────────────── - /** - * Connect to a draft pod host via PeerJS room code. Checks for an - * existing draft token in IndexedDB for reconnection. - */ + /** Connect to a draft pod host and wait for its first authoritative ack. */ async initialize(config: DraftPodGuestConfig): Promise { this.setStatus("connecting"); try { // 1. Join the PeerJS room - const joinResult = await joinRoom( - config.roomCode, - config.signal, - config.timeoutMs, - ); + const { joinResult, reconnectAttemptLimit } = await this.openRoom(config); this.joinResult = joinResult; - // 2. Check for existing draft token (reconnect case) - const persisted = await loadDraftGuestSession(joinResult.conn.peer); - const existingToken = persisted?.draftToken; + // The code route and the IndexedDB capability are both bound to this + // exact host. A different PeerJS target must never receive the token. + if (config.kind === "reconnect" && joinResult.conn.peer !== config.hostPeerId) { + joinResult.destroyPeer(); + this.joinResult = null; + throw new HostIdentityMismatchError(); + } + + const connection: DraftGuestConnection = config.kind === "new" + ? { kind: "new", roomCode: config.roomCode, displayName: config.displayName } + : { + kind: "reconnect", + roomCode: config.roomCode, + displayName: config.displayName, + draftToken: config.draftToken, + }; - // 3. Create P2PDraftGuest + // 2. Create P2PDraftGuest const guest = new P2PDraftGuest( joinResult.peer, joinResult.conn.peer, joinResult.conn, - config.displayName, - existingToken, + connection, ); - // 4. Wire guest events + // 3. Wire guest events this.guestEventUnsub = guest.onEvent((event) => { this.handleGuestEvent(event); }); - // 5. Initialize (sends join or reconnect message) - await guest.initialize(); + // 4. Initialize only resolves after welcome/reconnect acknowledgement. + // Retain ownership before awaiting so supersession can abort an + // acknowledgement wait without revoking the persisted capability. this.guest = guest; + if (reconnectAttemptLimit === undefined) { + await guest.initialize(config.signal); + } else { + await guest.initialize(config.signal, reconnectAttemptLimit); + } if (this._status === "connecting") { this.setStatus("lobby"); } } catch (err) { + if (isAbortError(err)) throw err; this.setStatus("error"); const message = err instanceof Error ? err.message : String(err); + if (config.kind === "reconnect" && !this.recoveryFailure) { + const failure: DraftGuestRecoveryFailure = err instanceof HostIdentityMismatchError + ? { kind: "invalid", message } + : { kind: "retryable", message }; + this.recoveryFailure = failure; + this.emit({ type: "reconnectFailed", failure }); + } this.emit({ type: "error", message }); throw err; } } + /** A fresh seat never retries; a persisted reconnect capability gets a small, abortable join budget. */ + private async openRoom( + config: DraftPodGuestConfig, + ): Promise<{ joinResult: JoinResult; reconnectAttemptLimit?: number }> { + if (config.kind === "new") { + return { joinResult: await joinRoom(config.roomCode, config.signal, config.timeoutMs) }; + } + + let lastError: unknown; + for (let attempt = 0; attempt < INITIAL_RECONNECT_JOIN_ATTEMPTS; attempt++) { + if (config.signal?.aborted) throw abortError(); + try { + return { + joinResult: await joinRoom(config.roomCode, config.signal, config.timeoutMs), + // The initial room join consumes the same bounded recovery budget as + // a later acknowledgement retry; the first handshake always gets one + // attempt after a successful room connection. + reconnectAttemptLimit: Math.max(1, INITIAL_RECONNECT_JOIN_ATTEMPTS - attempt), + }; + } catch (error) { + lastError = error; + if (config.signal?.aborted || attempt === INITIAL_RECONNECT_JOIN_ATTEMPTS - 1) break; + await waitForReconnectJoinBackoff(INITIAL_RECONNECT_JOIN_BACKOFF_MS[attempt]!, config.signal); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + // ── Guest event mapping ──────────────────────────────────────────── private handleGuestEvent(event: DraftGuestEvent): void { @@ -198,6 +270,14 @@ export class DraftPodGuestAdapter { this._currentView = event.view; this.emit({ type: "pickAcknowledged", view: event.view }); break; + case "deckSubmissionAcknowledged": + this._currentView = event.view; + this.emit({ + type: "deckSubmissionAcknowledged", + submissionId: event.submissionId, + view: event.view, + }); + break; case "lobbyUpdate": this.emit({ type: "lobbyUpdate", @@ -263,7 +343,8 @@ export class DraftPodGuestAdapter { break; case "reconnectFailed": this.setStatus("error"); - this.emit({ type: "reconnectFailed", reason: event.reason }); + this.recoveryFailure = event.failure; + this.emit({ type: "reconnectFailed", failure: event.failure }); break; case "bo3SideboardPrompt": this.emit({ @@ -375,13 +456,21 @@ export class DraftPodGuestAdapter { // ── Cleanup ──────────────────────────────────────────────────────── - async dispose(): Promise { + /** + * Transport/lifecycle disposal preserves credentials by default. Only an + * explicit participant leave is allowed to revoke durable guest recovery. + */ + async dispose({ preserveRecovery = true }: { preserveRecovery?: boolean } = {}): Promise { if (this.guestEventUnsub) { this.guestEventUnsub(); this.guestEventUnsub = null; } if (this.guest) { - await this.guest.leave(); + if (preserveRecovery) { + this.guest.dispose(); + } else { + await this.guest.leave(); + } this.guest = null; } if (this.joinResult) { @@ -395,3 +484,22 @@ export class DraftPodGuestAdapter { this.setStatus("idle"); } } + +function waitForReconnectJoinBackoff(delayMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(abortError()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + reject(abortError()); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function abortError(): Error { + return new DOMException("Draft reconnect aborted", "AbortError"); +} diff --git a/client/src/adapter/draftPodHostAdapter.ts b/client/src/adapter/draftPodHostAdapter.ts index e5ef27eaa8..3cfca7e97d 100644 --- a/client/src/adapter/draftPodHostAdapter.ts +++ b/client/src/adapter/draftPodHostAdapter.ts @@ -131,8 +131,13 @@ export class DraftPodHostAdapter { private host: P2PDraftHost | null = null; private hostResult: HostResult | null = null; private hostEventUnsub: (() => void) | null = null; + /** Closes an in-flight local host before a replacement may be created. */ + private pendingDispose: (() => Promise) | null = null; + /** Settles only after a canceled initializer has released every local resource. */ + private pendingInitialization: Promise | null = null; private _status: DraftPodHostStatus = "idle"; private _roomCode: string | null = null; + private disposed = false; onEvent(listener: DraftPodHostEventListener): () => void { this.listeners.push(listener); @@ -167,13 +172,54 @@ export class DraftPodHostAdapter { * accepting guest connections. */ async initialize(config: DraftPodHostConfig): Promise { + this.disposed = false; this.setStatus("connecting"); + let finishInitialization!: () => void; + const initializationSettled = new Promise((resolve) => { + finishInitialization = resolve; + }); + this.pendingInitialization = initializationSettled; + let pendingHost: P2PDraftHost | null = null; + let pendingHostDisposed = false; + let pendingHostResult: HostResult | null = null; + let pendingHostResultDestroyed = false; + + const abortIfRequested = () => { + if (config.signal?.aborted || this.disposed) { + throw new Error("Draft pod host initialization aborted"); + } + }; + const disposePending = async () => { + if (this.hostEventUnsub) { + this.hostEventUnsub(); + this.hostEventUnsub = null; + } + if (pendingHost && !pendingHostDisposed) { + pendingHostDisposed = true; + await pendingHost.dispose(); + } + if (pendingHostResult && !pendingHostResultDestroyed) { + pendingHostResultDestroyed = true; + pendingHostResult.destroy(); + } + if (pendingHostResult) { + if (this.hostResult === pendingHostResult) this.hostResult = null; + pendingHostResult = null; + } + this._roomCode = null; + }; + this.pendingDispose = disposePending; try { // 1. Create PeerJS host peer const hostResult = await hostRoom(config.signal, { preferredRoomCode: config.preferredRoomCode, }); + pendingHostResult = hostResult; + // `hostRoom` is only cancellation-aware while it is pending. Once it + // resolves, every following async boundary must re-check before making + // this peer discoverable or starting its local draft host. + abortIfRequested(); this.hostResult = hostResult; this._roomCode = hostResult.roomCode; this.emit({ type: "roomCreated", roomCode: hostResult.roomCode }); @@ -192,9 +238,11 @@ export class DraftPodHostAdapter { hostPeerId: hostResult.peerId, }); } catch (err) { + if (config.signal?.aborted || this.disposed) throw err; console.warn("[DraftPodHostAdapter] broker registration failed:", err); // Non-fatal: direct room code still works } + abortIfRequested(); } // 3. For cube drafts, the WASM CARD_DB must be populated before @@ -203,13 +251,18 @@ export class DraftPodHostAdapter { // and never touches CARD_DB. if (config.poolInput.type === "Cube") { const resp = await fetch(__CARD_DATA_URL__); + abortIfRequested(); if (!resp.ok) { throw new Error(`Failed to load card data: ${resp.status}`); } - await new DraftAdapter().loadCardDatabase(await resp.text()); + const cardData = await resp.text(); + abortIfRequested(); + await new DraftAdapter().loadCardDatabase(cardData); + abortIfRequested(); } // 4. Create P2PDraftHost + abortIfRequested(); const host = new P2PDraftHost( hostResult.peer, hostResult.onGuestConnected, @@ -223,6 +276,7 @@ export class DraftPodHostAdapter { config.persistenceId, hostResult.roomCode, ); + pendingHost = host; // 4. Wire host events this.hostEventUnsub = host.onEvent((event) => { @@ -232,8 +286,10 @@ export class DraftPodHostAdapter { // 5. Check for persisted session to restore if (config.persistenceId) { const persisted = await loadDraftHostSession(config.persistenceId); + abortIfRequested(); if (persisted) { const view = await host.restoreFromPersisted(persisted); + abortIfRequested(); if (view) { this.setStatus(hostStatusForView(view)); this.emit({ type: "viewUpdated", view }); @@ -243,16 +299,31 @@ export class DraftPodHostAdapter { // 6. Start accepting connections await host.initialize(); + abortIfRequested(); this.host = host; + pendingHost = null; + pendingHostResult = null; + if (this.pendingDispose === disposePending) this.pendingDispose = null; if (this._status === "connecting") { this.setStatus("lobby"); } } catch (err) { + await disposePending(); + if (config.signal?.aborted || this.disposed) { + this.setStatus("idle"); + throw err; + } this.setStatus("error"); const message = err instanceof Error ? err.message : String(err); this.emit({ type: "error", message }); throw err; + } finally { + finishInitialization(); + if (this.pendingDispose === disposePending) this.pendingDispose = null; + if (this.pendingInitialization === initializationSettled) { + this.pendingInitialization = null; + } } } @@ -496,13 +567,18 @@ export class DraftPodHostAdapter { // ── Cleanup ──────────────────────────────────────────────────────── async dispose(options: { preserveSession?: boolean } = {}): Promise { + this.disposed = true; + const pendingDispose = this.pendingDispose; + const pendingInitialization = this.pendingInitialization; + if (pendingDispose) await pendingDispose(); + if (pendingInitialization) await pendingInitialization; if (this.hostEventUnsub) { this.hostEventUnsub(); this.hostEventUnsub = null; } if (this.host) { if (options.preserveSession) { - this.host.dispose(); + await this.host.dispose(); } else { await this.host.terminateDraft(); } diff --git a/client/src/adapter/p2p-draft-guest.ts b/client/src/adapter/p2p-draft-guest.ts index a8bb4476d7..0d09809150 100644 --- a/client/src/adapter/p2p-draft-guest.ts +++ b/client/src/adapter/p2p-draft-guest.ts @@ -17,7 +17,12 @@ import { createDraftPeerSession, type DraftPeerSession, } from "../network/draftPeerSession"; -import { DRAFT_PROTOCOL_VERSION } from "../network/draftProtocol"; +import { parseRoomCode } from "../network/connection"; +import { + deckSubmissionFingerprint, + DRAFT_PROTOCOL_VERSION, + type DraftReconnectRejectionKind, +} from "../network/draftProtocol"; import type { DraftMatchLaunch, DraftMatchSettlement, @@ -26,7 +31,11 @@ import type { } from "../network/draftProtocol"; import { saveDraftGuestSession, - clearDraftGuestSession, + clearDraftGuestRecovery, + saveActiveDraftGuest, + clearDraftDeckSubmission, + loadDraftDeckSubmission, + saveDraftDeckSubmission, } from "../services/draftPersistence"; import type { DraftIntergameCommand, @@ -40,6 +49,7 @@ export type DraftGuestEvent = | { type: "reconnected"; seatIndex: number } | { type: "viewUpdated"; view: DraftPlayerView } | { type: "pickAcknowledged"; view: DraftPlayerView } + | { type: "deckSubmissionAcknowledged"; submissionId: string; view: DraftPlayerView } | { type: "lobbyUpdate"; seats: SeatPublicView[]; joined: number; total: number } | { type: "draftPaused"; reason: DraftPauseReason } | { type: "draftResumed" } @@ -57,12 +67,59 @@ export type DraftGuestEvent = | { type: "hostLeft"; reason: string } | { type: "error"; message: string } | { type: "reconnecting"; attempt: number } - | { type: "reconnectFailed"; reason: string }; + | { type: "reconnectFailed"; failure: DraftGuestRecoveryFailure }; + +/** + * The recovery layer, not a rendered string, owns whether another explicit + * reconnect attempt is meaningful. This keeps terminal capability revocation + * and an offline host from sharing a misleading "Retry" affordance. + */ +export type DraftGuestRecoveryFailure = + | { kind: "retryable"; message: string } + | { kind: "incompatible"; message: string } + | { kind: "invalid"; message: string }; + +/** First contact is intentionally an exclusive choice, never token fallback. */ +export type DraftGuestConnection = + | { + kind: "new"; + roomCode: string; + displayName: string; + } + | { + kind: "reconnect"; + roomCode: string; + displayName: string; + draftToken: string; + }; type DraftGuestEventListener = (event: DraftGuestEvent) => void; const RECONNECT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 60_000]; const RECONNECT_STEADY_STATE_MS = 60_000; +const FIRST_CONTACT_TIMEOUT_MS = 10_000; + +function reconnectFailureForRejection( + kind: DraftReconnectRejectionKind, + message: string, +): DraftGuestRecoveryFailure { + switch (kind) { + case "Kicked": + case "UnknownToken": + return { kind: "invalid", message }; + case "ProtocolMismatch": + return { kind: "incompatible", message }; + case "NoReconnectWindow": + return { kind: "retryable", message }; + } +} + +interface DraftHandshake { + session: DraftPeerSession; + resolve: () => void; + reject: (reason: Error) => void; + cleanup: () => void; +} // ── P2PDraftGuest ────────────────────────────────────────────────────── @@ -70,19 +127,28 @@ export class P2PDraftGuest { private listeners: DraftGuestEventListener[] = []; private session: DraftPeerSession | null = null; private draftToken: string | null = null; + private draftCode: string | null = null; private seatIndex: number | null = null; private terminated = false; private currentView: DraftPlayerView | null = null; + private handshake: DraftHandshake | null = null; + private reconnecting = false; + private retryTimer: ReturnType | null = null; + private deckSubmissionWaiters = new Map< + string, + { acknowledgement: Promise; resolve: () => void; reject: (error: Error) => void } + >(); + /** Set synchronously so two UI clicks share one outbox command. */ + private pendingDeckSubmission: Promise | null = null; constructor( private readonly guestPeer: Peer, private readonly hostPeerId: string, private readonly initialConn: DataConnection, - private readonly displayName: string, - existingDraftToken?: string, + private readonly connection: DraftGuestConnection, ) { - if (existingDraftToken) { - this.draftToken = existingDraftToken; + if (connection.kind === "reconnect") { + this.draftToken = connection.draftToken; } } @@ -103,24 +169,116 @@ export class P2PDraftGuest { // ── Initialization ───────────────────────────────────────────────── - async initialize(): Promise { - this.attachSession(this.initialConn); + async initialize(signal?: AbortSignal, reconnectAttemptLimit = Number.POSITIVE_INFINITY): Promise { + // A new guest gets exactly one join attempt. Only a persisted capability + // may use retry, and every retry waits for a complete host acknowledgement. + if (this.connection.kind === "new") { + await this.handshakeOn(this.initialConn, signal, false); + return; + } - if (this.draftToken) { - this.session!.send({ type: "draft_reconnect", draftToken: this.draftToken }); - } else { - this.session!.send({ type: "draft_join", displayName: this.displayName }); + let conn = this.initialConn; + for (let attempt = 0; !this.terminated && attempt < reconnectAttemptLimit; attempt++) { + try { + await this.handshakeOn(conn, signal, true); + return; + } catch (err) { + if (this.terminated || signal?.aborted) throw asError(err); + if (attempt + 1 >= reconnectAttemptLimit) throw asError(err); + this.emit({ type: "reconnecting", attempt: attempt + 1 }); + await this.waitForRetry(attempt, signal); + if (this.terminated || signal?.aborted) throw abortError(); + conn = await this.openReconnectConnection(signal); + } } + throw abortError(); } - private attachSession(conn: DataConnection): void { + private attachSession(conn: DataConnection): DraftPeerSession { const session = createDraftPeerSession(conn, { onSessionEnd: () => { - this.handleHostDisconnect(); + this.handleSessionEnd(session); }, }); this.session = session; - session.onMessage((msg) => this.handleHostMessage(msg)); + session.onMessage((msg) => { + // A timed-out or superseded connection must never promote a later + // reconnect attempt with its delayed acknowledgement. + if (this.session === session) void this.handleHostMessage(msg, session); + }); + return session; + } + + private async handshakeOn(conn: DataConnection, signal?: AbortSignal, reconnect = true): Promise { + if (signal?.aborted) throw abortError(); + if (this.session) this.retireSession(this.session); + const session = this.attachSession(conn); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.rejectHandshake(session, new Error("Draft host did not acknowledge connection")); + }, FIRST_CONTACT_TIMEOUT_MS); + const onAbort = () => this.rejectHandshake(session, abortError()); + signal?.addEventListener("abort", onAbort, { once: true }); + this.handshake = { + session, + resolve, + reject, + cleanup: () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }, + }; + void this.sendFirstContact(session, reconnect) + .catch((err: unknown) => this.rejectHandshake(session, asError(err))); + }); + } + + private sendFirstContact(session: DraftPeerSession, reconnect: boolean): Promise { + if (!reconnect) { + return session.send({ + type: "draft_join", + displayName: this.connection.displayName, + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + }); + } + if (!this.draftToken) return Promise.reject(new Error("Draft reconnect token is unavailable")); + return session.send({ + type: "draft_reconnect", + draftToken: this.draftToken, + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + }); + } + + private resolveHandshake(session: DraftPeerSession): void { + const handshake = this.handshake; + if (!handshake || handshake.session !== session) return; + this.handshake = null; + handshake.cleanup(); + handshake.resolve(); + } + + private rejectHandshake(session: DraftPeerSession, reason: Error): void { + const handshake = this.handshake; + if (!handshake || handshake.session !== session) return; + this.handshake = null; + handshake.cleanup(); + this.retireSession(session); + handshake.reject(reason); + } + + private retireSession(session: DraftPeerSession): void { + if (this.session === session) this.session = null; + session.close("Draft reconnect attempt retired"); + } + + private handleSessionEnd(session: DraftPeerSession): void { + if (this.session !== session) return; + this.session = null; + if (this.handshake?.session === session) { + this.rejectHandshake(session, new Error("Draft host disconnected before acknowledging")); + } else { + this.handleHostDisconnect(); + } } // ── Actions ──────────────────────────────────────────────────────── @@ -142,9 +300,92 @@ export class P2PDraftGuest { }); } - async submitDeck(mainDeck: string[]): Promise { + submitDeck(mainDeck: string[]): Promise { + if (this.pendingDeckSubmission) return this.pendingDeckSubmission; + const submission = this.submitDeckInner(mainDeck); + this.pendingDeckSubmission = submission; + void submission.then( + () => { + if (this.pendingDeckSubmission === submission) this.pendingDeckSubmission = null; + }, + () => { + if (this.pendingDeckSubmission === submission) this.pendingDeckSubmission = null; + }, + ); + return submission; + } + + private async submitDeckInner(mainDeck: string[]): Promise { + const identity = this.deckSubmissionIdentity(); + if (!identity) throw new Error("Draft identity is unavailable"); + const existing = await loadDraftDeckSubmission(this.hostPeerId, identity); + const samePayload = existing !== null + && deckSubmissionFingerprint(existing.mainDeck) === deckSubmissionFingerprint(mainDeck); + if (existing && !samePayload) { + throw new Error("A deck submission is still awaiting host confirmation"); + } + const submissionId = existing?.submissionId ?? crypto.randomUUID(); + const payload = existing?.mainDeck ?? mainDeck; + if (!existing) { + await saveDraftDeckSubmission(this.hostPeerId, { + ...identity, + draftCode: this.draftCode!, + submissionId, + mainDeck: payload, + }); + } + await this.sendDeckSubmission(submissionId, payload); + } + + private async sendDeckSubmission(submissionId: string, mainDeck: string[]): Promise { if (!this.session) throw new Error("Not connected to draft host"); - await this.session.send({ type: "draft_submit_deck", mainDeck }); + let waiter = this.deckSubmissionWaiters.get(submissionId); + if (!waiter) { + let resolve!: () => void; + let reject!: (error: Error) => void; + const acknowledgement = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + waiter = { acknowledgement, resolve, reject }; + this.deckSubmissionWaiters.set(submissionId, waiter); + } + try { + await this.session.send({ type: "draft_submit_deck", submissionId, mainDeck }); + await waiter.acknowledgement; + } finally { + if (this.deckSubmissionWaiters.get(submissionId) === waiter) { + this.deckSubmissionWaiters.delete(submissionId); + } + } + } + + private failDeckSubmissionWaiters(reason: string): void { + for (const waiter of this.deckSubmissionWaiters.values()) { + waiter.reject(new Error(reason)); + } + this.deckSubmissionWaiters.clear(); + } + + /** A reconnect makes the participant-owned command eligible for replay. */ + private async replayDeckSubmission(): Promise { + const identity = this.deckSubmissionIdentity(); + if (!identity) return; + const pending = await loadDraftDeckSubmission(this.hostPeerId, identity); + if (!pending || !this.session) return; + // Do not await here: the reconnect handshake must finish before normal + // state consumers run, while its durable submission can wait for its ack. + void this.sendDeckSubmission(pending.submissionId, pending.mainDeck) + .catch((error: unknown) => this.emit({ + type: "error", + message: error instanceof Error ? error.message : String(error), + })); + } + + private deckSubmissionIdentity(): { roomCode: string; draftToken: string } | null { + const roomCode = parseRoomCode(this.connection.roomCode); + if (!this.draftCode || !this.draftToken || !roomCode) return null; + return { roomCode, draftToken: this.draftToken }; } sendMatchSettlement(settlement: DraftMatchSettlement): void { @@ -174,14 +415,18 @@ export class P2PDraftGuest { // ── Message handling ─────────────────────────────────────────────── - private handleHostMessage(msg: DraftP2PMessage): void { + private async handleHostMessage(msg: DraftP2PMessage, session: DraftPeerSession): Promise { // Protocol version check on first-contact messages if (msg.type === "draft_welcome" || msg.type === "draft_reconnect_ack") { if (msg.draftProtocolVersion !== DRAFT_PROTOCOL_VERSION) { const reason = `Draft protocol mismatch: host v${msg.draftProtocolVersion}, client v${DRAFT_PROTOCOL_VERSION}. Refresh both windows.`; console.error("[P2PDraftGuest]", reason); this.terminated = true; - this.emit({ type: "reconnectFailed", reason }); + this.rejectHandshake(session, new Error(reason)); + this.emit({ + type: "reconnectFailed", + failure: { kind: "incompatible", message: reason }, + }); return; } } @@ -190,40 +435,71 @@ export class P2PDraftGuest { case "draft_welcome": { this.seatIndex = msg.seatIndex; this.draftToken = msg.draftToken; + this.draftCode = msg.draftCode; this.currentView = msg.view; - // Persist token at join time (P2P-04) - void saveDraftGuestSession(this.hostPeerId, { - draftToken: msg.draftToken, - seatIndex: msg.seatIndex, - draftCode: msg.draftCode, - }); + try { + // The IDB capability must commit before its local-storage locator is + // published; otherwise a reload can observe a dead recovery route. + await this.persistRecoveryIdentity({ + draftToken: msg.draftToken, + seatIndex: msg.seatIndex, + draftCode: msg.draftCode, + }); + } catch (err) { + this.rejectHandshake(session, asError(err)); + this.emit({ type: "error", message: "Could not save draft recovery details" }); + break; + } + this.resolveHandshake(session); this.emit({ type: "joined", seatIndex: msg.seatIndex, draftCode: msg.draftCode }); this.emit({ type: "viewUpdated", view: msg.view }); + void this.replayDeckSubmission(); break; } case "draft_reconnect_ack": { this.seatIndex = msg.seatIndex; + this.draftCode = msg.draftCode; this.currentView = msg.view; if (this.draftToken) { - void saveDraftGuestSession(this.hostPeerId, { - draftToken: this.draftToken, - seatIndex: msg.seatIndex, - draftCode: msg.draftCode, - }); + try { + await this.persistRecoveryIdentity({ + draftToken: this.draftToken, + seatIndex: msg.seatIndex, + draftCode: msg.draftCode, + }); + } catch (err) { + this.rejectHandshake(session, asError(err)); + this.emit({ type: "error", message: "Could not save draft recovery details" }); + break; + } } + this.resolveHandshake(session); this.emit({ type: "reconnected", seatIndex: msg.seatIndex }); this.emit({ type: "viewUpdated", view: msg.view }); + void this.replayDeckSubmission(); break; } case "draft_reconnect_rejected": { - this.terminated = true; - this.emit({ type: "reconnectFailed", reason: msg.reason }); + this.rejectHandshake(session, new Error(msg.reason)); + if (msg.kind === "Kicked" || msg.kind === "UnknownToken") { + this.terminated = true; + void clearDraftGuestRecovery(this.hostPeerId); + void clearDraftDeckSubmission(this.hostPeerId); + } else if (msg.kind === "ProtocolMismatch") { + // Refresh can restore compatibility, so retain credentials, but a + // version mismatch cannot be repaired by transport retries. + this.terminated = true; + } + this.emit({ + type: "reconnectFailed", + failure: reconnectFailureForRejection(msg.kind, msg.reason), + }); break; } @@ -239,13 +515,34 @@ export class P2PDraftGuest { break; } + case "draft_deck_submit_ack": { + this.currentView = msg.view; + await clearDraftDeckSubmission(this.hostPeerId, msg.submissionId); + this.deckSubmissionWaiters.get(msg.submissionId)?.resolve(); + this.emit({ type: "deckSubmissionAcknowledged", submissionId: msg.submissionId, view: msg.view }); + this.emit({ type: "viewUpdated", view: msg.view }); + break; + } + case "draft_error": { + if (msg.submissionId) { + const waiter = this.deckSubmissionWaiters.get(msg.submissionId); + if (waiter) { + if (msg.submissionDisposition !== "Retryable") { + await clearDraftDeckSubmission(this.hostPeerId, msg.submissionId); + } + waiter.reject(new Error(msg.reason)); + } + } this.emit({ type: "error", message: msg.reason }); break; } case "draft_kicked": { this.terminated = true; + void clearDraftGuestRecovery(this.hostPeerId); + void clearDraftDeckSubmission(this.hostPeerId); + this.failDeckSubmissionWaiters(msg.reason); this.emit({ type: "kicked", reason: msg.reason }); break; } @@ -316,6 +613,7 @@ export class P2PDraftGuest { case "draft_host_left": { this.terminated = true; + this.failDeckSubmissionWaiters(msg.reason); this.emit({ type: "hostLeft", reason: msg.reason }); break; } @@ -380,34 +678,23 @@ export class P2PDraftGuest { // ── Disconnect / Reconnect ───────────────────────────────────────── private handleHostDisconnect(): void { - this.session = null; - if (this.terminated) return; + if (this.terminated || this.reconnecting || !this.draftToken) return; + this.reconnecting = true; void this.attemptReconnect(0); } private async attemptReconnect(attemptIndex: number): Promise { - if (this.terminated) return; - - const delay = attemptIndex < RECONNECT_BACKOFF_MS.length - ? RECONNECT_BACKOFF_MS[attemptIndex] - : RECONNECT_STEADY_STATE_MS; + if (this.terminated || !this.draftToken) return; this.emit({ type: "reconnecting", attempt: attemptIndex + 1 }); - await new Promise((r) => setTimeout(r, delay)); + await this.waitForRetry(attemptIndex); - if (this.terminated) return; + if (this.terminated || !this.draftToken) return; try { - const conn = this.guestPeer.connect(this.hostPeerId); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("connect timed out")), 10_000); - conn.on("open", () => { clearTimeout(timeout); resolve(); }); - conn.on("error", (err) => { clearTimeout(timeout); reject(err); }); - }); - this.attachSession(conn); - if (this.draftToken) { - this.session!.send({ type: "draft_reconnect", draftToken: this.draftToken }); - } + const conn = await this.openReconnectConnection(); + await this.handshakeOn(conn, undefined, true); + this.reconnecting = false; } catch (err) { console.warn(`[P2PDraftGuest] reconnect attempt ${attemptIndex + 1} failed:`, err); void this.attemptReconnect(attemptIndex + 1); @@ -418,8 +705,12 @@ export class P2PDraftGuest { dispose(): void { this.terminated = true; + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.failDeckSubmissionWaiters("Draft connection disposed"); + if (this.handshake) this.rejectHandshake(this.handshake.session, abortError()); if (this.session) { - this.session.close(); + this.retireSession(this.session); this.session = null; } this.currentView = null; @@ -428,9 +719,8 @@ export class P2PDraftGuest { async leave(): Promise { this.terminated = true; - if (this.draftToken) { - void clearDraftGuestSession(this.hostPeerId); - } + await clearDraftGuestRecovery(this.hostPeerId); + await clearDraftDeckSubmission(this.hostPeerId); this.dispose(); try { this.guestPeer.destroy(); @@ -450,4 +740,65 @@ export class P2PDraftGuest { get token(): string | null { return this.draftToken; } + + private async persistRecoveryIdentity(data: { draftToken: string; seatIndex: number; draftCode: string }): Promise { + await saveDraftGuestSession(this.hostPeerId, { + ...data, + roomCode: this.connection.roomCode, + displayName: this.connection.displayName, + }); + saveActiveDraftGuest({ + roomCode: this.connection.roomCode, + displayName: this.connection.displayName, + hostPeerId: this.hostPeerId, + }); + } + + private waitForRetry(attemptIndex: number, signal?: AbortSignal): Promise { + const delay = attemptIndex < RECONNECT_BACKOFF_MS.length + ? RECONNECT_BACKOFF_MS[attemptIndex] + : RECONNECT_STEADY_STATE_MS; + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null; + const onAbort = () => { + if (timer) clearTimeout(timer); + this.retryTimer = null; + reject(abortError()); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => { + this.retryTimer = null; + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delay); + this.retryTimer = timer; + }); + } + + private openReconnectConnection(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(abortError()); + const conn = this.guestPeer.connect(this.hostPeerId); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => finish(() => reject(new Error("connect timed out"))), FIRST_CONTACT_TIMEOUT_MS); + const onAbort = () => finish(() => reject(abortError())); + const onOpen = () => finish(() => resolve(conn)); + const onError = (err: Error) => finish(() => reject(err)); + const finish = (complete: () => void) => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + complete(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + conn.on("open", onOpen); + conn.on("error", onError); + }); + } +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function abortError(): Error { + return new DOMException("Draft reconnect aborted", "AbortError"); } diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index abb355ecc0..8f9d97c0af 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -19,7 +19,12 @@ import { createDraftPeerSession, type DraftPeerSession, } from "../network/draftPeerSession"; -import { DRAFT_PROTOCOL_VERSION, DraftPauseReason } from "../network/draftProtocol"; +import { parseRoomCode } from "../network/connection"; +import { + deckSubmissionFingerprint, + DRAFT_PROTOCOL_VERSION, + DraftPauseReason, +} from "../network/draftProtocol"; import type { DraftDeckPayload, DraftMatchBinding, @@ -27,6 +32,7 @@ import type { DraftMatchLaunch, DraftMatchSettlement, DraftP2PMessage, + DraftReconnectRejectionKind, } from "../network/draftProtocol"; import type { DeckCardCount, MatchConfig, MatchScore } from "./types"; import { @@ -210,6 +216,7 @@ export class P2PDraftHost { number, { disconnectedAt: number; timer: ReturnType | null } >(); + private expiredDisconnectedSeats = new Set(); private picksThisRound = new Set(); private draftStarted = false; @@ -217,11 +224,15 @@ export class P2PDraftHost { private draftSeed: number | null = null; private activePodSize: number; private hostConnectionUnsub: (() => void) | null = null; + /** Explicit host intent, independent from transient disconnected seats. */ + private manualPause = false; private paused = false; private timerInterval: ReturnType | null = null; private timerRemainingMs = 0; private timerEndAt = 0; private timerContext: "pick" | "sideboard" | "playdraw" | null = null; + private timerTargetMatchId: string | null = null; + private frozenTimer: { context: "pick" | "sideboard" | "playdraw"; remainingMs: number; matchId: string | null } | null = null; private bo3State = new Map(); /** Registered decks are captured at match launch and become the first * authority-owned default for an unchanged sideboard submission. */ @@ -238,11 +249,22 @@ export class P2PDraftHost { private settlementOutbox = new Map(); /** Immutable receipt per match makes retries idempotent. */ private settlementReceipts = new Map(); + /** Submission id → immutable payload receipt. Persisted before its acknowledgement. */ + private deckSubmissionReceipts = new Map(); + /** Prevent duplicate local visibility while a connected guest retries its receipt. */ + private publishedDeckSubmissions = new Set(); // Server backup upload state (D-08) private backupEndpoint: string | null = null; private picksSinceLastBackup = 0; private persistQueue = Promise.resolve(); + /** Failed post-reducer snapshot retried verbatim before any later state. */ + private pendingDraftSnapshot: PersistedDraftHostSession | null = null; + /** Authoritative guest actions cannot race a snapshot/export boundary. */ + private mutationQueue = Promise.resolve(); + private pendingMutations = 0; + /** Admissions mutate token state before their durability fence, so serialize them. */ + private admissionQueue = Promise.resolve(); private persistenceClosed = false; private static readonly BACKUP_INTERVAL_PICKS = 5; @@ -262,6 +284,9 @@ export class P2PDraftHost { private readonly roomCode?: string, backupEndpoint?: string, ) { + if (persistenceId && (!roomCode || parseRoomCode(roomCode) !== roomCode)) { + throw new Error("Persistent draft hosts require a canonical room code"); + } // Host is always seat 0 this.seatNames.set(0, hostDisplayName); this.activePodSize = podSize; @@ -283,6 +308,50 @@ export class P2PDraftHost { } } + /** + * One host owns one reducer and one durable timeline. Guest DataChannels + * may deliver concurrently, but their authoritative operations may not + * interleave between reducer application, immutable snapshot capture, and + * the visibility fence that follows it. + */ + private enqueueAuthoritativeMutation(operation: () => Promise): Promise { + // Start the first mutation synchronously. Timer expiry is observable at + // the same tick it reaches zero, while later DataChannel messages still + // serialize behind its durable fence. + if (this.pendingMutations === 0) { + this.pendingMutations++; + let task: Promise; + try { + task = operation(); + } catch (error) { + task = Promise.reject(error); + } + this.mutationQueue = task.then(() => undefined, () => undefined).finally(() => { + this.pendingMutations--; + }); + return task; + } + this.pendingMutations++; + const task = this.mutationQueue.then(operation); + this.mutationQueue = task.then(() => undefined, () => undefined).finally(() => { + this.pendingMutations--; + }); + return task; + } + + /** Reports a failed detached mutation instead of leaking an unhandled rejection. */ + private reportDetachedMutationFailure(label: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`[P2PDraftHost] ${label} failed:`, error); + this.emit({ type: "error", message: `${label} did not commit: ${message}` }); + } + + private runDetachedMutation(label: string, operation: () => Promise): void { + void this.enqueueAuthoritativeMutation(operation).catch((error: unknown) => { + this.reportDetachedMutationFailure(label, error); + }); + } + // ── Initialization ───────────────────────────────────────────────── async initialize(): Promise { @@ -313,45 +382,127 @@ export class P2PDraftHost { identified = true; unsub(); - if (msg.type === "draft_join") { - this.handleNewGuest(session, msg.displayName); - } else if (msg.type === "draft_reconnect") { - this.handleReconnect(session, msg.draftToken); + if (msg.type !== "draft_join" && msg.type !== "draft_reconnect") { + void this.rejectAndClose( + session, + "ProtocolMismatch", + "Expected draft_join or draft_reconnect as first message", + "Protocol violation", + ).catch((error: unknown) => this.reportDetachedMutationFailure("first-contact rejection", error)); + } else if (msg.draftProtocolVersion !== DRAFT_PROTOCOL_VERSION) { + // First-contact versioning is a hard gate. Do not allocate a seat, + // consume reconnect grace, or attach the session before it passes. + void this.rejectAndClose( + session, + "ProtocolMismatch", + `Draft protocol mismatch: host v${DRAFT_PROTOCOL_VERSION}, client v${String(msg.draftProtocolVersion)}. Refresh both windows.`, + "Draft protocol mismatch", + ).catch((error: unknown) => this.reportDetachedMutationFailure("first-contact rejection", error)); + } else if (msg.type === "draft_join") { + this.runDetachedMutation("guest admission", () => this.handleNewGuest(session, msg.displayName)); } else { - session.send({ - type: "draft_reconnect_rejected", - reason: "Expected draft_join or draft_reconnect as first message", - }); - session.close("Protocol violation"); + this.runDetachedMutation("guest reconnect", () => this.handleReconnect(session, msg.draftToken)); } }); } - private handleNewGuest(session: DraftPeerSession, displayName: string): void { + /** Flush a typed rejection before closing its DataConnection. */ + private async rejectAndClose( + session: DraftPeerSession, + kind: DraftReconnectRejectionKind, + reason: string, + closeReason: string, + ): Promise { + await session.send({ + type: "draft_reconnect_rejected", + kind, + reason, + }); + session.close(closeReason); + } + + private async handleNewGuest(session: DraftPeerSession, displayName: string): Promise { + // This is intentionally outside `admissionQueue`: a connection can close + // while waiting behind another guest, before its admission transaction + // starts. In that case it must never allocate a provisional token. + let firstContactLive = true; + const stopWatchingFirstContact = session.onDisconnect(() => { + firstContactLive = false; + }); + try { + const admission = this.admissionQueue.then(() => + this.admitNewGuest(session, displayName, () => firstContactLive), + ); + this.admissionQueue = admission.catch(() => {}); + await admission; + } finally { + stopWatchingFirstContact(); + } + } + + private async admitNewGuest( + session: DraftPeerSession, + displayName: string, + isFirstContactLive: () => boolean, + ): Promise { + if (!isFirstContactLive()) return; if (this.draftStarted) { - session.send({ type: "draft_kicked", reason: "Draft already in progress" }); - session.close("Draft in progress"); + try { + await session.send({ type: "draft_kicked", reason: "Draft already in progress" }); + } finally { + session.close("Draft in progress"); + } return; } const seat = this.firstOpenSeat(); if (seat === null) { - session.send({ type: "draft_kicked", reason: "Pod is full" }); - session.close("Pod full"); + try { + await session.send({ type: "draft_kicked", reason: "Pod is full" }); + } finally { + session.close("Pod full"); + } return; } const token = crypto.randomUUID(); this.seatTokens.set(seat, token); - this.guestSessions.set(seat, session); this.seatNames.set(seat, displayName); - session.onMessage((msg) => this.handleGuestMessage(seat, msg)); + try { + // The guest receives the capability only after this host can recover the + // matching token and seat. Publishing either welcome or lobby state + // first would leave a reloaded host unable to honour that capability. + await this.persistSessionStrict(); + } catch (err) { + this.seatTokens.delete(seat); + this.seatNames.delete(seat); + console.warn("[P2PDraftHost] guest admission persistence failed:", err); + session.close("Guest admission persistence failed"); + return; + } + + if (!isFirstContactLive()) { + await this.rollbackDisconnectedAdmission(seat); + return; + } + + this.guestSessions.set(seat, session); + // A synchronous transport close can occur during registration. The + // first-contact watcher remains active until this transaction returns, so + // roll back before installing a guest handler or announcing the seat. + if (!isFirstContactLive()) { + await this.rollbackDisconnectedAdmission(seat, session); + return; + } + session.onMessage((msg) => { + this.runDetachedMutation("guest message", () => this.handleGuestMessage(seat, msg)); + }); // Send welcome with empty view (draft hasn't started) const emptyView: DraftPlayerView = this.buildLobbyView(); - session.send({ + await session.send({ type: "draft_welcome", draftProtocolVersion: DRAFT_PROTOCOL_VERSION, draftToken: token, @@ -360,7 +511,11 @@ export class P2PDraftHost { draftCode: this.draftCode || "pending", }); - this.persistSession(); + // `send` yields for wire encoding. A close during that await runs the + // registered session-end handler, which removes the seat and persists the + // disconnect; do not announce a guest that no longer exists. + if (this.guestSessions.get(seat) !== session) return; + this.emit({ type: "seatJoined", seatIndex: seat, displayName }); this.syncLobbyToGuests(); @@ -369,10 +524,25 @@ export class P2PDraftHost { } } - private handleReconnect(session: DraftPeerSession, draftToken: string): void { + /** Removes a post-fence provisional admission and commits that removal. */ + private async rollbackDisconnectedAdmission(seat: number, session?: DraftPeerSession): Promise { + if (session && this.guestSessions.get(seat) === session) { + this.guestSessions.delete(seat); + } + this.seatTokens.delete(seat); + this.seatNames.delete(seat); + try { + // The admission snapshot already committed this provisional token, so + // make the rollback durable before another admission can begin. + await this.persistSessionStrict(); + } catch (err) { + console.warn("[P2PDraftHost] disconnected admission rollback failed:", err); + } + } + + private async handleReconnect(session: DraftPeerSession, draftToken: string): Promise { if (this.kickedTokens.has(draftToken)) { - session.send({ type: "draft_reconnect_rejected", reason: "Player kicked" }); - session.close("Kicked"); + await this.rejectAndClose(session, "Kicked", "Player kicked", "Kicked"); return; } @@ -385,59 +555,93 @@ export class P2PDraftHost { } if (seat === null) { - session.send({ type: "draft_reconnect_rejected", reason: "Unknown token" }); - session.close("Unknown token"); + await this.rejectAndClose(session, "UnknownToken", "Unknown token", "Unknown token"); return; } if (!this.disconnectedSeats.has(seat)) { - session.send({ - type: "draft_reconnect_rejected", - reason: "No grace window active for this seat", - }); - session.close("Not in grace"); + await this.rejectAndClose( + session, + "NoReconnectWindow", + "No grace window active for this seat", + "Not in grace", + ); return; } - const grace = this.disconnectedSeats.get(seat)!; - if (grace.timer !== null) clearTimeout(grace.timer); - this.disconnectedSeats.delete(seat); - this.guestSessions.set(seat, session); - - session.onMessage((msg) => this.handleGuestMessage(seat!, msg)); - - // Send current view. Order matters: sync the engine connection bitmap - // BEFORE fetching the view so the reconnect_ack carries the up-to-date - // `seats[*].connected` snapshot. Then broadcast to siblings. - void (async () => { - try { + const reconnectSeat = seat; + let live = true; + const stopWatching = session.onDisconnect?.(() => { live = false; }) ?? (() => {}); + try { + // Keep the old grace record and do not install an action handler while + // the recovered connection is merely tentative. A close between the + // engine update and durable save is rolled back below rather than + // producing a connected-looking, unrecoverable seat. + if (this.draftStarted) await this.adapter.setSeatConnected(reconnectSeat, true); + await this.persistSessionStrict(); + if (!live) { if (this.draftStarted) { - await this.adapter.setSeatConnected(seat!, true); + await this.adapter.setSeatConnected(reconnectSeat, false); + await this.persistSessionStrict(); } - const view = this.draftStarted - ? await this.adapter.getViewForSeat(seat!) - : this.buildLobbyView(); - - session.send({ - type: "draft_reconnect_ack", - draftProtocolVersion: DRAFT_PROTOCOL_VERSION, - seatIndex: seat!, - view, - draftCode: this.draftCode, - }); + return; + } + + const grace = this.disconnectedSeats.get(reconnectSeat); + if (!grace) { if (this.draftStarted) { - await this.broadcastViews(); - } - if (view.status === "MatchInProgress") { - await this.dispatchMatchLaunchesForSeat(view, seat!); + await this.adapter.setSeatConnected(reconnectSeat, false); + await this.persistSessionStrict(); } - } catch (err) { - console.error("[P2PDraftHost] reconnect view failed:", err); + await this.rejectAndClose(session, "NoReconnectWindow", "Reconnect window expired", "Reconnect window expired"); + return; } - })(); + if (grace.timer !== null) clearTimeout(grace.timer); + this.disconnectedSeats.delete(reconnectSeat); + this.guestSessions.set(reconnectSeat, session); + session.onMessage((msg) => { + this.runDetachedMutation("guest message", () => this.handleGuestMessage(reconnectSeat, msg)); + }); + + const view = this.draftStarted + ? await this.adapter.getViewForSeat(reconnectSeat) + : this.buildLobbyView(); + await session.send({ + type: "draft_reconnect_ack", + draftProtocolVersion: DRAFT_PROTOCOL_VERSION, + seatIndex: reconnectSeat, + view, + draftCode: this.draftCode, + }); + if (this.draftStarted) await this.broadcastViews(); + if (view.status === "MatchInProgress") await this.dispatchMatchLaunchesForSeat(view, reconnectSeat); + } catch (err) { + console.error("[P2PDraftHost] reconnect view failed:", err); + if (this.guestSessions.get(reconnectSeat) === session) { + this.guestSessions.delete(reconnectSeat); + } + if (this.draftStarted) { + try { await this.adapter.setSeatConnected(reconnectSeat, false); } catch { /* best-effort rollback */ } + } + if (!this.disconnectedSeats.has(reconnectSeat)) { + const timer = setTimeout(() => { + this.runDetachedMutation("reconnect grace expiry", () => this.expireReconnectGrace(reconnectSeat)); + }, this.gracePeriodMs); + this.disconnectedSeats.set(reconnectSeat, { disconnectedAt: Date.now(), timer }); + } + try { + await this.persistSessionStrict(); + } catch (persistError) { + this.reportDetachedMutationFailure("reconnect rollback", persistError); + } + session.close("Reconnect failed"); + return; + } finally { + stopWatching(); + } for (const [otherSeat, otherSession] of this.guestSessions) { - if (otherSeat === seat) continue; + if (otherSeat === reconnectSeat) continue; otherSession.send({ type: "draft_lobby_update", seats: this.buildSeatPublicViews(), @@ -446,14 +650,11 @@ export class P2PDraftHost { }); } - this.emit({ type: "seatReconnected", seatIndex: seat }); + if (!this.guestSessions.has(reconnectSeat)) return; + this.emit({ type: "seatReconnected", seatIndex: reconnectSeat }); // Resume if no other seats disconnected - if (this.disconnectedSeats.size === 0 && this.paused) { - this.paused = false; - this.broadcastToGuests({ type: "draft_resumed" }); - this.emit({ type: "draftResumed" }); - } + this.reconcileEffectivePause(); } // ── Message handling ─────────────────────────────────────────────── @@ -479,10 +680,12 @@ export class P2PDraftHost { this.guestSessions.get(seat)?.send({ type: "draft_error", reason: "Draft not started", + submissionId: msg.submissionId, + submissionDisposition: "Rejected", }); return; } - await this.handleDeckSubmission(seat, msg.mainDeck); + await this.handleDeckSubmission(seat, msg.mainDeck, msg.submissionId); break; } case "draft_match_result": { @@ -512,11 +715,11 @@ export class P2PDraftHost { break; } case "draft_bo3_intergame_command": { - this.holdIntergameCommand(seat, msg.command); + await this.holdIntergameCommand(seat, msg.command); break; } case "draft_bo3_intergame_receipt": { - this.receiptIntergameCommand(seat, msg.acknowledgement, msg.receiptId); + await this.receiptIntergameCommand(seat, msg.acknowledgement, msg.receiptId); break; } default: @@ -531,6 +734,10 @@ export class P2PDraftHost { * (or the host decides to start with fewer players). */ async startDraft(botFillEmptySeats = true): Promise { + return this.enqueueAuthoritativeMutation(() => this.startDraftInner(botFillEmptySeats)); + } + + private async startDraftInner(botFillEmptySeats: boolean): Promise { if (this.draftStarted) return; const seed = Math.floor(Math.random() * 0xffffffff); @@ -572,6 +779,11 @@ export class P2PDraftHost { await this.resolveBotPicks({ emit: false, persist: false }); } + // No client may observe the started draft until the recoverable snapshot + // exists. A refresh between a state update and this fence was the root + // cause of the original missing-pod incident. + await this.persistSessionStrict(); + // Send each guest their filtered view for (const [seat, session] of this.guestSessions) { try { @@ -582,7 +794,6 @@ export class P2PDraftHost { } } - this.persistSession(); const freshHostView = await this.adapter.getViewForSeat(0); this.emit({ type: "draftStarted", view: freshHostView }); if (freshHostView.status === "Drafting") { @@ -594,7 +805,7 @@ export class P2PDraftHost { * Host submits their own pick (seat 0). */ async submitHostPick(cardInstanceId: string): Promise { - return this.handlePick(0, cardInstanceId); + return this.enqueueAuthoritativeMutation(() => this.handlePick(0, cardInstanceId)); } /** Host submits an effect pick for seat 0. */ @@ -602,14 +813,22 @@ export class P2PDraftHost { effectCardInstanceId: string, cardInstanceIds: string[], ): Promise { - return this.handlePickWithDraftEffect(0, effectCardInstanceId, cardInstanceIds); + return this.enqueueAuthoritativeMutation(() => + this.handlePickWithDraftEffect(0, effectCardInstanceId, cardInstanceIds)); } /** * Host submits their own deck (seat 0). */ async submitHostDeck(mainDeck: string[]): Promise { - return this.handleDeckSubmission(0, mainDeck); + return this.enqueueAuthoritativeMutation(() => { + if (!this.draftStarted) throw new Error("Draft not started"); + const payloadFingerprint = deckSubmissionFingerprint(mainDeck); + const priorSubmission = [...this.deckSubmissionReceipts.entries()].find( + ([, receipt]) => receipt.seat === 0 && receipt.payloadFingerprint === payloadFingerprint, + )?.[0]; + return this.handleDeckSubmission(0, mainDeck, priorSubmission ?? crypto.randomUUID()); + }); } private assertPickAllowed(): void { @@ -675,6 +894,14 @@ export class P2PDraftHost { const view = await submitPick(); this.picksThisRound.add(seat); + // A pick acknowledgement is externally authoritative: never publish it + // until a host reload can restore the reducer result. Bot sweeps during + // initial start intentionally defer to StartDraft's one encompassing + // fence (`persist: false`). + if (options.persist) { + await this.persistSessionStrict(); + } + // Send pick acknowledgement to the picking player const session = this.guestSessions.get(seat); if (options.acknowledge && session) { @@ -684,10 +911,6 @@ export class P2PDraftHost { if (options.emit) { this.emit({ type: "pickReceived", seatIndex: seat, cardInstanceId }); } - if (options.persist) { - this.persistSession(); - } - if (options.resolveBots && !this.isBotSeat(seat)) { await this.resolveBotPicks({ emit: true, persist: true }); await this.broadcastViews(); @@ -728,37 +951,95 @@ export class P2PDraftHost { } } - private async handleDeckSubmission(seat: number, mainDeck: string[]): Promise { + private async handleDeckSubmission( + seat: number, + mainDeck: string[], + submissionId: string, + ): Promise { + let submissionAccepted = false; + let receiptDurable = false; try { - const view = await this.adapter.submitDeckForSeat(seat, mainDeck); - - const session = this.guestSessions.get(seat); - if (session) { - session.send({ type: "draft_state_update", view }); + const payloadFingerprint = deckSubmissionFingerprint(mainDeck); + const previous = this.deckSubmissionReceipts.get(submissionId); + let view: DraftPlayerView; + if (previous) { + if (previous.seat !== seat || previous.payloadFingerprint !== payloadFingerprint) { + throw new Error("Deck submission id does not match its original payload"); + } + submissionAccepted = true; + view = await this.adapter.getViewForSeat(seat); + // This is also the retry path after an IDB failure: it flushes the + // immutable pending snapshot before issuing a receipt, without ever + // submitting the deck to the reducer again. + await this.persistSessionStrict(); + receiptDurable = true; + } else { + view = await this.adapter.submitDeckForSeat(seat, mainDeck); + // Record before saving the post-reducer snapshot. A retry after a host + // reload therefore sees the same result and cannot feed the reducer a + // second submission. + this.deckSubmissionReceipts.set(submissionId, { seat, payloadFingerprint }); + submissionAccepted = true; + await this.persistSessionStrict(); + receiptDurable = true; } - this.emit({ type: "deckSubmitted", seatIndex: seat }); - this.persistSession(); - - // Check if all decks are submitted - const hostView = await this.adapter.getViewForSeat(0); - if (hostView.seats.every((s) => s.has_submitted_deck || s.is_bot)) { - this.emit({ type: "allDecksSubmitted" }); - await this.generatePairings(); + try { + await this.sendDeckSubmissionAck(seat, submissionId, view); + } catch (error) { + // The receipt is already durable. Continue host progression; a later + // retry will receive the same acknowledgement without re-reducing. + console.warn("[P2PDraftHost] deck submission acknowledgement failed:", error); } + await this.publishAcceptedDeckSubmission(seat, submissionId); - if (seat === 0) return view; - return hostView; + return seat === 0 ? view : await this.adapter.getViewForSeat(0); } catch (err) { const reason = err instanceof Error ? err.message : String(err); const session = this.guestSessions.get(seat); - if (session) { - session.send({ type: "draft_error", reason }); + if (session && !submissionAccepted) { + session.send({ type: "draft_error", reason, submissionId, submissionDisposition: "Rejected" }); + } else if (session && !receiptDurable) { + session.send({ type: "draft_error", reason, submissionId, submissionDisposition: "Retryable" }); } throw err; } } + /** Runs delayed downstream deck visibility after a durable receipt retry. */ + private async publishAcceptedDeckSubmission( + seat: number, + submissionId: string, + ): Promise { + if (this.publishedDeckSubmissions.has(submissionId)) return; + const hostView = await this.adapter.getViewForSeat(0); + // `apply_submit_deck` opens Pairing for the final deck. Once pairing has + // generated, a recovered receipt needs no downstream replay. + if (hostView.status === "MatchInProgress" || hostView.status === "Complete") { + this.publishedDeckSubmissions.add(submissionId); + return; + } + this.emit({ type: "deckSubmitted", seatIndex: seat }); + if (hostView.seats.every((candidate) => candidate.has_submitted_deck || candidate.is_bot)) { + this.emit({ type: "allDecksSubmitted" }); + await this.generatePairingsInner(); + } + this.publishedDeckSubmissions.add(submissionId); + } + + private async sendDeckSubmissionAck( + seat: number, + submissionId: string, + view: DraftPlayerView, + ): Promise { + if (seat === 0) return; + await this.guestSessions.get(seat)?.send({ + type: "draft_deck_submit_ack", + submissionId, + view, + }); + } + // ── Broadcast ────────────────────────────────────────────────────── private async broadcastViews(): Promise { @@ -814,55 +1095,76 @@ export class P2PDraftHost { // Pre-draft disconnect: free the seat this.seatTokens.delete(seat); this.seatNames.delete(seat); - this.persistSession(); - this.syncLobbyToGuests(); - this.emit({ type: "seatDisconnected", seatIndex: seat }); + this.runDetachedMutation("pre-draft disconnect", async () => { + await this.persistSessionStrict(); + this.syncLobbyToGuests(); + this.emit({ type: "seatDisconnected", seatIndex: seat }); + }); return; } - // Mid-draft disconnect: sync the engine connection bitmap first so - // `DraftPlayerView.seats[*].connected` reflects the new state, then - // broadcast to all guests. Wrapped in a void-IIFE because this method - // is sync `: void`; matching the existing convention on lines 342 / 1267. - void (async () => { - try { - await this.adapter.setSeatConnected(seat, false); - await this.broadcastViews(); - } catch (err) { - console.error( - `[P2PDraftHost] setSeatConnected(false) failed for seat ${seat}:`, - err, - ); - } - })(); - // Mid-draft disconnect: grace window const timer = setTimeout(() => { - // Grace expired — mark seat as abandoned but don't remove from draft - // (other players' packs may depend on this seat's position) - this.disconnectedSeats.delete(seat); - this.emit({ - type: "seatKicked", - seatIndex: seat, - reason: DraftPauseReason.DisconnectGraceExpired, - }); + this.runDetachedMutation("reconnect grace expiry", () => this.expireReconnectGrace(seat)); }, this.gracePeriodMs); this.disconnectedSeats.set(seat, { disconnectedAt: Date.now(), timer }); + // The socket callback is synchronous, but all externally visible state + // follows the one durable queue: connected bitmap → snapshot → views/pause. + this.runDetachedMutation("guest disconnect", async () => { + await this.adapter.setSeatConnected(seat, false); + await this.persistSessionStrict(); + await this.broadcastViews(); + this.reconcileEffectivePause(); + this.emit({ type: "seatDisconnected", seatIndex: seat }); + }); + } - if (!this.paused) { - this.paused = true; - this.broadcastToGuests({ - type: "draft_paused", - reason: DraftPauseReason.PlayerDisconnected, - }); - this.emit({ - type: "draftPaused", - reason: DraftPauseReason.PlayerDisconnected, - }); + /** + * Only this derived state controls visibility and timers. Clearing a + * reconnect grace must never accidentally cancel a host's explicit pause. + */ + private reconcileEffectivePause(): void { + const shouldPause = this.manualPause || this.disconnectedSeats.size > 0 || this.expiredDisconnectedSeats.size > 0; + if (shouldPause === this.paused) return; + this.paused = shouldPause; + if (shouldPause) { + this.freezeActiveTimer(); + const reason = this.manualPause + ? DraftPauseReason.PausedByHost + : DraftPauseReason.PlayerDisconnected; + this.broadcastToGuests({ type: "draft_paused", reason }); + this.emit({ type: "draftPaused", reason }); + return; } - - this.emit({ type: "seatDisconnected", seatIndex: seat }); + this.broadcastToGuests({ type: "draft_resumed" }); + this.emit({ type: "draftResumed" }); + if (this.resumeFrozenTimer()) return; + if (this.draftStarted && this.podPolicy === "Competitive") { + void (async () => { + try { + const view = await this.adapter.getViewForSeat(0); + if (view.status === "Drafting") this.startPickTimer(view.pick_number); + } catch { /* A later authoritative operation will retry timer setup. */ } + })(); + } + } + + /** One durable terminal transition for live and recovered reconnect grace. */ + private async expireReconnectGrace(seat: number): Promise { + // Grace expiry remains an effective pause: a missing player cannot + // silently resume the pod just because their reconnect window ended. + if (!this.disconnectedSeats.delete(seat)) return; + if (this.draftStarted) await this.adapter.setSeatConnected(seat, false); + this.expiredDisconnectedSeats.add(seat); + await this.persistSessionStrict(); + this.broadcastToGuests({ + type: "draft_paused", + reason: DraftPauseReason.DisconnectGraceExpired, + }); + this.emit({ type: "seatKicked", seatIndex: seat, reason: DraftPauseReason.DisconnectGraceExpired }); + this.emit({ type: "draftPaused", reason: DraftPauseReason.DisconnectGraceExpired }); + this.reconcileEffectivePause(); } // ── Timer management ───────────────────────────────────────────────── @@ -873,13 +1175,36 @@ export class P2PDraftHost { this.timerInterval = null; } this.timerContext = null; + this.timerTargetMatchId = null; + } + + private freezeActiveTimer(): void { + if (this.timerContext) { + this.frozenTimer = { + context: this.timerContext, + remainingMs: Math.max(0, this.timerEndAt - Date.now()), + matchId: this.timerTargetMatchId, + }; + } + this.clearActiveTimer(); + } + + private resumeFrozenTimer(): boolean { + const timer = this.frozenTimer; + this.frozenTimer = null; + if (!timer || timer.remainingMs <= 0) return false; + switch (timer.context) { + case "pick": this.startPickTimer(undefined, timer.remainingMs); return true; + case "sideboard": if (timer.matchId) { this.startSideboardTimer(timer.matchId, timer.remainingMs); return true; } return false; + case "playdraw": if (timer.matchId) { this.startPlayDrawTimer(timer.matchId, timer.remainingMs); return true; } return false; + } } - private startPickTimer(pickNumber: number): void { + private startPickTimer(pickNumber?: number, durationOverride?: number): void { this.clearActiveTimer(); if (this.podPolicy !== "Competitive") return; this.timerContext = "pick"; - const duration = pickTimerDurationMs(pickNumber); + const duration = durationOverride ?? pickTimerDurationMs(pickNumber ?? 0); this.timerRemainingMs = duration; this.timerEndAt = Date.now() + duration; this.timerInterval = setInterval(() => { @@ -893,38 +1218,40 @@ export class P2PDraftHost { if (this.timerRemainingMs <= 0) { this.clearActiveTimer(); this.emit({ type: "timerExpired" }); - void this.autoPickAllPending(); + this.runDetachedMutation("pick timer expiry", () => this.autoPickAllPending()); } } - private startSideboardTimer(matchId: string): void { + private startSideboardTimer(matchId: string, durationOverride?: number): void { this.clearActiveTimer(); this.timerContext = "sideboard"; - const SIDEBOARD_TIMER_MS = 60_000; - this.timerRemainingMs = SIDEBOARD_TIMER_MS; - this.timerEndAt = Date.now() + SIDEBOARD_TIMER_MS; + const duration = durationOverride ?? 60_000; + this.timerTargetMatchId = matchId; + this.timerRemainingMs = duration; + this.timerEndAt = Date.now() + duration; this.timerInterval = setInterval(() => { this.timerRemainingMs = Math.max(0, this.timerEndAt - Date.now()); this.broadcastToGuests({ type: "draft_timer_sync", remainingMs: this.timerRemainingMs }); if (this.timerRemainingMs <= 0) { this.clearActiveTimer(); - this.autoSubmitSideboards(matchId); + this.runDetachedMutation("sideboard timer expiry", () => this.autoSubmitSideboards(matchId)); } }, 1_000); } - private startPlayDrawTimer(matchId: string): void { + private startPlayDrawTimer(matchId: string, durationOverride?: number): void { this.clearActiveTimer(); this.timerContext = "playdraw"; - const PLAY_DRAW_TIMER_MS = 10_000; - this.timerRemainingMs = PLAY_DRAW_TIMER_MS; - this.timerEndAt = Date.now() + PLAY_DRAW_TIMER_MS; + const duration = durationOverride ?? 10_000; + this.timerTargetMatchId = matchId; + this.timerRemainingMs = duration; + this.timerEndAt = Date.now() + duration; this.timerInterval = setInterval(() => { this.timerRemainingMs = Math.max(0, this.timerEndAt - Date.now()); this.broadcastToGuests({ type: "draft_timer_sync", remainingMs: this.timerRemainingMs }); if (this.timerRemainingMs <= 0) { this.clearActiveTimer(); - this.autoChoosePlayDraw(matchId); + this.runDetachedMutation("play-draw timer expiry", () => this.autoChoosePlayDraw(matchId)); } }, 1_000); } @@ -1001,8 +1328,13 @@ export class P2PDraftHost { * Called after all decks are submitted or after round advancement. */ async generatePairings(): Promise { + return this.enqueueAuthoritativeMutation(() => this.generatePairingsInner()); + } + + private async generatePairingsInner(): Promise { try { const view = await this.adapter.generatePairings(); + await this.persistSessionStrict(); // The engine owns the round. Read it back; never compute it here. const round = view.current_round; const launchablePairings = view.pairings.filter((pairing) => @@ -1035,9 +1367,10 @@ export class P2PDraftHost { const latestView = await this.adapter.getViewForSeat(0); - // Broadcast updated views + // Launch records may have changed while dispatching. Fence that final + // snapshot before either broadcasts or the host UI observe it. + await this.persistSessionStrict(); await this.broadcastViews(); - this.persistSession(); this.emit({ type: "pairingsGenerated", round, pairings: latestView.pairings }); this.emit({ type: "viewUpdated", view: latestView }); } catch (err) { @@ -1062,7 +1395,6 @@ export class P2PDraftHost { matchAuthoritySeat: Math.min(pairing.seat_a, pairing.seat_b), }; this.matchBindings.set(pairing.match_id, binding); - this.persistSession(); return binding; } @@ -1107,12 +1439,12 @@ export class P2PDraftHost { // Persist the intent before invoking the draft reducer. A recovered pod // can retry this record without applying a second result. this.settlementOutbox.set(settlement.receiptId, settlement); - this.persistSession(); + await this.persistSessionStrict(); await this.reportMatchResult(binding.matchId, settlement.winnerSeat); const accepted = { receiptId: settlement.receiptId, revision: binding.revision }; this.settlementReceipts.set(binding.matchId, accepted); this.settlementOutbox.delete(settlement.receiptId); - this.persistSession(); + await this.persistSessionStrict(); void this.sendSettlementAck(submittingSeat, binding.matchId, accepted); } @@ -1167,7 +1499,7 @@ export class P2PDraftHost { ai_decks: [], }; - this.sendMatchLaunch(humanSeat, { + await this.sendMatchLaunch(humanSeat, { type: "Bot", matchId: pairing.match_id, round: pairing.round, @@ -1194,7 +1526,7 @@ export class P2PDraftHost { ai_decks: [], }; - this.sendMatchLaunch(matchHostSeat, { + await this.sendMatchLaunch(matchHostSeat, { type: "HumanHost", matchId: pairing.match_id, matchRoomCode, @@ -1207,7 +1539,7 @@ export class P2PDraftHost { matchConfig: matchConfigForView(view), binding, }); - this.sendMatchLaunch(guestSeat, { + await this.sendMatchLaunch(guestSeat, { type: "HumanGuest", matchId: pairing.match_id, matchRoomCode, @@ -1222,7 +1554,7 @@ export class P2PDraftHost { }); } - private sendMatchLaunch(seat: number, launch: DraftMatchLaunch): void { + private async sendMatchLaunch(seat: number, launch: DraftMatchLaunch): Promise { this.rememberMatchDecks(launch); let launches = this.matchLaunches.get(launch.matchId); if (!launches) { @@ -1236,7 +1568,7 @@ export class P2PDraftHost { this.launchDigests.set(launch.matchId, digests); } digests.set(seat, draftIntergameDigest(launch)); - this.persistSession(); + await this.persistSessionStrict(); this.sendToSeat(seat, { type: "draft_match_start", launch }); } @@ -1318,11 +1650,11 @@ export class P2PDraftHost { async reportMatchResult(matchId: string, winnerSeat: number | null): Promise { try { const view = await this.adapter.reportMatchResult(matchId, winnerSeat); + await this.persistSessionStrict(); this.emit({ type: "matchResultReceived", matchId, winnerSeat }); // Broadcast updated views with new standings await this.broadcastViews(); - this.persistSession(); this.emit({ type: "viewUpdated", view }); // Check if the reducer auto-advanced (Competitive mode) @@ -1338,7 +1670,7 @@ export class P2PDraftHost { /** Seat 0 uses the same authenticated settlement gate as remote match hosts. */ async submitHostMatchSettlement(settlement: DraftMatchSettlement): Promise { - await this.acceptMatchSettlement(0, settlement); + await this.enqueueAuthoritativeMutation(() => this.acceptMatchSettlement(0, settlement)); } /** @@ -1346,10 +1678,15 @@ export class P2PDraftHost { * T-57-07: only callable from host UI; guests sending draft_request_advance are ignored. */ async advanceRound(): Promise { + return this.enqueueAuthoritativeMutation(() => this.advanceRoundInner()); + } + + private async advanceRoundInner(): Promise { try { await this.adapter.advanceRound(); + await this.persistSessionStrict(); this.emit({ type: "roundAdvanced" }); - await this.generatePairings(); + await this.generatePairingsInner(); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.emit({ type: "error", message: `Failed to advance round: ${message}` }); @@ -1360,11 +1697,22 @@ export class P2PDraftHost { * Replace a disconnected player with a bot (Casual mode, host-only). */ async replaceSeatWithBot(seat: number): Promise { + return this.enqueueAuthoritativeMutation(() => this.replaceSeatWithBotInner(seat)); + } + + private async replaceSeatWithBotInner(seat: number): Promise { try { const seed = this.draftSeed ?? hashStringToSeed(this.draftCode || this.roomCode || "draft"); await this.adapter.replaceSeatWithBot(seat, this.botNameForSeat(seat, seed)); + const grace = this.disconnectedSeats.get(seat); + if (grace && grace.timer !== null) clearTimeout(grace.timer); + this.disconnectedSeats.delete(seat); + this.expiredDisconnectedSeats.delete(seat); + this.seatTokens.delete(seat); + this.seatNames.delete(seat); + await this.persistSessionStrict(); await this.broadcastViews(); - this.persistSession(); + this.reconcileEffectivePause(); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.emit({ type: "error", message: `Failed to replace seat ${seat}: ${message}` }); @@ -1375,7 +1723,7 @@ export class P2PDraftHost { * Override a match result (Casual mode, host-only). */ async overrideMatchResult(matchId: string, winnerSeat: number | null): Promise { - await this.reportMatchResult(matchId, winnerSeat); + await this.enqueueAuthoritativeMutation(() => this.reportMatchResult(matchId, winnerSeat)); } // ── Bo3 Between-Games Orchestration ──────────────────────────────────── @@ -1392,6 +1740,19 @@ export class P2PDraftHost { seatA: number, seatB: number, ): void { + this.runDetachedMutation("between-games transition", () => this.handleMatchBetweenGamesDurably( + matchId, gameNumber, score, loserSeat, seatA, seatB, + )); + } + + private async handleMatchBetweenGamesDurably( + matchId: string, + gameNumber: number, + score: MatchScore, + loserSeat: number | null, + seatA: number, + seatB: number, + ): Promise { const decks = this.matchDecks.get(matchId); this.bo3State.set(matchId, { seatA, seatB, @@ -1405,6 +1766,8 @@ export class P2PDraftHost { const timerMs = this.podPolicy === "Competitive" ? 60_000 : 0; + await this.persistSessionStrict(); + // Send sideboard prompt to both pairing players via draft pod channel const prompt: DraftP2PMessage = { type: "draft_bo3_sideboard_prompt", @@ -1449,7 +1812,7 @@ export class P2PDraftHost { } if (this.bo3State.get(message.matchId)?.gameNumber === message.gameNumber) return; - this.handleMatchBetweenGames( + await this.handleMatchBetweenGamesDurably( message.matchId, message.gameNumber, message.score, @@ -1461,14 +1824,18 @@ export class P2PDraftHost { /** The sole command ingress for host UI and authenticated guest sessions. */ submitAuthorized(seat: number, command: DraftIntergameCommand): void { + this.runDetachedMutation("authorized intergame command", () => this.submitAuthorizedDurably(seat, command)); + } + + private async submitAuthorizedDurably(seat: number, command: DraftIntergameCommand): Promise { if (command.status === "Receipted" && command.receiptId) { - this.receiptIntergameCommand(seat, commandAcknowledgement(command), command.receiptId); + await this.receiptIntergameCommand(seat, commandAcknowledgement(command), command.receiptId); return; } - this.holdIntergameCommand(seat, command); + await this.holdIntergameCommand(seat, command); } - private holdIntergameCommand(seat: number, command: DraftIntergameCommand): void { + private async holdIntergameCommand(seat: number, command: DraftIntergameCommand): Promise { const state = this.bo3State.get(command.matchId); const launchDigest = this.launchDigests.get(command.matchId)?.get(seat); if (!state @@ -1505,12 +1872,11 @@ export class P2PDraftHost { launchPayload: command.launchPayload, launchDigest: command.launchDigest, }); - this.persistSession(); - switch (held.payload.type) { case "SubmitSideboard": if (seat === state.seatA) state.submittedA = true; else state.submittedB = true; + await this.persistSessionStrict(); if (state.submittedA && state.submittedB) { this.clearActiveTimer(); for (const pending of this.intergameCommands.snapshot()) { @@ -1518,19 +1884,19 @@ export class P2PDraftHost { && pending.gameNumber === held.gameNumber && pending.status === "Pending" && pending.payload.type === "SubmitSideboard") { - this.authorizeIntergameCommand(pending); + await this.authorizeIntergameCommand(pending); } } this.emit({ type: "bo3BothSideboardsSubmitted", matchId: held.matchId }); } break; case "ChoosePlayDraw": - this.authorizeIntergameCommand(held); + await this.authorizeIntergameCommand(held); break; } } - private authorizeIntergameCommand(command: DraftIntergameCommand): void { + private async authorizeIntergameCommand(command: DraftIntergameCommand): Promise { const acknowledgement = commandAcknowledgement(command); const authorized = this.intergameCommands.authorize(command.commandId, acknowledgement); if (!authorized) return; @@ -1540,7 +1906,7 @@ export class P2PDraftHost { // This deliberate no-op consumption proves the issuer created the permit; // the participant performs the same pre-execution check with its own issuer. void permit; - this.persistSession(); + await this.persistSessionStrict(); this.sendToSeat(command.seat, { type: "draft_bo3_intergame_authorized", command: authorized, @@ -1548,18 +1914,17 @@ export class P2PDraftHost { }); } - private receiptIntergameCommand( + private async receiptIntergameCommand( seat: number, acknowledgement: DraftIntergameCommandAck, receiptId: string, - ): void { + ): Promise { const command = this.intergameCommands.snapshot().find( (candidate) => candidate.commandId === acknowledgement.commandId, ); if (!command || command.seat !== seat || !matchesCommandAcknowledgement(command, acknowledgement)) return; const receipted = this.intergameCommands.receipt(command.commandId, acknowledgement, receiptId); if (!receipted) return; - this.persistSession(); switch (receipted.payload.type) { case "SubmitSideboard": { const state = this.bo3State.get(receipted.matchId); @@ -1576,16 +1941,18 @@ export class P2PDraftHost { && candidate.payload.type === "SubmitSideboard" && candidate.status === "Receipted"), ); - if (complete && state) this.transitionToPlayDraw(receipted.matchId, state); + await this.persistSessionStrict(); + if (complete && state) await this.transitionToPlayDraw(receipted.matchId, state); break; } case "ChoosePlayDraw": - this.resolvePlayDrawChoice(receipted.matchId, receipted.payload.playFirst); + await this.persistSessionStrict(); + await this.resolvePlayDrawChoice(receipted.matchId, receipted.payload.playFirst); break; } } - private autoSubmitSideboards(matchId: string): void { + private async autoSubmitSideboards(matchId: string): Promise { const state = this.bo3State.get(matchId); if (!state) return; const participants = [state.seatA, state.seatB]; @@ -1600,7 +1967,7 @@ export class P2PDraftHost { this.emit({ type: "error", message: "Sideboard timer expired without a registered deck" }); continue; } - this.submitDefaultIntergameCommand(matchId, state, seat, { + await this.submitDefaultIntergameCommand(matchId, state, seat, { type: "SubmitSideboard", main: deck.main, sideboard: deck.sideboard, @@ -1608,10 +1975,10 @@ export class P2PDraftHost { } } - private autoChoosePlayDraw(matchId: string): void { + private async autoChoosePlayDraw(matchId: string): Promise { const state = this.bo3State.get(matchId); if (!state || state.loserSeat === null) return; - this.submitDefaultIntergameCommand(matchId, state, state.loserSeat, { + await this.submitDefaultIntergameCommand(matchId, state, state.loserSeat, { type: "ChoosePlayDraw", playFirst: true, }); @@ -1619,19 +1986,19 @@ export class P2PDraftHost { /** Timeout defaults enter the same signed launch/ledger path as a player * submission, so they cannot bypass authorization or the execution receipt. */ - private submitDefaultIntergameCommand( + private async submitDefaultIntergameCommand( matchId: string, state: Bo3MatchState, seat: number, payload: DraftIntergameCommand["payload"], - ): void { + ): Promise { const launch = this.matchLaunches.get(matchId)?.get(seat); const launchDigest = this.launchDigests.get(matchId)?.get(seat); if (!launch || !launchDigest) { this.emit({ type: "error", message: "Intergame timeout lacks launch authority" }); return; } - this.holdIntergameCommand(seat, { + await this.holdIntergameCommand(seat, { commandId: crypto.randomUUID(), matchId, gameNumber: state.gameNumber, @@ -1644,7 +2011,7 @@ export class P2PDraftHost { }); } - private transitionToPlayDraw(matchId: string, state: Bo3MatchState): void { + private async transitionToPlayDraw(matchId: string, state: Bo3MatchState): Promise { if (state.loserSeat !== null) { const timerMs = this.podPolicy === "Competitive" ? 10_000 : 0; const prompt: DraftP2PMessage = { @@ -1658,11 +2025,11 @@ export class P2PDraftHost { if (timerMs > 0) this.startPlayDrawTimer(matchId); } else { // Draw — keep previous first player. Signal game start immediately. - this.resolvePlayDrawChoice(matchId, true); + await this.resolvePlayDrawChoice(matchId, true); } } - private resolvePlayDrawChoice(matchId: string, playFirst: boolean): void { + private async resolvePlayDrawChoice(matchId: string, playFirst: boolean): Promise { this.clearActiveTimer(); const state = this.bo3State.get(matchId); if (!state) return; @@ -1671,6 +2038,8 @@ export class P2PDraftHost { ? (state.loserSeat ?? state.seatA) : (state.loserSeat === state.seatA ? state.seatB : state.seatA); + this.bo3State.delete(matchId); + await this.persistSessionStrict(); const msg: DraftP2PMessage = { type: "draft_bo3_game_start", matchId, @@ -1680,7 +2049,6 @@ export class P2PDraftHost { this.sendToSeat(state.seatA, msg); this.sendToSeat(state.seatB, msg); - this.bo3State.delete(matchId); this.emit({ type: "bo3GameStarted", matchId, gameNumber: state.gameNumber }); } @@ -1739,15 +2107,15 @@ export class P2PDraftHost { // ── Host controls ────────────────────────────────────────────────── kickPlayer(seat: number, reason: string = "Kicked by host"): void { + this.runDetachedMutation("kick player", () => this.kickPlayerDurably(seat, reason)); + } + + private async kickPlayerDurably(seat: number, reason: string): Promise { const token = this.seatTokens.get(seat); if (token) this.kickedTokens.add(token); const session = this.guestSessions.get(seat); - if (session) { - session.send({ type: "draft_kicked", reason }); - session.close("Kicked"); - this.guestSessions.delete(seat); - } + if (session) this.guestSessions.delete(seat); // Cancel grace timer if active const grace = this.disconnectedSeats.get(seat); @@ -1755,97 +2123,133 @@ export class P2PDraftHost { if (grace.timer !== null) clearTimeout(grace.timer); this.disconnectedSeats.delete(seat); } + this.expiredDisconnectedSeats.delete(seat); - this.persistSession(); + await this.persistSessionStrict(); + if (session) { + session.send({ type: "draft_kicked", reason }); + session.close("Kicked"); + } this.emit({ type: "seatKicked", seatIndex: seat, reason }); this.syncLobbyToGuests(); + this.reconcileEffectivePause(); } requestPause(): void { - if (!this.paused) { - this.clearActiveTimer(); - this.paused = true; - this.broadcastToGuests({ - type: "draft_paused", - reason: DraftPauseReason.PausedByHost, - }); - this.emit({ type: "draftPaused", reason: DraftPauseReason.PausedByHost }); - } + void this.enqueueAuthoritativeMutation(async () => { + if (this.manualPause) return; + this.manualPause = true; + await this.persistSessionStrict(); + this.reconcileEffectivePause(); + }).catch((error: unknown) => console.error("[P2PDraftHost] pause persistence failed:", error)); } requestResume(): void { - if (this.paused && this.disconnectedSeats.size === 0) { - this.paused = false; - this.broadcastToGuests({ type: "draft_resumed" }); - this.emit({ type: "draftResumed" }); - // Restart timer if still in drafting phase - if (this.draftStarted && this.podPolicy === "Competitive") { - void (async () => { - try { - const view = await this.adapter.getViewForSeat(0); - if (view.status === "Drafting") { - this.startPickTimer(view.pick_number); - } - } catch { /* best-effort */ } - })(); - } - } + void this.enqueueAuthoritativeMutation(async () => { + if (!this.manualPause) return; + this.manualPause = false; + await this.persistSessionStrict(); + this.reconcileEffectivePause(); + }).catch((error: unknown) => console.error("[P2PDraftHost] resume persistence failed:", error)); } // ── Persistence (P2P-05) ────────────────────────────────────────── private persistSession(): void { if (!this.persistenceId || this.persistenceClosed) return; - this.persistQueue = this.persistQueue.then(async () => { - try { - if (this.persistenceClosed) return; - const sessionJson = this.draftStarted - ? await this.adapter.exportSession() - : null; - if (this.persistenceClosed) return; - - const snapshot: PersistedDraftHostSession = { - persistenceId: this.persistenceId!, - roomCode: this.roomCode ?? "", - kind: this.kind, - podSize: this.podSize, - hostDisplayName: this.hostDisplayName, - tournamentFormat: this.tournamentFormat, - podPolicy: this.podPolicy, - seatTokens: Object.fromEntries(this.seatTokens), - seatNames: Object.fromEntries(this.seatNames), - kickedTokens: [...this.kickedTokens], - draftStarted: this.draftStarted, - draftCode: this.draftCode, - draftSessionJson: sessionJson, - poolInput: this.poolInput, - matchBindings: [...this.matchBindings.values()], - settlementOutbox: [...this.settlementOutbox.values()], - settlementReceipts: [...this.settlementReceipts.entries()].map( - ([matchId, receipt]) => ({ matchId, ...receipt }), - ), - intergameCommands: this.intergameCommands.snapshot(), - bo3State: [...this.bo3State.entries()].map(([matchId, state]) => ({ matchId, ...state })), - launchDigests: [...this.launchDigests.entries()].flatMap(([matchId, digests]) => - [...digests.entries()].map(([seat, digest]) => ({ matchId, seat, digest })), - ), - matchLaunches: [...this.matchLaunches.entries()].flatMap(([matchId, launches]) => - [...launches.entries()].map(([seat, launch]) => ({ matchId, seat, launch })), - ), - }; + // Lobby snapshots contain no asynchronous engine export, so capture them + // at mutation time. A later admission must not leak into an earlier queued + // snapshot if its own strict write fails. + const snapshot = this.draftStarted ? undefined : this.buildPersistedSnapshot(null); + void this.enqueuePersistSession(snapshot).catch(() => {}); + } + + /** Admission callers await this fence before issuing a recoverable token. */ + private persistSessionStrict(): Promise { + if (!this.persistenceId || this.persistenceClosed) return Promise.resolve(); + return this.enqueuePersistSession( + this.draftStarted ? undefined : this.buildPersistedSnapshot(null), + ); + } + + /** + * Serializes snapshots while retaining a live queue after a failed write. + * Fire-and-forget mutations report errors through `persistSession`; admission + * awaits the returned task and rolls its mutation back on failure. + */ + private enqueuePersistSession(snapshotAtMutation?: PersistedDraftHostSession): Promise { + if (!this.persistenceId || this.persistenceClosed) return Promise.resolve(); + const persist = this.persistQueue.then(async () => { + if (this.persistenceClosed) return; + // A failed deck/pick snapshot is immutable evidence of an already-run + // reducer. Retry it before capturing newer state; never retry by + // applying the command again. + if (this.pendingDraftSnapshot) { + await saveDraftHostSession(this.persistenceId!, this.pendingDraftSnapshot); + this.pendingDraftSnapshot = null; + } + const snapshot = snapshotAtMutation ?? this.buildPersistedSnapshot( + this.draftStarted ? await this.adapter.exportSession() : null, + ); + if (this.persistenceClosed) return; + try { await saveDraftHostSession(this.persistenceId!, snapshot); + } catch (error) { + // Admission has its own transactional rollback. Only an engine-backed + // snapshot represents a reducer result that must be replayed exactly. + if (this.draftStarted) this.pendingDraftSnapshot = snapshot; + throw error; + } - // Server backup upload (D-08, T-60-11: rate-limited to every N picks) - this.picksSinceLastBackup++; - if (this.backupEndpoint && this.picksSinceLastBackup >= P2PDraftHost.BACKUP_INTERVAL_PICKS) { - this.picksSinceLastBackup = 0; - void this.uploadBackupSnapshot(snapshot); - } - } catch (err) { - console.warn("[P2PDraftHost] persist failed:", err); + // Server backup upload (D-08, T-60-11: rate-limited to every N picks) + this.picksSinceLastBackup++; + if (this.backupEndpoint && this.picksSinceLastBackup >= P2PDraftHost.BACKUP_INTERVAL_PICKS) { + this.picksSinceLastBackup = 0; + void this.uploadBackupSnapshot(snapshot); } }); + this.persistQueue = persist.catch((err) => { + console.warn("[P2PDraftHost] persist failed:", err); + }); + return persist; + } + + private buildPersistedSnapshot(draftSessionJson: string | null): PersistedDraftHostSession { + return { + persistenceId: this.persistenceId!, + roomCode: this.roomCode!, + kind: this.kind, + podSize: this.podSize, + hostDisplayName: this.hostDisplayName, + tournamentFormat: this.tournamentFormat, + podPolicy: this.podPolicy, + seatTokens: Object.fromEntries(this.seatTokens), + seatNames: Object.fromEntries(this.seatNames), + kickedTokens: [...this.kickedTokens], + expiredDisconnectedSeats: [...this.expiredDisconnectedSeats], + draftStarted: this.draftStarted, + manualPause: this.manualPause, + draftCode: this.draftCode, + draftSessionJson, + poolInput: this.poolInput, + matchBindings: [...this.matchBindings.values()], + settlementOutbox: [...this.settlementOutbox.values()], + settlementReceipts: [...this.settlementReceipts.entries()].map( + ([matchId, receipt]) => ({ matchId, ...receipt }), + ), + intergameCommands: this.intergameCommands.snapshot(), + bo3State: [...this.bo3State.entries()].map(([matchId, state]) => ({ matchId, ...state })), + launchDigests: [...this.launchDigests.entries()].flatMap(([matchId, digests]) => + [...digests.entries()].map(([seat, digest]) => ({ matchId, seat, digest })), + ), + matchLaunches: [...this.matchLaunches.entries()].flatMap(([matchId, launches]) => + [...launches.entries()].map(([seat, launch]) => ({ matchId, seat, launch })), + ), + deckSubmissionReceipts: [...this.deckSubmissionReceipts.entries()].map( + ([submissionId, receipt]) => ({ submissionId, ...receipt }), + ), + }; } /** @@ -1899,7 +2303,11 @@ export class P2PDraftHost { for (const token of session.kickedTokens) { this.kickedTokens.add(token); } + for (const seat of session.expiredDisconnectedSeats ?? []) { + this.expiredDisconnectedSeats.add(seat); + } this.draftStarted = session.draftStarted; + this.manualPause = session.manualPause ?? false; this.draftCode = session.draftCode; this.draftSeed = hashStringToSeed(session.draftCode || this.roomCode || "draft"); for (const binding of session.matchBindings ?? []) { @@ -1914,6 +2322,12 @@ export class P2PDraftHost { revision: receipt.revision, }); } + for (const receipt of session.deckSubmissionReceipts ?? []) { + this.deckSubmissionReceipts.set(receipt.submissionId, { + seat: receipt.seat, + payloadFingerprint: receipt.payloadFingerprint, + }); + } this.intergameCommands = new IntergameCommandController(session.intergameCommands ?? []); this.intergameCommands.recover(); for (const state of session.bo3State ?? []) { @@ -1941,28 +2355,13 @@ export class P2PDraftHost { this.rememberMatchDecks(recoveredLaunch); } + this.armRecoveredGuestGrace(); + if (session.draftSessionJson) { const view = await this.adapter.importSession(session.draftSessionJson, 2); await this.recoverSettlementOutbox(view); - // Arm grace windows for all guest seats - for (const seatStr of Object.keys(session.seatTokens)) { - const seat = Number(seatStr); - if (seat === 0) continue; - const timer = setTimeout(() => { - this.disconnectedSeats.delete(seat); - this.emit({ type: "seatKicked", seatIndex: seat, reason: "Resume grace expired" }); - }, 5 * 60_000); - this.disconnectedSeats.set(seat, { disconnectedAt: Date.now(), timer }); - } - - if (this.disconnectedSeats.size > 0) { - this.paused = true; - this.emit({ - type: "draftPaused", - reason: DraftPauseReason.PlayerDisconnected, - }); - } + this.reconcileEffectivePause(); if (view.status === "MatchInProgress") { await this.dispatchMatchLaunchesForSeat(view, 0); @@ -1983,7 +2382,7 @@ export class P2PDraftHost { // round; and `AdvanceRound` requires `RoundComplete`, which the final // round never enters (it transitions straight to `Complete`), so there // is no round past the last one for this branch to invent. - await this.generatePairings(); + await this.generatePairingsInner(); return this.adapter.getViewForSeat(0); } @@ -1993,6 +2392,17 @@ export class P2PDraftHost { return null; } + /** Restored guests get the same bounded reconnect window in lobby or draft. */ + private armRecoveredGuestGrace(): void { + for (const seat of this.seatTokens.keys()) { + if (seat === 0 || this.disconnectedSeats.has(seat) || this.expiredDisconnectedSeats.has(seat)) continue; + const timer = setTimeout(() => { + this.runDetachedMutation("recovered reconnect grace expiry", () => this.expireReconnectGrace(seat)); + }, 5 * 60_000); + this.disconnectedSeats.set(seat, { disconnectedAt: Date.now(), timer }); + } + } + /** Replays only write-ahead settlements that the restored draft still lacks. */ private async recoverSettlementOutbox(view: DraftPlayerView): Promise { for (const settlement of [...this.settlementOutbox.values()]) { @@ -2015,7 +2425,11 @@ export class P2PDraftHost { // ── Cleanup ──────────────────────────────────────────────────────── - dispose(): void { + async dispose(): Promise { + // Closing this synchronously is a write fence: `persistSession` continuations + // may already be queued, but none may snapshot or save after their host loses + // ownership to a newer recovery using the same persistence ID. + this.persistenceClosed = true; this.clearActiveTimer(); if (this.hostConnectionUnsub) this.hostConnectionUnsub(); for (const { timer } of this.disconnectedSeats.values()) { @@ -2030,19 +2444,21 @@ export class P2PDraftHost { } this.guestSessions.clear(); this.listeners = []; + await this.persistQueue; } async terminateDraft(): Promise { + // Fence queued non-terminal saves before awaiting guest notifications. + this.persistenceClosed = true; for (const session of this.guestSessions.values()) { await session.send({ type: "draft_host_left", reason: "Host left the draft" }); } - this.persistenceClosed = true; await this.persistQueue; if (this.persistenceId) { await clearDraftHostSession(this.persistenceId); } void this.cleanupServerBackup(); - this.dispose(); + await this.dispose(); try { this.hostPeer.destroy(); } catch { /* best-effort */ } diff --git a/client/src/components/draft/LimitedDeckBuilder.tsx b/client/src/components/draft/LimitedDeckBuilder.tsx index 35cb227670..82389175c2 100644 --- a/client/src/components/draft/LimitedDeckBuilder.tsx +++ b/client/src/components/draft/LimitedDeckBuilder.tsx @@ -254,6 +254,7 @@ export function LimitedDeckBuilder({ const [legacyFilterOptions, setLegacyFilterOptions] = useState(null); const [localSubmissionError, setLocalSubmissionError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const pool = useMemo(() => view?.pool ?? [], [view?.pool]); @@ -367,12 +368,16 @@ export function LimitedDeckBuilder({ }, [mainDeck, landCounts]); const handleSubmit = async () => { + if (isSubmitting) return; setLocalSubmissionError(null); + setIsSubmitting(true); try { await submitDeck(); } catch (error) { const message = error instanceof Error ? error.message : String(error); setLocalSubmissionError(message || t("limitedDeck.submitFailed")); + } finally { + setIsSubmitting(false); } }; @@ -549,11 +554,11 @@ export function LimitedDeckBuilder({ + + ); +} + function ActiveDraftCard({ meta }: { meta: ActiveQuickDraftMeta }) { const { t } = useTranslation("draft"); const navigate = useNavigate(); diff --git a/client/src/pages/DraftPodPage.tsx b/client/src/pages/DraftPodPage.tsx index 5a63911765..f755667edf 100644 --- a/client/src/pages/DraftPodPage.tsx +++ b/client/src/pages/DraftPodPage.tsx @@ -8,9 +8,9 @@ * 4. Deckbuilding: LimitedDeckBuilder (reuses Quick Draft component) */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import { MenuSelect } from "../components/ui/MenuSelect"; import type { CardHoverInfo } from "../components/card/CardPreview"; @@ -38,6 +38,7 @@ import { intergamePromptKey, useMultiplayerDraftStore, type DraftPodScreen, + type GuestDraftResumeOutcome, } from "../stores/multiplayerDraftStore"; import { useDraftPodStore } from "../stores/draftPodStore"; @@ -757,20 +758,31 @@ function CompleteView({ onLeave }: { onLeave: () => void }) { function PodErrorView({ phase, onLeave, + onRetry, }: { phase: "error" | "kicked" | "hostLeft"; onLeave: () => void; + onRetry: () => void; }) { const { t } = useTranslation("draft"); + const recoveryFailure = useMultiplayerDraftStore((s) => s.guestRecoveryFailure); const message = phase === "kicked" ? t("podError.kicked") : phase === "hostLeft" ? t("podError.hostLeft") - : t("podError.connection"); + : recoveryFailure?.message ?? t("podError.connection"); return (
{message}
+ {phase === "error" && recoveryFailure?.kind === "retryable" && ( + + )}
)}
- {phaseContent(visibleScreen, handleLeave, () => setDismissedPromptKey(promptKey))} + {phaseContent(visibleScreen, handleLeave, () => setDismissedPromptKey(promptKey), retryGuestRecovery)}
diff --git a/client/src/pages/MultiplayerPage.tsx b/client/src/pages/MultiplayerPage.tsx index 9608dfbee7..f7bd85033e 100644 --- a/client/src/pages/MultiplayerPage.tsx +++ b/client/src/pages/MultiplayerPage.tsx @@ -525,7 +525,7 @@ export function MultiplayerPage() { async (code: string, _context?: LobbyGame) => { const playerName = useMultiplayerStore.getState().displayName ?? "Player"; try { - await joinDraft({ roomCode: code, displayName: playerName }); + await joinDraft({ kind: "new", roomCode: code, displayName: playerName }); setView("draft-lobby"); } catch { showToast(t("page.failedToJoinDraft")); diff --git a/client/src/pages/__tests__/DraftPodPage.podError.test.tsx b/client/src/pages/__tests__/DraftPodPage.podError.test.tsx index 9df70a6938..d06d645fdf 100644 --- a/client/src/pages/__tests__/DraftPodPage.podError.test.tsx +++ b/client/src/pages/__tests__/DraftPodPage.podError.test.tsx @@ -12,7 +12,9 @@ const { draftState } = vi.hoisted(() => ({ sideboardPrompt: null, playDrawPrompt: null, error: null as string | null, + guestRecoveryFailure: null as { kind: "retryable" | "incompatible" | "invalid"; message: string } | null, clearError: vi.fn(), + resumeDraft: vi.fn<(options?: { signal?: AbortSignal }) => Promise>(async () => "resumed"), currentRound: 2, nextPairingRound: 3, standings: [], @@ -50,7 +52,7 @@ vi.mock("../../components/draft/LimitedDeckBuilder", () => ({ LimitedDeckBuilder vi.mock("../../components/draft/ScoreBadge", () => ({ ScoreBadge: () =>
})); function renderPage() { - return render(); + return render(); } const ERROR_TEXT = "Failed to advance round: pairing generation failed"; @@ -60,7 +62,9 @@ describe("DraftPodPage pod error banner", () => { beforeEach(() => { draftState.error = null; + draftState.guestRecoveryFailure = null; draftState.clearError.mockClear(); + draftState.resumeDraft.mockClear(); }); it("surfaces the store error in the pairing phase", () => { @@ -126,4 +130,49 @@ describe("DraftPodPage pod error banner", () => { expect(draftState.clearError).toHaveBeenCalled(); }); + + it("offers retry only for a typed retryable guest recovery failure", async () => { + const user = userEvent.setup(); + draftState.phase = "error"; + draftState.guestRecoveryFailure = { + kind: "retryable", + message: "Host is still coming back online", + }; + renderPage(); + + expect(screen.getByText("Host is still coming back online")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Try Reconnecting" })); + expect(draftState.resumeDraft).toHaveBeenCalledOnce(); + }); + + it("does not offer retry for an incompatible recovery failure", () => { + draftState.phase = "error"; + draftState.guestRecoveryFailure = { + kind: "incompatible", + message: "Refresh both windows", + }; + renderPage(); + + expect(screen.getByText("Refresh both windows")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Try Reconnecting" })).toBeNull(); + }); + + it("aborts the retry attempt when its page unmounts", async () => { + const user = userEvent.setup(); + let settle!: (outcome: string) => void; + draftState.phase = "error"; + draftState.guestRecoveryFailure = { kind: "retryable", message: "Host is restarting" }; + draftState.resumeDraft.mockImplementationOnce(({ signal }: { signal?: AbortSignal } = {}) => new Promise((resolve) => { + settle = resolve; + expect(signal?.aborted).toBe(false); + })); + const { unmount } = renderPage(); + + await user.click(screen.getByRole("button", { name: "Try Reconnecting" })); + const [{ signal } = {}] = draftState.resumeDraft.mock.calls[0]!; + unmount(); + + expect(signal?.aborted).toBe(true); + settle("superseded"); + }); }); diff --git a/client/src/pwa/__tests__/chunkReloadHandler.test.ts b/client/src/pwa/__tests__/chunkReloadHandler.test.ts index 8cc0d06a07..6a8289fba1 100644 --- a/client/src/pwa/__tests__/chunkReloadHandler.test.ts +++ b/client/src/pwa/__tests__/chunkReloadHandler.test.ts @@ -4,7 +4,9 @@ import { installChunkReloadHandler } from "../chunkReloadHandler"; const mocks = vi.hoisted(() => ({ isMultiplayerGameLive: vi.fn<() => boolean>(() => false), - whenMultiplayerGameEnds: vi.fn<(cb: () => void) => () => void>(), + deferUntilMultiplayerSessionEnds: vi.fn< + (action: () => void) => { deferred: boolean; cancel: () => void } + >(), trackEvent: vi.fn(), flushNow: vi.fn(), claimUpdateStatus: vi.fn(() => true), @@ -16,7 +18,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("../multiplayerGuard", () => ({ isMultiplayerGameLive: mocks.isMultiplayerGameLive, - whenMultiplayerGameEnds: mocks.whenMultiplayerGameEnds, + deferUntilMultiplayerSessionEnds: mocks.deferUntilMultiplayerSessionEnds, })); vi.mock("../updateStatus", () => ({ claimUpdateStatus: mocks.claimUpdateStatus, @@ -83,9 +85,13 @@ describe("chunkReloadHandler loop breaker", () => { window.sessionStorage.clear(); gameEndCallbacks = []; mocks.isMultiplayerGameLive.mockReturnValue(false); - mocks.whenMultiplayerGameEnds.mockImplementation((cb: () => void) => { - gameEndCallbacks.push(cb); - return () => {}; + mocks.deferUntilMultiplayerSessionEnds.mockImplementation((action: () => void) => { + if (!mocks.isMultiplayerGameLive()) { + action(); + return { deferred: false, cancel: () => {} }; + } + gameEndCallbacks.push(action); + return { deferred: true, cancel: () => {} }; }); reloadSpy = vi.fn(); Object.defineProperty(window.location, "reload", { @@ -247,7 +253,7 @@ describe("chunkReloadHandler loop breaker", () => { // First-failure-wins: one queued reload, no executed reloads, no breach. expect(reloadSpy).not.toHaveBeenCalled(); - expect(mocks.whenMultiplayerGameEnds).toHaveBeenCalledTimes(1); + expect(mocks.deferUntilMultiplayerSessionEnds).toHaveBeenCalledTimes(1); expect(mocks.setUpdateStatus).toHaveBeenCalledWith("deferred"); expect(mocks.setUpdateError).not.toHaveBeenCalled(); expect(guardEntry(message)).toBeNull(); @@ -264,7 +270,7 @@ describe("chunkReloadHandler loop breaker", () => { "Failed to fetch dynamically imported module: https://phase-rs.dev/assets/GamePage-mp.js"; firePreloadError(message); - expect(mocks.whenMultiplayerGameEnds).toHaveBeenCalledTimes(1); + expect(mocks.deferUntilMultiplayerSessionEnds).toHaveBeenCalledTimes(1); // A breach mid-game (counter pre-filled from before the game started) // must not queue more work, but must not cancel the queued reload either. @@ -274,7 +280,7 @@ describe("chunkReloadHandler loop breaker", () => { ); firePreloadError(message); - expect(mocks.whenMultiplayerGameEnds).toHaveBeenCalledTimes(1); + expect(mocks.deferUntilMultiplayerSessionEnds).toHaveBeenCalledTimes(1); expect(mocks.setUpdateError).toHaveBeenCalledTimes(1); expectAbortEvent(message); diff --git a/client/src/pwa/__tests__/multiplayerGuard.test.ts b/client/src/pwa/__tests__/multiplayerGuard.test.ts new file mode 100644 index 0000000000..c59675d893 --- /dev/null +++ b/client/src/pwa/__tests__/multiplayerGuard.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useGameStore } from "../../stores/gameStore"; +import { + type MultiplayerDraftPhase, + useMultiplayerDraftStore, +} from "../../stores/multiplayerDraftStore"; +import { + deferUntilMultiplayerSessionEnds, + isMultiplayerGameLive, + whenMultiplayerGameEnds, +} from "../multiplayerGuard"; + +const LIVE_DRAFT_PHASES: MultiplayerDraftPhase[] = [ + "connecting", + "lobby", + "drafting", + "deckbuilding", + "pairing", + "matchInProgress", + "roundComplete", +]; + +function setRemoteGameLive(live: boolean): void { + useGameStore.setState({ + gameMode: live ? "p2p-host" : null, + adapter: live ? ({} as never) : null, + gameState: null, + }); +} + +function setDraftPod(role: "host" | "guest" | null, phase: MultiplayerDraftPhase): void { + useMultiplayerDraftStore.setState({ role, phase }); +} + +describe("multiplayerGuard", () => { + beforeEach(() => { + setRemoteGameLive(false); + setDraftPod(null, "idle"); + }); + + afterEach(() => { + setRemoteGameLive(false); + setDraftPod(null, "idle"); + }); + + it.each(LIVE_DRAFT_PHASES)("keeps update actions parked during draft pod %s", (phase) => { + setDraftPod("guest", phase); + + expect(isMultiplayerGameLive()).toBe(true); + }); + + it.each(["idle", "complete", "error", "kicked", "hostLeft"])( + "does not treat terminal draft pod %s as live", + (phase) => { + setDraftPod("host", phase); + + expect(isMultiplayerGameLive()).toBe(false); + }, + ); + + it("requires a pod role so stale phases do not block an update", () => { + setDraftPod(null, "deckbuilding"); + + expect(isMultiplayerGameLive()).toBe(false); + }); + + it("waits for both a remote game and a draft pod to end", () => { + setRemoteGameLive(true); + setDraftPod("host", "deckbuilding"); + const callback = vi.fn(); + + const cancel = whenMultiplayerGameEnds(callback); + setRemoteGameLive(false); + + expect(callback).not.toHaveBeenCalled(); + + setDraftPod("host", "complete"); + + expect(callback).toHaveBeenCalledTimes(1); + cancel(); + }); + + it("runs a deferred action exactly once and clears its cancellation handle", () => { + setDraftPod("guest", "pairing"); + const action = vi.fn(); + + const pending = deferUntilMultiplayerSessionEnds(action); + + expect(pending.deferred).toBe(true); + setDraftPod("guest", "complete"); + setDraftPod("guest", "hostLeft"); + pending.cancel(); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it("runs an activation before queued reloads when the pod ends", () => { + setDraftPod("host", "deckbuilding"); + const actions: string[] = []; + + deferUntilMultiplayerSessionEnds(() => actions.push("reload-a"), "reload"); + deferUntilMultiplayerSessionEnds(() => actions.push("activation"), "activation"); + deferUntilMultiplayerSessionEnds(() => actions.push("reload-b"), "reload"); + deferUntilMultiplayerSessionEnds(() => actions.push("install"), "install"); + setDraftPod("host", "complete"); + + expect(actions).toEqual(["activation", "reload-a", "reload-b", "install"]); + }); + + it("cancels only its own queued action while other callers remain parked", () => { + setDraftPod("host", "drafting"); + const cancelled = vi.fn(); + const retained = vi.fn(); + + const first = deferUntilMultiplayerSessionEnds(cancelled, "reload"); + deferUntilMultiplayerSessionEnds(retained, "activation"); + first.cancel(); + setDraftPod("host", "complete"); + + expect(cancelled).not.toHaveBeenCalled(); + expect(retained).toHaveBeenCalledTimes(1); + }); + + it("cancels a deferred action without retaining an end callback", () => { + setRemoteGameLive(true); + const action = vi.fn(); + + const pending = deferUntilMultiplayerSessionEnds(action); + pending.cancel(); + setRemoteGameLive(false); + + expect(action).not.toHaveBeenCalled(); + }); + + it("runs an action immediately when neither session is live", () => { + const action = vi.fn(); + + const pending = deferUntilMultiplayerSessionEnds(action); + + expect(pending.deferred).toBe(false); + expect(action).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/pwa/__tests__/registerServiceWorker.test.ts b/client/src/pwa/__tests__/registerServiceWorker.test.ts new file mode 100644 index 0000000000..0c29627986 --- /dev/null +++ b/client/src/pwa/__tests__/registerServiceWorker.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { useMultiplayerDraftStore as MultiplayerDraftStore } from "../../stores/multiplayerDraftStore"; + +const mocks = vi.hoisted(() => ({ + registerSW: vi.fn(), + isBundledTauriOrigin: vi.fn(() => false), + claimServiceWorkerReload: vi.fn(() => true), + markPendingAutoUpdate: vi.fn(), + claimUpdateStatus: vi.fn(() => true), + setUpdateStatus: vi.fn(), + getUpdateStatus: vi.fn(() => "idle"), + releaseUpdateStatus: vi.fn(), + setDownloadProgress: vi.fn(), + pushUpdateDebug: vi.fn(), + setUpdateError: vi.fn(), + clearUpdateError: vi.fn(), +})); + +vi.mock("\0virtual:pwa-register-stub", () => ({ registerSW: mocks.registerSW })); +vi.mock("../../services/platform", () => ({ isBundledTauriOrigin: mocks.isBundledTauriOrigin })); +vi.mock("../updateMarker", () => ({ + claimServiceWorkerReload: mocks.claimServiceWorkerReload, + markPendingAutoUpdate: mocks.markPendingAutoUpdate, +})); +vi.mock("../updateStatus", () => ({ + claimUpdateStatus: mocks.claimUpdateStatus, + setUpdateStatus: mocks.setUpdateStatus, + getUpdateStatus: mocks.getUpdateStatus, + releaseUpdateStatus: mocks.releaseUpdateStatus, + setDownloadProgress: mocks.setDownloadProgress, + pushUpdateDebug: mocks.pushUpdateDebug, + setUpdateError: mocks.setUpdateError, + clearUpdateError: mocks.clearUpdateError, +})); + +type ServiceWorkerOptions = { + onNeedRefresh(): void; +}; + +describe("registerServiceWorker draft-pod protection", () => { + let controllerChange: (() => void) | null; + let updateSW: ReturnType; + let reload: ReturnType; + let draftStore: typeof MultiplayerDraftStore; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv("DEV", false); + ({ useMultiplayerDraftStore: draftStore } = await import("../../stores/multiplayerDraftStore")); + controllerChange = null; + updateSW = vi.fn().mockResolvedValue(undefined); + mocks.registerSW.mockReturnValue(updateSW); + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: { + controller: {}, + addEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "controllerchange") controllerChange = listener; + }), + }, + }); + reload = vi.fn(); + Object.defineProperty(window.location, "reload", { configurable: true, value: reload }); + draftStore.setState({ role: "host", phase: "deckbuilding" }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + draftStore.setState({ role: null, phase: "idle" }); + }); + + async function register(): Promise { + const { registerServiceWorker } = await import("../registerServiceWorker"); + registerServiceWorker(); + return mocks.registerSW.mock.calls[0][0] as ServiceWorkerOptions; + } + + it("defers onNeedRefresh through live deckbuilding and applies it once the pod ends", async () => { + const options = await register(); + + options.onNeedRefresh(); + + expect(updateSW).not.toHaveBeenCalled(); + draftStore.setState({ phase: "complete" }); + + expect(updateSW).toHaveBeenCalledTimes(1); + expect(updateSW).toHaveBeenCalledWith(true); + }); + + it("defers controllerchange during a live pod and reloads exactly once after release", async () => { + await register(); + expect(controllerChange).not.toBeNull(); + + controllerChange?.(); + + expect(reload).not.toHaveBeenCalled(); + draftStore.setState({ phase: "complete" }); + + expect(reload).toHaveBeenCalledTimes(1); + controllerChange?.(); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/pwa/__tests__/tauriUpdater.test.ts b/client/src/pwa/__tests__/tauriUpdater.test.ts index c09218f178..f6b96b3454 100644 --- a/client/src/pwa/__tests__/tauriUpdater.test.ts +++ b/client/src/pwa/__tests__/tauriUpdater.test.ts @@ -1,45 +1,99 @@ -import { beforeEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { checkMock, isDesktopTauriMock } = vi.hoisted(() => ({ - checkMock: vi.fn(), - isDesktopTauriMock: vi.fn(), +import type { useMultiplayerDraftStore as MultiplayerDraftStore } from "../../stores/multiplayerDraftStore"; + +const mocks = vi.hoisted(() => ({ + isDesktopTauri: vi.fn(), + check: vi.fn(), + relaunch: vi.fn().mockResolvedValue(undefined), + markPendingAutoUpdate: vi.fn(), + claimUpdateStatus: vi.fn(() => true), + clearUpdateError: vi.fn(), + pushUpdateDebug: vi.fn(), + releaseUpdateStatus: vi.fn(), + setDownloadProgress: vi.fn(), + setUpdateError: vi.fn(), + setUpdateStatus: vi.fn(), })); -vi.mock("../../services/platform", () => ({ isDesktopTauri: isDesktopTauriMock })); -vi.mock("@tauri-apps/plugin-updater", () => ({ check: checkMock })); +vi.mock("../../services/platform", () => ({ isDesktopTauri: mocks.isDesktopTauri })); +vi.mock("@tauri-apps/plugin-updater", () => ({ check: mocks.check })); +vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: mocks.relaunch })); +vi.mock("../updateMarker", () => ({ markPendingAutoUpdate: mocks.markPendingAutoUpdate })); +vi.mock("../updateStatus", () => ({ + claimUpdateStatus: mocks.claimUpdateStatus, + clearUpdateError: mocks.clearUpdateError, + pushUpdateDebug: mocks.pushUpdateDebug, + releaseUpdateStatus: mocks.releaseUpdateStatus, + setDownloadProgress: mocks.setDownloadProgress, + setUpdateError: mocks.setUpdateError, + setUpdateStatus: mocks.setUpdateStatus, +})); -beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - vi.unstubAllEnvs(); -}); +describe("registerTauriUpdater", () => { + const downloadAndInstall = vi.fn().mockResolvedValue(undefined); + let draftStore: typeof MultiplayerDraftStore; -it("does not import or invoke the updater on Android/iOS", async () => { - vi.stubEnv("DEV", false); - isDesktopTauriMock.mockReturnValue(false); - const updater = await import("../tauriUpdater"); - updater.registerTauriUpdater(); - expect(updater.checkForTauriUpdate()).toBe(false); - expect(checkMock).not.toHaveBeenCalled(); -}); + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv("DEV", false); + ({ useMultiplayerDraftStore: draftStore } = await import("../../stores/multiplayerDraftStore")); + downloadAndInstall.mockResolvedValue(undefined); + mocks.check.mockResolvedValue({ + version: "0.64.0", + currentVersion: "0.63.0", + downloadAndInstall, + }); + }); -it("retains desktop updater reachability", async () => { - vi.stubEnv("DEV", false); - isDesktopTauriMock.mockReturnValue(true); - checkMock.mockResolvedValue(null); - const updater = await import("../tauriUpdater"); - updater.registerTauriUpdater(); - await vi.waitFor(() => expect(checkMock).toHaveBeenCalledOnce()); -}); + afterEach(() => { + draftStore.setState({ role: null, phase: "idle" }); + }); + + it("does not import or invoke the updater on Android/iOS", async () => { + mocks.isDesktopTauri.mockReturnValue(false); + const updater = await import("../tauriUpdater"); + updater.registerTauriUpdater(); + expect(updater.checkForTauriUpdate()).toBe(false); + expect(mocks.check).not.toHaveBeenCalled(); + }); + + it("retains desktop updater reachability", async () => { + mocks.isDesktopTauri.mockReturnValue(true); + mocks.check.mockResolvedValue(null); + const updater = await import("../tauriUpdater"); + updater.registerTauriUpdater(); + await vi.waitFor(() => expect(mocks.check).toHaveBeenCalledOnce()); + }); + + it("does not self-update a dev build", async () => { + vi.stubEnv("DEV", true); + mocks.isDesktopTauri.mockReturnValue(true); + const updater = await import("../tauriUpdater"); + updater.registerTauriUpdater(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mocks.check).not.toHaveBeenCalled(); + expect(updater.checkForTauriUpdate()).toBe(false); + }); + + it("defers a detected update until the live pod ends and does not start a parallel install", async () => { + mocks.isDesktopTauri.mockReturnValue(true); + draftStore.setState({ role: "guest", phase: "pairing" }); + const { checkForTauriUpdate, registerTauriUpdater } = await import("../tauriUpdater"); + registerTauriUpdater(); + + await vi.waitFor(() => expect(mocks.check).toHaveBeenCalledTimes(1)); + expect(downloadAndInstall).not.toHaveBeenCalled(); + + expect(checkForTauriUpdate()).toBe(true); + expect(checkForTauriUpdate()).toBe(true); + expect(mocks.check).toHaveBeenCalledTimes(1); + + draftStore.setState({ phase: "complete" }); -it("does not self-update a dev build", async () => { - vi.stubEnv("DEV", true); - isDesktopTauriMock.mockReturnValue(true); - checkMock.mockResolvedValue(null); - const updater = await import("../tauriUpdater"); - updater.registerTauriUpdater(); - // An unguarded registration awaits a dynamic import before it calls `check()`. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(checkMock).not.toHaveBeenCalled(); - expect(updater.checkForTauriUpdate()).toBe(false); + await vi.waitFor(() => expect(downloadAndInstall).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(mocks.relaunch).toHaveBeenCalledTimes(1)); + }); }); diff --git a/client/src/pwa/chunkReloadHandler.ts b/client/src/pwa/chunkReloadHandler.ts index a84d80245f..e6162e9295 100644 --- a/client/src/pwa/chunkReloadHandler.ts +++ b/client/src/pwa/chunkReloadHandler.ts @@ -1,4 +1,4 @@ -import { isMultiplayerGameLive, whenMultiplayerGameEnds } from "./multiplayerGuard"; +import { deferUntilMultiplayerSessionEnds, isMultiplayerGameLive } from "./multiplayerGuard"; import { claimUpdateStatus, pushUpdateDebug, @@ -22,7 +22,7 @@ import { flushNow, trackEvent } from "../services/telemetry"; * Multiplayer safety: a chunk-load failure mid-lobby or mid-game would * still reload and drop the P2P/WebSocket connection. We mirror the * service-worker updater's deferral by parking the reload until - * `whenMultiplayerGameEnds()` fires, so the running game isn't killed for + * `deferUntilMultiplayerSessionEnds()` releases it, so the running game isn't killed for * everyone else in it. The user lives with a degraded UI for the rest of * the game (one missing lazy route), but the game itself stays alive and * the reconnect-on-end story remains intact. @@ -43,7 +43,7 @@ import { flushNow, trackEvent } from "../services/telemetry"; */ let isInstalled = false; let deferredReload: (() => void) | null = null; -let deferredReloadUnsub: (() => void) | null = null; +let deferredReloadCancel: (() => void) | null = null; /** Reloads allowed per failing chunk within {@link RELOAD_GUARD_WINDOW_MS}. */ const RELOAD_GUARD_MAX = 2; @@ -190,12 +190,14 @@ export function installChunkReloadHandler(): void { // we already have a reload queued — replacing it changes nothing. if (deferredReload) return; deferredReload = doReload; - deferredReloadUnsub = whenMultiplayerGameEnds(() => { + const scheduledReload = deferUntilMultiplayerSessionEnds(() => { const fn = deferredReload; deferredReload = null; - deferredReloadUnsub = null; + deferredReloadCancel = null; fn?.(); - }); + }, "reload"); + if (!scheduledReload.deferred || deferredReload === null) return; + deferredReloadCancel = scheduledReload.cancel; return; } @@ -205,8 +207,8 @@ export function installChunkReloadHandler(): void { window.addEventListener( "beforeunload", () => { - deferredReloadUnsub?.(); - deferredReloadUnsub = null; + deferredReloadCancel?.(); + deferredReloadCancel = null; deferredReload = null; releaseUpdateStatus("chunk"); }, diff --git a/client/src/pwa/multiplayerGuard.ts b/client/src/pwa/multiplayerGuard.ts index 965b841ad9..cf432b6e79 100644 --- a/client/src/pwa/multiplayerGuard.ts +++ b/client/src/pwa/multiplayerGuard.ts @@ -1,4 +1,25 @@ import { isAuthorityRemote, useGameStore } from "../stores/gameStore"; +import { useMultiplayerDraftStore, type MultiplayerDraftPhase } from "../stores/multiplayerDraftStore"; + +const LIVE_DRAFT_PHASES: ReadonlySet = new Set([ + "connecting", + "lobby", + "drafting", + "deckbuilding", + "pairing", + "matchInProgress", + "roundComplete", +]); + +function isRemoteGameLive(): boolean { + const { gameMode, gameState, adapter } = useGameStore.getState(); + return isAuthorityRemote(gameMode) && adapter !== null && gameState?.waiting_for?.type !== "GameOver"; +} + +function isDraftPodLive(): boolean { + const { role, phase } = useMultiplayerDraftStore.getState(); + return role !== null && LIVE_DRAFT_PHASES.has(phase); +} /** * True when a multiplayer game is live in this tab and reloading would @@ -13,43 +34,114 @@ import { isAuthorityRemote, useGameStore } from "../stores/gameStore"; * (desktop) to defer activation/relaunch until the game ends. */ export function isMultiplayerGameLive(): boolean { - const { gameMode, gameState, adapter } = useGameStore.getState(); - if (!isAuthorityRemote(gameMode)) return false; - if (!adapter) return false; - if (gameState?.waiting_for?.type === "GameOver") return false; - return true; + return isRemoteGameLive() || isDraftPodLive(); +} + +export type DeferredMultiplayerActionKind = "activation" | "reload" | "install" | "observer"; + +interface QueuedMultiplayerAction { + id: number; + kind: DeferredMultiplayerActionKind; + action: () => void; +} + +const ACTION_PRIORITY: Record = { + activation: 0, + reload: 1, + install: 2, + observer: 3, +}; + +let nextActionId = 0; +let queuedActions: QueuedMultiplayerAction[] = []; +let gameUnsubscribe: (() => void) | null = null; +let draftUnsubscribe: (() => void) | null = null; + +function stopLivenessSubscription(): void { + gameUnsubscribe?.(); + draftUnsubscribe?.(); + gameUnsubscribe = null; + draftUnsubscribe = null; +} + +function releaseQueuedActions(): void { + if (isMultiplayerGameLive() || queuedActions.length === 0) return; + + // Stop watching before actions run: an activation can synchronously trigger + // a controller-change event, which must either run immediately (the pod is + // over) or create a fresh wait for a newly-live session. + stopLivenessSubscription(); + const ready = queuedActions + .sort((left, right) => ACTION_PRIORITY[left.kind] - ACTION_PRIORITY[right.kind] || left.id - right.id); + queuedActions = []; + for (const queued of ready) queued.action(); +} + +function ensureLivenessSubscription(): void { + if (gameUnsubscribe || draftUnsubscribe) return; + + // Both stores use plain Zustand subscriptions. Re-evaluating the combined + // predicate prevents a game-to-pod handoff from applying an update between + // the two lifecycles. + const recheck = () => releaseQueuedActions(); + gameUnsubscribe = useGameStore.subscribe(recheck); + draftUnsubscribe = useMultiplayerDraftStore.subscribe(recheck); + + // Close the guard/check → subscribe TOCTOU window after *both* listeners + // exist. `releaseQueuedActions` tears both down before it invokes work. + releaseQueuedActions(); +} + +function queueUntilMultiplayerSessionEnds( + action: () => void, + kind: DeferredMultiplayerActionKind, +): { deferred: boolean; cancel: () => void } { + if (!isMultiplayerGameLive()) { + action(); + return { deferred: false, cancel: NOOP }; + } + + const id = nextActionId++; + queuedActions.push({ id, kind, action }); + ensureLivenessSubscription(); + + let active = queuedActions.some((queued) => queued.id === id); + const cancel = () => { + if (!active) return; + active = false; + queuedActions = queuedActions.filter((queued) => queued.id !== id); + if (queuedActions.length === 0) stopLivenessSubscription(); + }; + return { deferred: active, cancel }; } /** - * Register a one-shot callback that fires once `isMultiplayerGameLive()` - * transitions from true to false. Returns the unsubscribe function so - * callers can cancel if the deferred action is no longer needed. - * - * Selector-based subscribe so the listener only fires when the derived - * liveness boolean flips, not on every unrelated store mutation (actions, - * log entries, animation ticks). - * - * Immediate re-check after subscribe closes a TOCTOU window: the state - * may have transitioned out of "live" between the caller's guard and - * our subscribe, and Zustand only fires on *subsequent* changes. + * Register a one-shot callback for the end of every live remote game and + * draft-pod session. Kept for callers that only need an observer; update + * actions should use `deferUntilMultiplayerSessionEnds` with an explicit kind. */ export function whenMultiplayerGameEnds(callback: () => void): () => void { - let fired = false; - const fire = () => { - if (fired) return; - fired = true; - unsub(); - callback(); - }; - const unsub = useGameStore.subscribe( - (s) => { - if (!isAuthorityRemote(s.gameMode)) return false; - if (!s.adapter) return false; - if (s.gameState?.waiting_for?.type === "GameOver") return false; - return true; - }, - (live) => { if (!live) fire(); }, - ); - if (!isMultiplayerGameLive()) fire(); - return unsub; + return queueUntilMultiplayerSessionEnds(callback, "observer").cancel; +} + +export interface DeferredMultiplayerAction { + /** True when the action was parked behind a live remote game or draft pod. */ + readonly deferred: boolean; + /** Cancels a parked action. It is safe to call after the action has fired. */ + cancel(): void; +} + +const NOOP = () => {}; + +/** + * Run `action` immediately when no remote session is live, otherwise once + * the last live game or draft pod ends. The returned cancellation handle and + * the one-shot subscription form a single ownership unit, so consumers never + * retain a stale callback after it executes or is superseded. + */ +export function deferUntilMultiplayerSessionEnds( + action: () => void, + kind: DeferredMultiplayerActionKind = "reload", +): DeferredMultiplayerAction { + return queueUntilMultiplayerSessionEnds(action, kind); } diff --git a/client/src/pwa/registerServiceWorker.ts b/client/src/pwa/registerServiceWorker.ts index c3217a76b2..7e04b6d20b 100644 --- a/client/src/pwa/registerServiceWorker.ts +++ b/client/src/pwa/registerServiceWorker.ts @@ -1,6 +1,6 @@ import { registerSW } from "virtual:pwa-register"; import { isBundledTauriOrigin } from "../services/platform"; -import { isMultiplayerGameLive, whenMultiplayerGameEnds } from "./multiplayerGuard"; +import { deferUntilMultiplayerSessionEnds } from "./multiplayerGuard"; import { claimServiceWorkerReload, markPendingAutoUpdate } from "./updateMarker"; import { claimUpdateStatus, @@ -33,7 +33,7 @@ let ownsUpdateStatus = false; * is live. Applied when the game ends. Null when nothing is deferred. */ let deferredUpdate: (() => void) | null = null; -let deferredUpdateUnsub: (() => void) | null = null; +let deferredUpdateCancel: (() => void) | null = null; /** * Deferred reload closure captured at `controllerchange` time when a MP @@ -41,7 +41,7 @@ let deferredUpdateUnsub: (() => void) | null = null; * activation of a new SW while this tab is still mid-game. */ let deferredReload: (() => void) | null = null; -let deferredReloadUnsub: (() => void) | null = null; +let deferredReloadCancel: (() => void) | null = null; function formatError(error: unknown): string { if (error instanceof Error && error.message) return error.message; @@ -204,7 +204,16 @@ export function registerServiceWorker() { // Defer reload until a live multiplayer game ends — reloading mid-game // tears down the P2P DataChannel / WebSocket, forcing the opponent // into the disconnect grace window and breaking continuity. - if (isMultiplayerGameLive()) { + deferredReloadCancel?.(); + deferredReload = doReload; + const scheduledReload = deferUntilMultiplayerSessionEnds(() => { + const fn = deferredReload; + deferredReload = null; + deferredReloadCancel = null; + fn?.(); + }, "reload"); + + if (scheduledReload.deferred && deferredReload !== null) { pushUpdateDebug( "Service worker controller changed during multiplayer game; deferring reload until game ends.", "warn", @@ -212,18 +221,9 @@ export function registerServiceWorker() { if (claimServiceWorkerUpdateStatus()) { setServiceWorkerUpdateStatus("deferred"); } - deferredReload = doReload; - deferredReloadUnsub = whenMultiplayerGameEnds(() => { - pushUpdateDebug("Multiplayer game ended; applying deferred reload."); - const fn = deferredReload; - deferredReload = null; - deferredReloadUnsub = null; - fn?.(); - }); + deferredReloadCancel = scheduledReload.cancel; return; } - - doReload(); }); const updateSW = registerSW({ @@ -249,7 +249,16 @@ export function registerServiceWorker() { // `updateSW(true)` triggers skipWaiting → controllerchange → reload, // which would drop the user's live connection mid-game. Leave the new // SW parked in "installed" until the game ends, then activate. - if (isMultiplayerGameLive()) { + deferredUpdateCancel?.(); + deferredUpdate = applyUpdate; + const scheduledUpdate = deferUntilMultiplayerSessionEnds(() => { + const fn = deferredUpdate; + deferredUpdate = null; + deferredUpdateCancel = null; + fn?.(); + }, "activation"); + + if (scheduledUpdate.deferred && deferredUpdate !== null) { pushUpdateDebug( "Update ready during multiplayer game; deferring activation until game ends.", "warn", @@ -262,19 +271,9 @@ export function registerServiceWorker() { setServiceWorkerDownloadProgress(0); setServiceWorkerUpdateStatus("deferred"); } - deferredUpdate = applyUpdate; - deferredUpdateUnsub?.(); - deferredUpdateUnsub = whenMultiplayerGameEnds(() => { - pushUpdateDebug("Multiplayer game ended; applying deferred update."); - const fn = deferredUpdate; - deferredUpdate = null; - deferredUpdateUnsub = null; - fn?.(); - }); + deferredUpdateCancel = scheduledUpdate.cancel; return; } - - applyUpdate(); }, onRegisteredSW(swUrl, swRegistration) { if (!swRegistration) return; @@ -379,8 +378,8 @@ export function registerServiceWorker() { clearActivationTimeout(); document.removeEventListener("visibilitychange", handleVisibilityChange); manualCheckForUpdate = null; - deferredUpdateUnsub?.(); - deferredReloadUnsub?.(); + deferredUpdateCancel?.(); + deferredReloadCancel?.(); releaseUpdateStatus("serviceWorker"); ownsUpdateStatus = false; }, diff --git a/client/src/pwa/tauriUpdater.ts b/client/src/pwa/tauriUpdater.ts index 28cc1554ea..fda83225d3 100644 --- a/client/src/pwa/tauriUpdater.ts +++ b/client/src/pwa/tauriUpdater.ts @@ -9,7 +9,7 @@ import type { Update } from "@tauri-apps/plugin-updater"; import { isDesktopTauri } from "../services/platform"; -import { isMultiplayerGameLive, whenMultiplayerGameEnds } from "./multiplayerGuard"; +import { deferUntilMultiplayerSessionEnds, isMultiplayerGameLive } from "./multiplayerGuard"; import { markPendingAutoUpdate } from "./updateMarker"; import { claimUpdateStatus, @@ -35,7 +35,9 @@ let inFlight: Promise | null = null; * the bundle is already swapped in by the first). * - Manual `↻` clicks from triggering parallel installs during the wait. */ -let deferredUnsub: (() => void) | null = null; +let deferredCancel: (() => void) | null = null; +let deferredUpdate: Update | null = null; +let deferredInstall: Promise | null = null; function setTauriUpdateStatus(next: "checking" | "downloading" | "activating" | "deferred", ownsStatus: boolean): void { if (ownsStatus) setUpdateStatus(next); @@ -98,7 +100,7 @@ async function runInstall(update: Update, ownsStatus: boolean): Promise { } async function performCheck(reason: "startup" | "interval" | "manual"): Promise { - if (deferredUnsub) { + if (deferredCancel || deferredInstall) { pushUpdateDebug( `Tauri update check (${reason}) skipped — install already deferred for end of multiplayer game.`, ); @@ -147,12 +149,22 @@ async function performCheck(reason: "startup" | "interval" | "manual"): Promise< "warn", ); setTauriUpdateStatus("deferred", ownsStatus); - const pending = update; - deferredUnsub = whenMultiplayerGameEnds(() => { - deferredUnsub = null; + deferredUpdate = update; + const scheduledInstall = deferUntilMultiplayerSessionEnds(() => { + const pending = deferredUpdate; + deferredUpdate = null; + deferredCancel = null; + if (!pending) return; pushUpdateDebug("Multiplayer game ended; applying deferred Tauri update."); - void runInstall(pending, ownsStatus); - }); + deferredInstall = runInstall(pending, ownsStatus).finally(() => { + deferredInstall = null; + }); + }, "install"); + if (scheduledInstall.deferred && deferredUpdate !== null) { + deferredCancel = scheduledInstall.cancel; + return; + } + await deferredInstall; return; } @@ -209,8 +221,9 @@ export function registerTauriUpdater(): void { () => { window.clearInterval(intervalId); manualCheck = null; - deferredUnsub?.(); - deferredUnsub = null; + deferredCancel?.(); + deferredCancel = null; + deferredUpdate = null; releaseUpdateStatus("tauri"); }, { once: true }, diff --git a/client/src/services/__tests__/draftPersistence.test.ts b/client/src/services/__tests__/draftPersistence.test.ts index c93e8d177f..9ab1e53fb9 100644 --- a/client/src/services/__tests__/draftPersistence.test.ts +++ b/client/src/services/__tests__/draftPersistence.test.ts @@ -17,16 +17,26 @@ vi.mock("idb-keyval", () => ({ import { clearActiveDraftPod, + clearActiveDraftPodIfCurrent, + clearActiveDraftGuest, + clearDraftGuestRecovery, clearDraftGuestSession, + clearDraftDeckSubmission, clearDraftHostSession, loadActiveDraftPod, + loadActiveDraftGuest, + inspectActiveDraftPod, loadDraftGuestSession, + loadDraftDeckSubmission, loadDraftHostSession, loadDraftIntergameCommands, loadDraftSettlementOutbox, saveActiveDraftPod, + saveActiveDraftGuest, saveDraftGuestSession, + saveDraftDeckSubmission, saveDraftHostSession, + persistedDraftHostSessionState, saveDraftIntergameCommands, saveDraftSettlementOutbox, } from "../draftPersistence"; @@ -83,6 +93,14 @@ describe("draftPersistence", () => { expect(loaded!.draftStarted).toBe(false); }); + it("accepts a live pre-draft lobby before it has generated a draft code", async () => { + const lobby = { ...testSession, draftStarted: false, draftSessionJson: null, draftCode: "" }; + await saveDraftHostSession(lobby.persistenceId, lobby); + + await expect(loadDraftHostSession(lobby.persistenceId)).resolves.toEqual(lobby); + expect(persistedDraftHostSessionState(lobby)).toBe("live"); + }); + it("returns null for legacy snapshots missing poolInput (C6 shape guard)", async () => { // Simulate a pre-#1253 snapshot with the flat setPoolJson field. const legacy = { @@ -123,6 +141,27 @@ describe("draftPersistence", () => { expect(loaded?.poolInput.type).toBe("Cube"); }); + it("rejects persisted Set and Cube snapshots missing data used by resume", async () => { + mockStore.set("phase-draft-host:bad-set", { + ...testSession, + poolInput: { type: "Set", data: {} }, + }); + mockStore.set("phase-draft-host:bad-cube", { + ...testSession, + poolInput: { + type: "Cube", + data: { + cube_list_text: "1 Lightning Bolt", + cube_name: "Cube", + cube_draft_settings: { pod_size: 8 }, + }, + }, + }); + + await expect(loadDraftHostSession("bad-set")).resolves.toBeNull(); + await expect(loadDraftHostSession("bad-cube")).resolves.toBeNull(); + }); + it("saves and loads active host resume metadata", () => { saveActiveDraftPod({ id: "test-draft-1", @@ -162,6 +201,54 @@ describe("draftPersistence", () => { expect(loadActiveDraftPod()).toBeNull(); }); + + it("does not clear metadata replaced while an older resume was loading", () => { + const older = { + id: "test-draft-1", roomCode: "ABCDE", updatedAt: Date.now(), + }; + saveActiveDraftPod({ + id: older.id, + roomCode: older.roomCode, + kind: "Premier", + podSize: 8, + hostDisplayName: "Alice", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + phase: "drafting", + pickCount: 1, + updatedAt: older.updatedAt, + }); + saveActiveDraftPod({ + id: "new-draft", + roomCode: "FGHJK", + kind: "Premier", + podSize: 8, + hostDisplayName: "Alice", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + phase: "lobby", + pickCount: 0, + updatedAt: older.updatedAt + 1, + }); + + clearActiveDraftPodIfCurrent(older); + + expect(loadActiveDraftPod()).toMatchObject({ id: "new-draft", roomCode: "FGHJK" }); + }); + + it("classifies only live snapshot states as host-resumable", () => { + expect(persistedDraftHostSessionState({ ...testSession, draftStarted: false, draftSessionJson: null })).toBe("live"); + expect(persistedDraftHostSessionState(testSession)).toBe("live"); + expect(persistedDraftHostSessionState({ ...testSession, draftSessionJson: '{"status":"Complete"}' })).toBe("terminal"); + expect(persistedDraftHostSessionState({ ...testSession, draftSessionJson: '{"status":"Abandoned"}' })).toBe("invalid"); + expect(persistedDraftHostSessionState({ ...testSession, draftSessionJson: "not json" })).toBe("invalid"); + }); + + it("reports malformed active metadata as invalid", () => { + localStorage.setItem("phase-active-draft-pod", JSON.stringify({ id: "draft", roomCode: "lower", updatedAt: 1 })); + + expect(inspectActiveDraftPod()).toMatchObject({ type: "invalid" }); + }); }); it("persists a held intergame command until its receipt", async () => { @@ -193,11 +280,30 @@ describe("draftPersistence", () => { }); describe("guest session", () => { + it("canonicalizes a deck submission room code for replay and removal", async () => { + await saveDraftDeckSubmission("phase2-HOST1", { + draftCode: "draft-xyz", + roomCode: " abcde ", + draftToken: "token-abc", + submissionId: "submission-1", + mainDeck: ["Island"], + }); + + await expect(loadDraftDeckSubmission("phase2-HOST1", { + roomCode: "abcde", + draftToken: "token-abc", + })).resolves.toMatchObject({ roomCode: "ABCDE" }); + await clearDraftDeckSubmission("phase2-HOST1", "submission-1"); + await expect(loadDraftDeckSubmission("phase2-HOST1")).resolves.toBeNull(); + }); + it("saves and loads a guest session", async () => { await saveDraftGuestSession("phase2-HOST1", { draftToken: "token-abc", seatIndex: 3, draftCode: "draft-xyz", + roomCode: "ABCDE", + displayName: "Alice", }); const loaded = await loadDraftGuestSession("phase2-HOST1"); @@ -206,6 +312,8 @@ describe("draftPersistence", () => { expect(loaded!.seatIndex).toBe(3); expect(loaded!.draftCode).toBe("draft-xyz"); expect(loaded!.hostPeerId).toBe("phase2-HOST1"); + expect(loaded!.roomCode).toBe("ABCDE"); + expect(loaded!.displayName).toBe("Alice"); }); it("returns null for expired session", async () => { @@ -214,6 +322,8 @@ describe("draftPersistence", () => { draftToken: "old-token", seatIndex: 1, draftCode: "draft-old", + roomCode: "ABCDE", + displayName: "Alice", }); // Manually patch the stored timestamp to simulate expiry @@ -231,15 +341,76 @@ describe("draftPersistence", () => { expect(loaded).toBeNull(); }); + it("refuses a token record stored under a different host peer key", async () => { + mockStore.set("phase-draft-guest:phase2-EXPECTED", { + hostPeerId: "phase2-OTHER", + draftToken: "cross-key-token", + seatIndex: 1, + draftCode: "draft-xyz", + roomCode: "ABCDE", + displayName: "Alice", + timestamp: Date.now(), + }); + + await expect(loadDraftGuestSession("phase2-EXPECTED", { + roomCode: "ABCDE", + displayName: "Alice", + })).resolves.toBeNull(); + }); + it("clears a guest session", async () => { await saveDraftGuestSession("phase2-CLEAR", { draftToken: "token-clear", seatIndex: 0, draftCode: "draft-clear", + roomCode: "ABCDE", + displayName: "Alice", }); await clearDraftGuestSession("phase2-CLEAR"); const loaded = await loadDraftGuestSession("phase2-CLEAR"); expect(loaded).toBeNull(); }); + + it("uses a non-secret room-code locator to bind the IndexedDB capability", async () => { + await saveDraftGuestSession("phase2-HOST1", { + draftToken: "token-abc", + seatIndex: 3, + draftCode: "draft-xyz", + roomCode: "ABCDE", + displayName: "Alice", + }); + saveActiveDraftGuest({ roomCode: "ABCDE", displayName: "Alice", hostPeerId: "phase2-HOST1" }); + + expect(loadActiveDraftGuest()).toMatchObject({ + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-HOST1", + }); + expect(localStorage.getItem("phase-active-draft-guest")).not.toContain("token-abc"); + await expect(loadDraftGuestSession("phase2-HOST1", { + roomCode: "ABCDE", + displayName: "Alice", + })).resolves.toMatchObject({ draftToken: "token-abc" }); + await expect(loadDraftGuestSession("phase2-HOST1", { + roomCode: "ABCDE", + displayName: "Mallory", + })).resolves.toBeNull(); + }); + + it("clears both recovery records for an explicit terminal removal", async () => { + await saveDraftGuestSession("phase2-HOST1", { + draftToken: "token-abc", + seatIndex: 3, + draftCode: "draft-xyz", + roomCode: "ABCDE", + displayName: "Alice", + }); + saveActiveDraftGuest({ roomCode: "ABCDE", displayName: "Alice", hostPeerId: "phase2-HOST1" }); + + await clearDraftGuestRecovery("phase2-HOST1"); + expect(loadActiveDraftGuest()).toBeNull(); + await expect(loadDraftGuestSession("phase2-HOST1")).resolves.toBeNull(); + clearActiveDraftGuest(); + }); }); }); diff --git a/client/src/services/draftPersistence.ts b/client/src/services/draftPersistence.ts index 8e95463eb7..d0d4a10e9b 100644 --- a/client/src/services/draftPersistence.ts +++ b/client/src/services/draftPersistence.ts @@ -12,9 +12,10 @@ import { createStore, del, get, set } from "idb-keyval"; -import type { DraftKind, PoolInput } from "../adapter/draft-adapter"; +import type { DraftKind, DraftStatus, PoolInput } from "../adapter/draft-adapter"; import type { DraftMatchBinding, DraftMatchLaunch, DraftMatchSettlement } from "../network/draftProtocol"; -import { ACTIVE_DRAFT_POD_KEY } from "../constants/storage"; +import { parseRoomCode } from "../network/connection"; +import { ACTIVE_DRAFT_GUEST_KEY, ACTIVE_DRAFT_POD_KEY } from "../constants/storage"; import type { DraftIntergameCommand } from "./intergameCommandLedger"; export type { PoolInput } from "../adapter/draft-adapter"; @@ -41,8 +42,12 @@ export interface PersistedDraftHostSession { seatNames: Record; /** Tokens that were kicked — refused on reconnect. */ kickedTokens: string[]; + /** Seats whose reconnect grace elapsed while this host owned the pod. */ + expiredDisconnectedSeats?: number[]; /** Whether StartDraft has been applied. */ draftStarted: boolean; + /** Host intent is separate from effective disconnect-induced pausing. */ + manualPause?: boolean; /** Draft code for display/identification. */ draftCode: string; /** Serialized DraftSession JSON from draft-wasm. Null if draft hasn't started. */ @@ -77,6 +82,12 @@ export interface PersistedDraftHostSession { /** Full immutable launch records let a recovered host issue timeout commands * through the same launch-bound intergame ledger. */ matchLaunches?: Array<{ matchId: string; seat: number; launch: DraftMatchLaunch }>; + /** Durable idempotency ledger for participant deck-submission retries. */ + deckSubmissionReceipts?: Array<{ + seat: number; + submissionId: string; + payloadFingerprint: string; + }>; } /** @@ -90,6 +101,31 @@ export interface PersistedDraftGuestSession { draftToken: string; seatIndex: number; draftCode: string; + /** Bound to the non-secret recovery pointer before reconnect is attempted. */ + roomCode: string; + displayName: string; + timestamp: number; +} + +/** A participant-owned deck submission retained until the host acknowledges it. */ +export interface PersistedDraftDeckSubmission { + hostPeerId: string; + /** Display identity captured at submit time; it can change from lobby pending. */ + draftCode: string; + /** Stable pod route, unlike the draft code generated at draft start. */ + roomCode: string; + /** Capability identity prevents reusing an outbox after a seat is reissued. */ + draftToken: string; + submissionId: string; + mainDeck: string[]; + timestamp: number; +} + +/** A non-secret, validated route back to a guest's persisted capability. */ +export interface ActiveDraftGuestMeta { + roomCode: string; + displayName: string; + hostPeerId: string; timestamp: number; } @@ -114,12 +150,39 @@ export interface ActiveDraftPodMeta { updatedAt: number; } +/** Stable identity captured before an asynchronous resume attempt. */ +export interface ActiveDraftPodMetaCapture { + id: string; + roomCode: string; + updatedAt: number; +} + +export type ActiveDraftPodLoadResult = + | { type: "absent" } + | { type: "invalid"; capture: ActiveDraftPodMetaCapture | null } + | { type: "present"; meta: ActiveDraftPodMeta; capture: ActiveDraftPodMetaCapture }; + +export type PersistedDraftHostSessionState = "live" | "terminal" | "invalid"; + +const PERSISTED_DRAFT_STATUS: Record = { + Lobby: "live", + Drafting: "live", + Paused: "live", + Deckbuilding: "live", + Pairing: "live", + MatchInProgress: "live", + RoundComplete: "live", + Complete: "terminal", + Abandoned: "invalid", +}; + // ── Store ────────────────────────────────────────────────────────────── const DRAFT_HOST_PREFIX = "phase-draft-host:"; const DRAFT_GUEST_PREFIX = "phase-draft-guest:"; const DRAFT_SETTLEMENT_PREFIX = "phase-draft-settlement:"; const DRAFT_INTERGAME_PREFIX = "phase-draft-intergame:"; +const DRAFT_DECK_SUBMISSION_PREFIX = "phase-draft-deck-submission:"; const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; /** Guest token TTL — 4 hours matches the game session TTL. */ const GUEST_SESSION_TTL_MS = 4 * 60 * 60 * 1000; @@ -143,6 +206,7 @@ export async function saveDraftHostSession( await set(DRAFT_HOST_PREFIX + id, session, getDraftStore()); } catch (err) { console.warn("[saveDraftHostSession] IDB write failed:", err); + throw err; } } @@ -155,15 +219,7 @@ export async function loadDraftHostSession( getDraftStore(), ); if (!s) return null; - // C6 shape guard: legacy snapshots (pre-#1253) carried a flat - // `setPoolJson: string` field instead of the PoolInput discriminated - // union. Discriminate on the new shape; reject anything that doesn't - // self-identify as Set or Cube so the resume path falls back to - // "no draft pod to resume" instead of crashing on `persisted.poolInput.data`. - if (s.poolInput?.type !== "Set" && s.poolInput?.type !== "Cube") { - return null; - } - return s; + return isPersistedDraftHostSession(s) ? s : null; } catch { return null; } @@ -182,58 +238,309 @@ export function saveActiveDraftPod(meta: ActiveDraftPodMeta): void { } export function loadActiveDraftPod(): ActiveDraftPodMeta | null { + const result = inspectActiveDraftPod(); + return result.type === "present" ? result.meta : null; +} + +/** + * Reads active-host metadata without mutating it. Resume owns deletion because + * the record can change while IndexedDB is being read. + */ +export function inspectActiveDraftPod(): ActiveDraftPodLoadResult { try { const raw = localStorage.getItem(ACTIVE_DRAFT_POD_KEY); + if (!raw) return { type: "absent" }; + const value: unknown = JSON.parse(raw); + const capture = activeDraftPodCapture(value); + if (!isActiveDraftPodMeta(value) || Date.now() - value.updatedAt > HOST_SESSION_TTL_MS) { + return { type: "invalid", capture }; + } + return { type: "present", meta: value, capture: activeDraftPodCapture(value)! }; + } catch { + return { type: "invalid", capture: null }; + } +} + +export function clearActiveDraftPod(): void { + localStorage.removeItem(ACTIVE_DRAFT_POD_KEY); +} + +// ── Active Guest Meta ───────────────────────────────────────────────── + +/** + * The room code and display name are enough to reconnect, but never confer a + * seat. The opaque draft token stays in IndexedDB under the expected host id. + */ +export function saveActiveDraftGuest(meta: Omit): void { + const roomCode = parseRoomCode(meta.roomCode); + const displayName = meta.displayName.trim(); + if (!roomCode || !displayName || !meta.hostPeerId.trim()) return; + localStorage.setItem(ACTIVE_DRAFT_GUEST_KEY, JSON.stringify({ + roomCode, + displayName, + hostPeerId: meta.hostPeerId, + timestamp: Date.now(), + })); +} + +export function loadActiveDraftGuest(): ActiveDraftGuestMeta | null { + try { + const raw = localStorage.getItem(ACTIVE_DRAFT_GUEST_KEY); if (!raw) return null; - const meta = JSON.parse(raw) as ActiveDraftPodMeta; - if (Date.now() - meta.updatedAt > HOST_SESSION_TTL_MS) { - void clearDraftHostSession(meta.id); - clearActiveDraftPod(); + const value: unknown = JSON.parse(raw); + if (!isActiveDraftGuestMeta(value) || Date.now() - value.timestamp > GUEST_SESSION_TTL_MS) { + clearActiveDraftGuest(); return null; } - return meta; + return value; } catch { + clearActiveDraftGuest(); return null; } } -export function clearActiveDraftPod(): void { - localStorage.removeItem(ACTIVE_DRAFT_POD_KEY); +export function clearActiveDraftGuest(): void { + localStorage.removeItem(ACTIVE_DRAFT_GUEST_KEY); +} + +/** Do not erase a newer guest locator when an older adapter is disposed. */ +export function clearActiveDraftGuestForHost(hostPeerId: string): void { + const current = loadActiveDraftGuest(); + if (current?.hostPeerId === hostPeerId) clearActiveDraftGuest(); +} + +/** Clears stale metadata only when it is still the record this caller read. */ +export function clearActiveDraftPodIfCurrent(capture: ActiveDraftPodMetaCapture): void { + try { + const raw = localStorage.getItem(ACTIVE_DRAFT_POD_KEY); + if (!raw) return; + const current: unknown = JSON.parse(raw); + const currentCapture = activeDraftPodCapture(current); + if ( + currentCapture?.id === capture.id && + currentCapture.roomCode === capture.roomCode && + currentCapture.updatedAt === capture.updatedAt + ) { + clearActiveDraftPod(); + } + } catch { + // A malformed replacement is not evidence that this caller owns it. + } +} + +/** + * The host snapshot, not the UI progress cache, decides whether a pod can be + * resumed. A lobby has not created a WASM session yet, so its null JSON is a + * valid live state only before `draftStarted`. + */ +export function persistedDraftHostSessionState( + session: PersistedDraftHostSession, +): PersistedDraftHostSessionState { + if (typeof session.draftStarted !== "boolean") return "invalid"; + if (!session.draftStarted) { + return session.draftSessionJson === null ? "live" : "invalid"; + } + if (typeof session.draftSessionJson !== "string") return "invalid"; + + try { + const value: unknown = JSON.parse(session.draftSessionJson); + if (!isRecord(value) || typeof value.status !== "string") return "invalid"; + if (!(value.status in PERSISTED_DRAFT_STATUS)) return "invalid"; + return PERSISTED_DRAFT_STATUS[value.status as DraftStatus]; + } catch { + return "invalid"; + } +} + +function isActiveDraftPodMeta(value: unknown): value is ActiveDraftPodMeta { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && value.id.length > 0 && + isCanonicalRoomCode(value.roomCode) && + (value.kind === "Premier" || value.kind === "Traditional" || value.kind === "Sealed" || value.kind === "CommanderDraft") && + isPositiveInteger(value.podSize) && + typeof value.hostDisplayName === "string" && + (value.tournamentFormat === "Swiss" || value.tournamentFormat === "SingleElimination") && + (value.podPolicy === "Competitive" || value.podPolicy === "Casual") && + (value.phase === "lobby" || value.phase === "drafting" || value.phase === "deckbuilding" || + value.phase === "pairing" || value.phase === "matchInProgress" || value.phase === "complete") && + isNonnegativeInteger(value.pickCount) && + isPositiveFiniteNumber(value.updatedAt) + ); +} + +function isActiveDraftGuestMeta(value: unknown): value is ActiveDraftGuestMeta { + if (!isRecord(value)) return false; + return isCanonicalRoomCode(value.roomCode) + && isNonEmptyString(value.displayName) + && isNonEmptyString(value.hostPeerId) + && isPositiveFiniteNumber(value.timestamp); +} + +/** + * IndexedDB is a user-controlled boundary. Validate every persisted field the + * recovery path reads before rebuilding host configuration or passing the + * snapshot into the P2P host; a type assertion here would turn bad storage + * into a resume-time exception. + */ +function isPersistedDraftHostSession(value: unknown): value is PersistedDraftHostSession { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value.persistenceId) && + isCanonicalRoomCode(value.roomCode) && + isDraftKind(value.kind) && + isPositiveInteger(value.podSize) && + isNonEmptyString(value.hostDisplayName) && + (value.tournamentFormat === "Swiss" || value.tournamentFormat === "SingleElimination") && + (value.podPolicy === "Competitive" || value.podPolicy === "Casual") && + isSeatStringRecord(value.seatTokens) && + isSeatStringRecord(value.seatNames) && + Array.isArray(value.kickedTokens) && value.kickedTokens.every((token) => typeof token === "string") && + (value.expiredDisconnectedSeats === undefined || + (Array.isArray(value.expiredDisconnectedSeats) && value.expiredDisconnectedSeats.every(isNonnegativeInteger))) && + typeof value.draftStarted === "boolean" && + (value.manualPause === undefined || typeof value.manualPause === "boolean") && + // A live pre-draft lobby has no generated draft code yet; the host uses + // the room code as its seed fallback until StartDraft assigns one. + typeof value.draftCode === "string" && + (value.draftSessionJson === null || typeof value.draftSessionJson === "string") && + isPoolInput(value.poolInput) && + isOptionalRecordArray(value.matchBindings) && + isOptionalRecordArray(value.settlementOutbox) && + isOptionalRecordArray(value.settlementReceipts) && + isOptionalRecordArray(value.intergameCommands) && + isOptionalRecordArray(value.deckSubmissionReceipts) && + isOptionalRecordArray(value.bo3State) && + isOptionalRecordArray(value.launchDigests) && + isOptionalRecordArray(value.matchLaunches) + ); +} + +function isDraftKind(value: unknown): value is Exclude { + return value === "Premier" || value === "Traditional" || value === "Sealed" || value === "CommanderDraft"; +} + +function isPoolInput(value: unknown): value is PoolInput { + if (!isRecord(value) || !isRecord(value.data)) return false; + if (value.type === "Set") { + return typeof value.data.set_pool_json === "string" && isJsonRecord(value.data.set_pool_json); + } + if (value.type !== "Cube") return false; + const settings = value.data.cube_draft_settings; + return ( + isNonEmptyString(value.data.cube_list_text) && + isNonEmptyString(value.data.cube_name) && + isRecord(settings) && + isPositiveInteger(settings.pod_size) && + isPositiveInteger(settings.pack_count) && + isPositiveInteger(settings.cards_per_pack) && + isPositiveInteger(settings.min_deck_size) && + isRecord(settings.addable_cards) && + (settings.addable_cards.policy === "StandardBasics" || + settings.addable_cards.policy === "CustomOnly" || + settings.addable_cards.policy === "StandardBasicsPlusCustom") && + Array.isArray(settings.addable_cards.custom) && + settings.addable_cards.custom.every((card) => typeof card === "string") + ); +} + +function isSeatStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.entries(value).every(([seat, token]) => isPositiveSeat(seat) && typeof token === "string"); +} + +function isPositiveSeat(value: string): boolean { + const seat = Number(value); + return Number.isInteger(seat) && seat >= 0 && String(seat) === value; +} + +function isOptionalRecordArray(value: unknown): boolean { + return value === undefined || (Array.isArray(value) && value.every(isRecord)); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function isNonnegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function isJsonRecord(value: string): boolean { + try { + return isRecord(JSON.parse(value)); + } catch { + return false; + } +} + +function activeDraftPodCapture(value: unknown): ActiveDraftPodMetaCapture | null { + if (!isRecord(value) || typeof value.id !== "string" || !isCanonicalRoomCode(value.roomCode) || + !isPositiveFiniteNumber(value.updatedAt)) { + return null; + } + return { id: value.id, roomCode: value.roomCode, updatedAt: value.updatedAt }; +} + +function isCanonicalRoomCode(value: unknown): value is string { + return typeof value === "string" && parseRoomCode(value) === value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; } // ── Guest Persistence ────────────────────────────────────────────────── export async function saveDraftGuestSession( hostPeerId: string, - data: { draftToken: string; seatIndex: number; draftCode: string }, + data: { draftToken: string; seatIndex: number; draftCode: string; roomCode: string; displayName: string }, ): Promise { + const roomCode = parseRoomCode(data.roomCode); + const displayName = data.displayName.trim(); + if (!roomCode) throw new Error("Invalid draft room code"); + if (!displayName) throw new Error("Draft display name is required"); const session: PersistedDraftGuestSession = { hostPeerId, draftToken: data.draftToken, seatIndex: data.seatIndex, draftCode: data.draftCode, + roomCode, + displayName, timestamp: Date.now(), }; try { await set(DRAFT_GUEST_PREFIX + hostPeerId, session, getDraftStore()); } catch (err) { console.warn("[saveDraftGuestSession] IDB write failed:", err); + throw err; } } export async function loadDraftGuestSession( hostPeerId: string, + identity?: Pick, ): Promise { try { const session = await get( DRAFT_GUEST_PREFIX + hostPeerId, getDraftStore(), ); - if (!session) return null; + if (!session || !isPersistedDraftGuestSession(session) || session.hostPeerId !== hostPeerId) return null; if (Date.now() - session.timestamp > GUEST_SESSION_TTL_MS) { await clearDraftGuestSession(hostPeerId); return null; } + if (identity && (session.roomCode !== identity.roomCode || session.displayName !== identity.displayName)) { + return null; + } return session; } catch { return null; @@ -246,6 +553,84 @@ export async function clearDraftGuestSession(hostPeerId: string): Promise } catch { /* best-effort */ } } +/** Clears both halves of one guest's recovery identity. */ +export async function clearDraftGuestRecovery(hostPeerId: string): Promise { + await clearDraftGuestSession(hostPeerId); + clearActiveDraftGuestForHost(hostPeerId); +} + +/** + * Writes the deck command before it enters the transport. The key is owned by + * the participant (not the pod), so a host reload or a dropped DataChannel + * cannot turn an accepted deck into a lost UI submission. + */ +export async function saveDraftDeckSubmission( + hostPeerId: string, + submission: Omit, +): Promise { + const roomCode = parseRoomCode(submission.roomCode); + if (!roomCode) throw new Error("Invalid draft room code"); + const value: PersistedDraftDeckSubmission = { + hostPeerId, + draftCode: submission.draftCode, + roomCode, + draftToken: submission.draftToken, + submissionId: submission.submissionId, + mainDeck: [...submission.mainDeck], + timestamp: Date.now(), + }; + await set(`${DRAFT_DECK_SUBMISSION_PREFIX}${hostPeerId}`, value, getDraftStore()); +} + +export async function loadDraftDeckSubmission( + hostPeerId: string, + identity?: Pick, +): Promise { + try { + const roomCode = identity && parseRoomCode(identity.roomCode); + if (identity && !roomCode) return null; + const value = await get( + `${DRAFT_DECK_SUBMISSION_PREFIX}${hostPeerId}`, + getDraftStore(), + ); + if (!value || value.hostPeerId !== hostPeerId || !isNonEmptyString(value.draftCode) + || !isCanonicalRoomCode(value.roomCode) + || !isNonEmptyString(value.draftToken) || !isNonEmptyString(value.submissionId) + || !Array.isArray(value.mainDeck) || !value.mainDeck.every((card) => typeof card === "string")) { + return null; + } + if (identity && (value.roomCode !== roomCode || value.draftToken !== identity.draftToken)) { + return null; + } + return value; + } catch { + return null; + } +} + +export async function clearDraftDeckSubmission(hostPeerId: string, submissionId?: string): Promise { + try { + if (submissionId) { + const current = await loadDraftDeckSubmission(hostPeerId); + if (current?.submissionId !== submissionId) return; + } + await del(`${DRAFT_DECK_SUBMISSION_PREFIX}${hostPeerId}`, getDraftStore()); + } catch { + /* Retain safely when IndexedDB is unavailable. */ + } +} + +function isPersistedDraftGuestSession(value: unknown): value is PersistedDraftGuestSession { + if (!isRecord(value)) return false; + return isNonEmptyString(value.hostPeerId) + && isNonEmptyString(value.draftToken) + && isNonnegativeInteger(value.seatIndex) + && typeof value.draftCode === "string" + && isCanonicalRoomCode(value.roomCode) + && isNonEmptyString(value.displayName) + && isPositiveFiniteNumber(value.timestamp); +} + /** A participant-owned outbox survives a reload until the pod host acks it. */ export async function saveDraftSettlementOutbox(settlement: DraftMatchSettlement): Promise { try { diff --git a/client/src/stores/__tests__/draftPodStore.test.ts b/client/src/stores/__tests__/draftPodStore.test.ts index 2556e43411..cc68876a8d 100644 --- a/client/src/stores/__tests__/draftPodStore.test.ts +++ b/client/src/stores/__tests__/draftPodStore.test.ts @@ -3,19 +3,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ clearActiveDraftPod: vi.fn(), loadActiveDraftPod: vi.fn(), + inspectActiveDraftPod: vi.fn(), + clearActiveDraftPodIfCurrent: vi.fn(), loadDraftHostSession: vi.fn(), + persistedDraftHostSessionState: vi.fn(() => "live"), multiplayerState: { role: null as "host" | "guest" | null, phase: "idle", roomCode: null as string | null, - hostDraft: vi.fn(), + hostDraft: vi.fn<(config: unknown) => Promise>(async () => true), }, })); vi.mock("../../services/draftPersistence", () => ({ clearActiveDraftPod: mocks.clearActiveDraftPod, loadActiveDraftPod: mocks.loadActiveDraftPod, + inspectActiveDraftPod: mocks.inspectActiveDraftPod, + clearActiveDraftPodIfCurrent: mocks.clearActiveDraftPodIfCurrent, loadDraftHostSession: mocks.loadDraftHostSession, + persistedDraftHostSessionState: mocks.persistedDraftHostSessionState, })); vi.mock("../multiplayerDraftStore", () => ({ @@ -62,17 +68,61 @@ describe("draftPodStore", () => { mocks.multiplayerState.role = null; mocks.multiplayerState.phase = "idle"; mocks.multiplayerState.roomCode = null; - mocks.multiplayerState.hostDraft = vi.fn(async () => {}); + mocks.multiplayerState.hostDraft = vi.fn<(config: unknown) => Promise>(async () => true); + mocks.persistedDraftHostSessionState.mockReturnValue("live"); + mocks.inspectActiveDraftPod.mockReturnValue({ + type: "absent", + }); useDraftPodStore.getState().reset(); }); describe("resumeHostedPod", () => { + it("returns absent silently without changing setup state", async () => { + const outcome = await useDraftPodStore.getState().resumeHostedPod({ silent: true, routeToken: 1 }); + + expect(outcome).toBe("absent"); + expect(useDraftPodStore.getState().configError).toBeNull(); + }); + + it("treats a completed persisted snapshot as terminal and never re-hosts it", async () => { + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); + mocks.loadDraftHostSession.mockResolvedValue(persistedSession); + mocks.persistedDraftHostSessionState.mockReturnValue("terminal"); + + const outcome = await useDraftPodStore.getState().resumeHostedPod({ routeToken: 2 }); + + expect(outcome).toBe("terminal"); + expect(mocks.multiplayerState.hostDraft).not.toHaveBeenCalled(); + expect(mocks.clearActiveDraftPodIfCurrent).toHaveBeenCalled(); + }); + + it("uses the snapshot rather than stale complete metadata as resume authority", async () => { + const staleMeta = { ...activeMeta, phase: "complete" as const }; + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: staleMeta, capture: { id: staleMeta.id, roomCode: staleMeta.roomCode, updatedAt: staleMeta.updatedAt } }); + mocks.loadDraftHostSession.mockResolvedValue(persistedSession); + + const outcome = await useDraftPodStore.getState().resumeHostedPod({ routeToken: 3 }); + + expect(outcome).toBe("resumed"); + expect(mocks.multiplayerState.hostDraft).toHaveBeenCalledOnce(); + expect(mocks.clearActiveDraftPodIfCurrent).not.toHaveBeenCalled(); + }); + + it("does not report recovery as resumed when host initialization fails", async () => { + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); + mocks.loadDraftHostSession.mockResolvedValue(persistedSession); + mocks.multiplayerState.hostDraft = vi.fn<(config: unknown) => Promise>(async () => false); + + await expect(useDraftPodStore.getState().resumeHostedPod({ routeToken: 4 })).resolves.toBe("invalid"); + expect(mocks.clearActiveDraftPodIfCurrent).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent resume calls for the same hosted pod", async () => { let resolveSession!: (session: typeof persistedSession) => void; const sessionPromise = new Promise((resolve) => { resolveSession = resolve; }); - mocks.loadActiveDraftPod.mockReturnValue(activeMeta); + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); mocks.loadDraftHostSession.mockReturnValue(sessionPromise); const first = useDraftPodStore.getState().resumeHostedPod(); @@ -88,7 +138,7 @@ describe("draftPodStore", () => { mocks.multiplayerState.role = "host"; mocks.multiplayerState.phase = "matchInProgress"; mocks.multiplayerState.roomCode = "ABCDE"; - mocks.loadActiveDraftPod.mockReturnValue(activeMeta); + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); await useDraftPodStore.getState().resumeHostedPod(); @@ -100,7 +150,7 @@ describe("draftPodStore", () => { mocks.multiplayerState.role = "host"; mocks.multiplayerState.phase = "error"; mocks.multiplayerState.roomCode = "ABCDE"; - mocks.loadActiveDraftPod.mockReturnValue(activeMeta); + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); mocks.loadDraftHostSession.mockResolvedValue(persistedSession); await useDraftPodStore.getState().resumeHostedPod(); @@ -127,7 +177,7 @@ describe("draftPodStore", () => { }, }, }; - mocks.loadActiveDraftPod.mockReturnValue(activeMeta); + mocks.inspectActiveDraftPod.mockReturnValue({ type: "present", meta: activeMeta, capture: { id: activeMeta.id, roomCode: activeMeta.roomCode, updatedAt: activeMeta.updatedAt } }); mocks.loadDraftHostSession.mockResolvedValue(cubeSession); await useDraftPodStore.getState().resumeHostedPod(); @@ -142,7 +192,9 @@ describe("draftPodStore", () => { // The hostConfig dispatched to multiplayerDraftStore must mirror the // persisted Cube source 1:1 so the host re-initializes onto the same // cube content rather than falling back to "{}". - const dispatched = mocks.multiplayerState.hostDraft.mock.calls[0]?.[0]; + const dispatched = mocks.multiplayerState.hostDraft.mock.calls[0]?.[0] as { + poolInput: { type: string }; + }; expect(dispatched.poolInput.type).toBe("Cube"); }); }); @@ -191,7 +243,9 @@ describe("draftPodStore", () => { await useDraftPodStore.getState().createPod(); expect(mocks.multiplayerState.hostDraft).toHaveBeenCalledOnce(); - const dispatched = mocks.multiplayerState.hostDraft.mock.calls[0]?.[0]; + const dispatched = mocks.multiplayerState.hostDraft.mock.calls[0]?.[0] as { + poolInput: { type: string; data: { cube_name: string; cube_list_text: string } }; + }; expect(dispatched.poolInput.type).toBe("Cube"); expect(dispatched.poolInput.data.cube_name).toBe("Test Cube"); expect(dispatched.poolInput.data.cube_list_text).toBe("1 Lightning Bolt\n"); diff --git a/client/src/stores/__tests__/multiplayerDraftStore.persistenceFence.test.ts b/client/src/stores/__tests__/multiplayerDraftStore.persistenceFence.test.ts new file mode 100644 index 0000000000..a075490f20 --- /dev/null +++ b/client/src/stores/__tests__/multiplayerDraftStore.persistenceFence.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + clearDraftHostSession, + hostRoom, + loadDraftHostSession, + saveActiveDraftPod, + saveDraftHostSession, +} = vi.hoisted(() => ({ + clearDraftHostSession: vi.fn(async () => {}), + hostRoom: vi.fn(), + loadDraftHostSession: vi.fn(async () => null), + saveActiveDraftPod: vi.fn(), + saveDraftHostSession: vi.fn(async () => {}), +})); + +vi.mock("../../adapter/draft-adapter", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DraftAdapter: vi.fn().mockImplementation(function () { + return { + draftProcedure: vi.fn(async () => ({ + pod_size: 8, + human_seats: 8, + min_pod_size: 2, + packs_per_player: 3, + cards_per_pick: 1, + min_deck_size: 40, + match_config: { match_type: "Bo1" }, + })), + }; + }), + }; +}); + +vi.mock("../../network/connection", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, hostRoom }; +}); + +vi.mock("../../services/draftPersistence", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + clearDraftHostSession, + loadDraftHostSession, + saveActiveDraftPod, + saveDraftHostSession, + }; +}); + +import { useMultiplayerDraftStore } from "../multiplayerDraftStore"; + +describe("multiplayerDraftStore recovered-host persistence fence", () => { + beforeEach(() => { + vi.clearAllMocks(); + let hostNumber = 0; + hostRoom.mockImplementation(async () => { + hostNumber++; + return { + roomCode: "ABCDE", + peerId: `phase2-ABCDE-${hostNumber}`, + peer: { destroy: vi.fn() } as never, + onGuestConnected: vi.fn(() => vi.fn()), + destroy: vi.fn(), + }; + }); + }); + + afterEach(async () => { + await useMultiplayerDraftStore.getState().leave(true); + }); + + it("holds a replacement recovery until a route-aborted host's in-flight save drains", async () => { + let releaseStaleSave!: () => void; + saveDraftHostSession.mockImplementationOnce( + () => new Promise((resolve) => { + releaseStaleSave = resolve; + }), + ); + const config = { + poolInput: { type: "Set" as const, data: { set_pool_json: "{}" } }, + kind: "Premier" as const, + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss" as const, + podPolicy: "Competitive" as const, + persistenceId: "shared-recovery", + }; + const controller = new AbortController(); + + await expect(useMultiplayerDraftStore.getState().hostDraft({ + ...config, + signal: controller.signal, + })).resolves.toBe(true); + await Promise.resolve(); + expect(saveDraftHostSession).toHaveBeenCalledOnce(); + + controller.abort(); + const replacement = useMultiplayerDraftStore.getState().hostDraft(config); + await Promise.resolve(); + await Promise.resolve(); + + expect(hostRoom).toHaveBeenCalledOnce(); + expect(saveDraftHostSession).toHaveBeenCalledOnce(); + + releaseStaleSave(); + + await expect(replacement).resolves.toBe(true); + expect(hostRoom).toHaveBeenCalledTimes(2); + expect(saveDraftHostSession).toHaveBeenCalledTimes(2); + }); + + it("holds a replacement recovery until a route-aborted delayed hostRoom is destroyed", async () => { + const staleHostResult = { + roomCode: "ABCDE", + peerId: "phase2-ABCDE-stale", + peer: { destroy: vi.fn() } as never, + onGuestConnected: vi.fn(() => vi.fn()), + destroy: vi.fn(), + }; + let resolveStaleHostRoom!: (result: typeof staleHostResult) => void; + hostRoom.mockReset(); + hostRoom.mockImplementationOnce( + () => new Promise((resolve) => { + resolveStaleHostRoom = resolve; + }), + ); + hostRoom.mockImplementationOnce(async () => { + expect(staleHostResult.destroy).toHaveBeenCalledOnce(); + return { + roomCode: "ABCDE", + peerId: "phase2-ABCDE-current", + peer: { destroy: vi.fn() } as never, + onGuestConnected: vi.fn(() => vi.fn()), + destroy: vi.fn(), + }; + }); + const config = { + poolInput: { type: "Set" as const, data: { set_pool_json: "{}" } }, + kind: "Premier" as const, + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss" as const, + podPolicy: "Competitive" as const, + persistenceId: "shared-recovery", + }; + const controller = new AbortController(); + const stale = useMultiplayerDraftStore.getState().hostDraft({ + ...config, + signal: controller.signal, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + controller.abort(); + const replacement = useMultiplayerDraftStore.getState().hostDraft(config); + await Promise.resolve(); + await Promise.resolve(); + + expect(hostRoom).toHaveBeenCalledOnce(); + resolveStaleHostRoom(staleHostResult); + + await expect(stale).resolves.toBe(false); + await expect(replacement).resolves.toBe(true); + expect(hostRoom).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/stores/__tests__/multiplayerDraftStore.test.ts b/client/src/stores/__tests__/multiplayerDraftStore.test.ts index f1da7c2226..bfd0e7eaf6 100644 --- a/client/src/stores/__tests__/multiplayerDraftStore.test.ts +++ b/client/src/stores/__tests__/multiplayerDraftStore.test.ts @@ -12,6 +12,7 @@ import { useMultiplayerDraftStore, type DraftPodScreen, } from "../multiplayerDraftStore"; +import { DraftPodHostAdapter } from "../../adapter/draftPodHostAdapter"; import type { DraftPlayerView } from "../../adapter/draft-adapter"; import { DraftPauseReason, type DraftMatchLaunch } from "../../network/draftProtocol"; @@ -138,6 +139,116 @@ describe("multiplayerDraftStore", () => { }); describe("hostDraft", () => { + it("hands a completed host session off before joining and gates its late events", async () => { + await useMultiplayerDraftStore.getState().hostDraft({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + }); + const staleHostEvent = capturedHostEventHandler!; + + await useMultiplayerDraftStore.getState().joinDraft({ kind: "new", roomCode: "ABCDE", displayName: "Guest" }); + staleHostEvent({ type: "roomCreated", roomCode: "STALE" }); + + expect(mockHostAdapter.dispose).toHaveBeenCalledWith({ preserveSession: true }); + expect(useMultiplayerDraftStore.getState()).toMatchObject({ role: "guest", roomCode: null }); + }); + + it("waits for a cancelled recovery's same-ID host cleanup before starting its replacement", async () => { + const config = { + poolInput: { type: "Set" as const, data: { set_pool_json: "{}" } }, + kind: "Premier" as const, + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss" as const, + podPolicy: "Competitive" as const, + persistenceId: "shared-recovery", + }; + await expect(useMultiplayerDraftStore.getState().hostDraft(config)).resolves.toBe(true); + + let releaseCleanup!: () => void; + mockHostAdapter.dispose.mockImplementationOnce(() => new Promise((resolve) => { + releaseCleanup = resolve; + })); + const replacement = useMultiplayerDraftStore.getState().hostDraft(config); + await Promise.resolve(); + + expect(vi.mocked(DraftPodHostAdapter)).toHaveBeenCalledTimes(1); + releaseCleanup(); + + await expect(replacement).resolves.toBe(true); + expect(vi.mocked(DraftPodHostAdapter)).toHaveBeenCalledTimes(2); + }); + + it("disposes a superseded in-flight host after its late initialization resolves", async () => { + let resolveHost!: () => void; + mockHostAdapter.initialize.mockImplementationOnce(() => new Promise((resolve) => { + resolveHost = resolve; + })); + + const first = useMultiplayerDraftStore.getState().hostDraft({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + }); + await useMultiplayerDraftStore.getState().joinDraft({ kind: "new", roomCode: "ABCDE", displayName: "Guest" }); + resolveHost(); + await first; + + expect(mockHostAdapter.dispose).toHaveBeenCalledWith({ preserveSession: true }); + expect(useMultiplayerDraftStore.getState().role).toBe("guest"); + }); + + it("releases an in-flight host when its owning route aborts", async () => { + let resolveHost!: () => void; + mockHostAdapter.initialize.mockImplementationOnce(() => new Promise((resolve) => { + resolveHost = resolve; + })); + const controller = new AbortController(); + const hosting = useMultiplayerDraftStore.getState().hostDraft({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + signal: controller.signal, + }); + + await Promise.resolve(); + controller.abort(); + resolveHost(); + + await expect(hosting).resolves.toBe(false); + expect(mockHostAdapter.dispose).toHaveBeenCalledWith({ preserveSession: true }); + expect(useMultiplayerDraftStore.getState().role).not.toBe("host"); + }); + + it("releases an initialized host when its owning route later aborts", async () => { + const controller = new AbortController(); + await expect(useMultiplayerDraftStore.getState().hostDraft({ + poolInput: { type: "Set", data: { set_pool_json: "{}" } }, + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + signal: controller.signal, + })).resolves.toBe(true); + + controller.abort(); + await Promise.resolve(); + + expect(mockHostAdapter.dispose).toHaveBeenCalledWith({ preserveSession: true }); + expect(useMultiplayerDraftStore.getState().role).not.toBe("host"); + }); + it("sets role to host and phase to connecting", async () => { await useMultiplayerDraftStore.getState().hostDraft({ poolInput: { type: "Set", data: { set_pool_json: "{}" } }, @@ -401,6 +512,7 @@ describe("multiplayerDraftStore", () => { describe("joinDraft", () => { it("sets role to guest and phase to connecting", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -409,8 +521,33 @@ describe("multiplayerDraftStore", () => { expect(state.role).toBe("guest"); }); + it("releases an in-flight guest recovery when its route aborts", async () => { + let resolveGuest!: () => void; + mockGuestAdapter.initialize.mockImplementationOnce(() => new Promise((resolve) => { + resolveGuest = resolve; + })); + const controller = new AbortController(); + const joining = useMultiplayerDraftStore.getState().joinDraft({ + kind: "reconnect", + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", + signal: controller.signal, + }); + + await Promise.resolve(); + controller.abort(); + expect(useMultiplayerDraftStore.getState()).toMatchObject({ role: null, phase: "idle" }); + + resolveGuest(); + await joining; + expect(mockGuestAdapter.dispose).toHaveBeenCalledWith({ preserveRecovery: true }); + }); + it("sets seatIndex and draftCode on joined event", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -429,6 +566,7 @@ describe("multiplayerDraftStore", () => { it("tracks pause state", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -450,6 +588,7 @@ describe("multiplayerDraftStore", () => { it("tracks pairing info", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -475,6 +614,7 @@ describe("multiplayerDraftStore", () => { it("sets phase to kicked on kicked event", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -486,8 +626,29 @@ describe("multiplayerDraftStore", () => { expect(state.error).toBe("AFK"); }); + it("retains typed reconnect failure semantics for the recovery screen", async () => { + await useMultiplayerDraftStore.getState().joinDraft({ + kind: "reconnect", + roomCode: "ABCDE", + displayName: "Alice", + hostPeerId: "phase2-ABCDE", + draftToken: "opaque-token", + }); + + capturedGuestEventHandler!({ + type: "reconnectFailed", + failure: { kind: "retryable", message: "Host is restarting" }, + }); + + expect(useMultiplayerDraftStore.getState()).toMatchObject({ + error: "Host is restarting", + guestRecoveryFailure: { kind: "retryable", message: "Host is restarting" }, + }); + }); + it("retires a guest error when the phase changes, and only then", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -546,6 +707,7 @@ describe("multiplayerDraftStore", () => { it("clears a stale message when the guest enters a message-less error phase, but not one that follows the flip", async () => { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); @@ -736,6 +898,7 @@ describe("multiplayerDraftStore", () => { async function guestInMatch(): Promise { await useMultiplayerDraftStore.getState().joinDraft({ + kind: "new", roomCode: "ABCDE", displayName: "Alice", }); diff --git a/client/src/stores/draftPodStore.ts b/client/src/stores/draftPodStore.ts index a8cb07f6c0..356612e934 100644 --- a/client/src/stores/draftPodStore.ts +++ b/client/src/stores/draftPodStore.ts @@ -20,9 +20,10 @@ import type { CubeDraftSettings, TournamentFormat, PodPolicy, DraftKind as CoreD import type { DraftPodHostConfig } from "../adapter/draftPodHostAdapter"; import type { DraftPodGuestConfig } from "../adapter/draftPodGuestAdapter"; import { - clearActiveDraftPod, - loadActiveDraftPod, + clearActiveDraftPodIfCurrent, + inspectActiveDraftPod, loadDraftHostSession, + persistedDraftHostSessionState, } from "../services/draftPersistence"; import { useMultiplayerDraftStore } from "./multiplayerDraftStore"; @@ -32,6 +33,9 @@ export type DraftKind = Exclude; export type PoolMode = "set" | "cube"; +/** Result of one host-recovery probe. Page entry owns the route policy. */ +export type HostedPodResumeOutcome = "resumed" | "absent" | "terminal" | "invalid" | "superseded"; + export interface CubeForm { cubeName: string; cubeListText: string; @@ -90,7 +94,7 @@ interface DraftPodActions { /** Join an existing pod as guest. */ joinPod: () => Promise; /** Resume the active hosted pod from local persistence. */ - resumeHostedPod: () => Promise; + resumeHostedPod: (options?: { silent?: boolean; routeToken?: number; signal?: AbortSignal }) => Promise; /** Host: start the draft (delegates to multiplayerDraftStore). */ startDraft: () => Promise; /** Reset pod store state. */ @@ -126,7 +130,13 @@ function normalizePodConfig(config: PodConfig): PodConfig { return config; } -let resumeHostedPodPromise: Promise | null = null; +interface HostedPodResumeAttempt { + routeToken: number; + signal: AbortSignal | undefined; + promise: Promise; +} + +let resumeHostedPodAttempt: HostedPodResumeAttempt | null = null; // ── Store ────────────────────────────────────────────────────────────── @@ -262,17 +272,36 @@ export const useDraftPodStore = create()( } }, - resumeHostedPod: async () => { - if (resumeHostedPodPromise) { - return resumeHostedPodPromise; + resumeHostedPod: async (options = {}) => { + const routeToken = options.routeToken ?? 0; + if ( + resumeHostedPodAttempt && + resumeHostedPodAttempt.routeToken === routeToken && + resumeHostedPodAttempt.signal === options.signal + ) { + return resumeHostedPodAttempt.promise; } - resumeHostedPodPromise = (async () => { - const meta = loadActiveDraftPod(); - if (!meta) { - set({ configError: "No draft pod to resume" }); - return; + const attempt: HostedPodResumeAttempt = { + routeToken, + signal: options.signal, + promise: Promise.resolve("superseded"), + }; + const isCurrentAttempt = () => + resumeHostedPodAttempt === attempt && !options.signal?.aborted; + attempt.promise = (async (): Promise => { + if (options.signal?.aborted) return "superseded"; + const active = inspectActiveDraftPod(); + if (active.type === "absent") { + if (!options.silent) set({ configError: "No draft pod to resume" }); + return "absent"; + } + if (active.type === "invalid") { + if (active.capture) clearActiveDraftPodIfCurrent(active.capture); + if (!options.silent) set({ configError: "Saved draft pod is invalid" }); + return "invalid"; } + const { meta, capture } = active; const activeDraft = useMultiplayerDraftStore.getState(); if ( @@ -281,14 +310,30 @@ export const useDraftPodStore = create()( activeDraft.phase !== "error" && activeDraft.roomCode === meta.roomCode ) { - return; + return "resumed"; } const persisted = await loadDraftHostSession(meta.id); + if (!isCurrentAttempt()) return "superseded"; if (!persisted) { - clearActiveDraftPod(); - set({ configError: "Saved draft pod was not found" }); - return; + clearActiveDraftPodIfCurrent(capture); + if (!options.silent) set({ configError: "Saved draft pod was not found" }); + return "invalid"; + } + const sessionState = persistedDraftHostSessionState(persisted); + if ( + persisted.persistenceId !== meta.id || + persisted.roomCode !== meta.roomCode || + sessionState !== "live" + ) { + // Release only the active locator. A terminal snapshot is retained as + // local history; a corrupt one is unreachable after this exact-match + // cleanup and cannot poison a replacement pod. + clearActiveDraftPodIfCurrent(capture); + if (!options.silent) { + set({ configError: sessionState === "terminal" ? "Saved draft pod is complete" : "Saved draft pod is invalid" }); + } + return sessionState === "terminal" ? "terminal" : "invalid"; } // Branch on the persisted pool source: restore the matching UI @@ -346,13 +391,20 @@ export const useDraftPodStore = create()( preferredRoomCode: persisted.roomCode || undefined, }; - await useMultiplayerDraftStore.getState().hostDraft(hostConfig); + if (!isCurrentAttempt()) return "superseded"; + const hosted = await useMultiplayerDraftStore.getState().hostDraft({ + ...hostConfig, + signal: options.signal, + }); + if (!isCurrentAttempt()) return "superseded"; + return hosted ? "resumed" : "invalid"; })(); + resumeHostedPodAttempt = attempt; try { - await resumeHostedPodPromise; + return await attempt.promise; } finally { - resumeHostedPodPromise = null; + if (resumeHostedPodAttempt === attempt) resumeHostedPodAttempt = null; } }, @@ -371,6 +423,7 @@ export const useDraftPodStore = create()( set({ configError: null }); const guestConfig: DraftPodGuestConfig = { + kind: "new", roomCode: joinCode.trim(), displayName: guestDisplayName.trim(), }; diff --git a/client/src/stores/multiplayerDraftStore.ts b/client/src/stores/multiplayerDraftStore.ts index b6dd019a4b..9eb9a1c4f0 100644 --- a/client/src/stores/multiplayerDraftStore.ts +++ b/client/src/stores/multiplayerDraftStore.ts @@ -39,11 +39,15 @@ import { type DraftPodGuestEvent, type DraftPodGuestStatus, } from "../adapter/draftPodGuestAdapter"; +import type { DraftGuestRecoveryFailure } from "../adapter/p2p-draft-guest"; import { clearActiveDraftPod, + clearActiveDraftGuest, clearDraftSettlementOutbox, loadDraftIntergameCommands, loadActiveDraftPod, + loadActiveDraftGuest, + loadDraftGuestSession, loadDraftSettlementOutbox, saveActiveDraftPod, saveDraftIntergameCommands, @@ -66,6 +70,8 @@ import { FORMAT_DEFAULTS } from "./multiplayerStore"; export type DraftRole = "host" | "guest"; +export type GuestDraftResumeOutcome = "resumed" | "absent" | "invalid" | "failed" | "superseded"; + /** * The pod SESSION's phase. * @@ -194,6 +200,8 @@ interface MultiplayerDraftState { pauseReason: DraftPauseReason | null; pairing: PairingInfo | null; error: string | null; + /** Recovery-only failure semantics, retained for an explicit retry CTA. */ + guestRecoveryFailure: DraftGuestRecoveryFailure | null; selectedCard: string | null; mainDeck: string[]; landCounts: Record; @@ -227,9 +235,12 @@ interface MultiplayerDraftState { interface MultiplayerDraftActions { /** Host: create a new draft pod and start accepting guests. */ - hostDraft: (config: DraftPodHostConfig) => Promise; + /** `true` only after the current adapter initialized and remains owned. */ + hostDraft: (config: DraftPodHostConfig) => Promise; /** Guest: join an existing draft pod by room code. */ joinDraft: (config: DraftPodGuestConfig) => Promise; + /** Reconnect exclusively through the persisted capability, never `draft_join`. */ + resumeDraft: (options?: { routeToken?: number; signal?: AbortSignal }) => Promise; /** Host: start the draft once the pod is ready. */ startDraft: (botFillEmptySeats?: boolean) => Promise; /** Both: submit a pick. */ @@ -292,10 +303,113 @@ interface MultiplayerDraftActions { let activeHostAdapter: DraftPodHostAdapter | null = null; let activeGuestAdapter: DraftPodGuestAdapter | null = null; +let activeHostEventUnsub: (() => void) | null = null; +let activeGuestEventUnsub: (() => void) | null = null; +let activeHostAbort: AbortController | null = null; +let activeGuestAbort: AbortController | null = null; +let activeHostRouteAbortListener: { signal: AbortSignal; listener: () => void } | null = null; +let activeGuestRouteAbortListener: { signal: AbortSignal; listener: () => void } | null = null; +let activeHostPersistenceId: string | null = null; +let draftAdapterEpoch = 0; +let resumeGuestDraftAttempt: { + routeToken: number; + signal: AbortSignal | undefined; + promise: Promise; +} | null = null; +const disposedHostAdapters = new WeakSet(); +const disposedGuestAdapters = new WeakSet(); +const retainedDraftSessionTeardowns = new Map>(); let activeMatchController: GameLoopController | null = null; const intergameControllers = new Map(); const DRAFT_MATCH_FORMAT_CONFIG = FORMAT_DEFAULTS.Limited; +interface DetachedDraftAdapters { + host: DraftPodHostAdapter | null; + guest: DraftPodGuestAdapter | null; + hostPersistenceId: string | null; +} + +function detachDraftAdapters(): DetachedDraftAdapters { + const detached = { + host: activeHostAdapter, + guest: activeGuestAdapter, + hostPersistenceId: activeHostPersistenceId, + }; + activeHostAdapter = null; + activeGuestAdapter = null; + activeHostPersistenceId = null; + activeHostAbort?.abort(); + activeGuestAbort?.abort(); + activeHostRouteAbortListener?.signal.removeEventListener("abort", activeHostRouteAbortListener.listener); + activeGuestRouteAbortListener?.signal.removeEventListener("abort", activeGuestRouteAbortListener.listener); + activeHostAbort = null; + activeGuestAbort = null; + activeHostRouteAbortListener = null; + activeGuestRouteAbortListener = null; + activeHostEventUnsub?.(); + activeGuestEventUnsub?.(); + activeHostEventUnsub = null; + activeGuestEventUnsub = null; + return detached; +} + +async function disposeHostAdapter(adapter: DraftPodHostAdapter, preserveSession: boolean): Promise { + if (disposedHostAdapters.has(adapter)) return; + disposedHostAdapters.add(adapter); + await adapter.dispose({ preserveSession }); +} + +async function disposeGuestAdapter(adapter: DraftPodGuestAdapter, preserveRecovery = true): Promise { + if (disposedGuestAdapters.has(adapter)) return; + disposedGuestAdapters.add(adapter); + await adapter.dispose({ preserveRecovery }); +} + +async function disposeDetachedDraftAdapters( + detached: DetachedDraftAdapters, + preserveSession: boolean, +): Promise { + await Promise.allSettled([ + ...(detached.host ? [disposeHostAdapter(detached.host, preserveSession)] : []), + ...(detached.guest ? [disposeGuestAdapter(detached.guest, preserveSession)] : []), + ]); +} + +/** Retains teardown after the active adapter ref has been detached on route abort. */ +function retainDraftSessionTeardown( + persistenceId: string | null, + teardown: Promise, +): void { + if (!persistenceId) return; + const previous = retainedDraftSessionTeardowns.get(persistenceId); + const retained = previous + ? previous.then(() => teardown, () => teardown) + : teardown; + retainedDraftSessionTeardowns.set(persistenceId, retained); + void retained.then( + () => { + if (retainedDraftSessionTeardowns.get(persistenceId) === retained) { + retainedDraftSessionTeardowns.delete(persistenceId); + } + }, + () => { + if (retainedDraftSessionTeardowns.get(persistenceId) === retained) { + retainedDraftSessionTeardowns.delete(persistenceId); + } + }, + ); +} + +/** Waits for the previous local owner of this persisted draft session to finish. */ +async function claimDraftSessionOwner(persistenceId: string | undefined): Promise { + if (!persistenceId) return; + await retainedDraftSessionTeardowns.get(persistenceId); +} + +function lifecycleSignal(controller: AbortController): AbortSignal { + return controller.signal; +} + function intergameAction(payload: DraftIntergameCommandPayload): GameAction { switch (payload.type) { case "SubmitSideboard": @@ -585,6 +699,7 @@ const initialState: MultiplayerDraftState = { pauseReason: null, pairing: null, error: null, + guestRecoveryFailure: null, selectedCard: null, mainDeck: [], landCounts: {}, @@ -679,10 +794,38 @@ export const useMultiplayerDraftStore = create< ...initialState, hostDraft: async (config) => { + const epoch = ++draftAdapterEpoch; + const previous = detachDraftAdapters(); + const previousTeardown = disposeDetachedDraftAdapters(previous, true); + retainDraftSessionTeardown(previous.hostPersistenceId, previousTeardown); + if (previous.host || previous.guest) await previousTeardown; + if (config.persistenceId) await claimDraftSessionOwner(config.persistenceId); + if (epoch !== draftAdapterEpoch || config.signal?.aborted) return false; + const adapter = new DraftPodHostAdapter(); + const controller = new AbortController(); activeHostAdapter = adapter; + activeHostAbort = controller; + activeHostPersistenceId = config.persistenceId ?? null; - adapter.onEvent((event) => handleHostEvent(event, set)); + activeHostEventUnsub = adapter.onEvent((event) => { + if (activeHostAdapter === adapter && epoch === draftAdapterEpoch) { + handleHostEvent(event, set); + } + }); + + const abortOwner = () => { + controller.abort(); + if (activeHostAdapter !== adapter || epoch !== draftAdapterEpoch) return; + ++draftAdapterEpoch; + const detached = detachDraftAdapters(); + const teardown = disposeDetachedDraftAdapters(detached, true); + retainDraftSessionTeardown(detached.hostPersistenceId, teardown); + void teardown; + set(initialState); + }; + config.signal?.addEventListener("abort", abortOwner, { once: true }); + if (config.signal) activeHostRouteAbortListener = { signal: config.signal, listener: abortOwner }; set({ ...initialState, @@ -691,8 +834,11 @@ export const useMultiplayerDraftStore = create< seatIndex: 0, }); + let initialized = false; try { - await adapter.initialize(config); + await adapter.initialize({ ...config, signal: lifecycleSignal(controller) }); + initialized = true; + if (activeHostAdapter !== adapter || epoch !== draftAdapterEpoch) return false; if (config.persistenceId) { const view = get().view; const phase = view ? activePhaseForDraftViewStatus(view.status) ?? "lobby" : "lobby"; @@ -709,16 +855,59 @@ export const useMultiplayerDraftStore = create< updatedAt: Date.now(), }); } + return true; } catch { - // Error already emitted via adapter event + // The adapter reports the error while it is current. A late failure is + // deliberately silent: its event gate was detached by the new owner. + } finally { + if (activeHostAdapter !== adapter || epoch !== draftAdapterEpoch) { + config.signal?.removeEventListener("abort", abortOwner); + await disposeHostAdapter(adapter, true); + } else if (!initialized || adapter.status === "error") { + activeHostAdapter = null; + activeHostAbort = null; + activeHostPersistenceId = null; + activeHostEventUnsub?.(); + activeHostEventUnsub = null; + config.signal?.removeEventListener("abort", abortOwner); + activeHostRouteAbortListener = null; + await disposeHostAdapter(adapter, true); + } } + return false; }, joinDraft: async (config) => { + const epoch = ++draftAdapterEpoch; + const previous = detachDraftAdapters(); + const previousTeardown = disposeDetachedDraftAdapters(previous, true); + retainDraftSessionTeardown(previous.hostPersistenceId, previousTeardown); + if (previous.host || previous.guest) await previousTeardown; + if (epoch !== draftAdapterEpoch || config.signal?.aborted) return; + const adapter = new DraftPodGuestAdapter(); + const controller = new AbortController(); activeGuestAdapter = adapter; + activeGuestAbort = controller; + + activeGuestEventUnsub = adapter.onEvent((event) => { + if (activeGuestAdapter === adapter && epoch === draftAdapterEpoch) { + handleGuestEvent(event, set); + } + }); - adapter.onEvent((event) => handleGuestEvent(event, set)); + const abortOwner = () => { + controller.abort(); + if (activeGuestAdapter !== adapter || epoch !== draftAdapterEpoch) return; + ++draftAdapterEpoch; + const detached = detachDraftAdapters(); + const teardown = disposeDetachedDraftAdapters(detached, true); + retainDraftSessionTeardown(detached.hostPersistenceId, teardown); + void teardown; + set(initialState); + }; + config.signal?.addEventListener("abort", abortOwner, { once: true }); + if (config.signal) activeGuestRouteAbortListener = { signal: config.signal, listener: abortOwner }; set({ ...initialState, @@ -726,10 +915,76 @@ export const useMultiplayerDraftStore = create< phase: "connecting", }); + let initialized = false; try { - await adapter.initialize(config); + await adapter.initialize({ ...config, signal: lifecycleSignal(controller) }); + initialized = true; } catch { - // Error already emitted via adapter event + // See hostDraft: only the current owner is allowed to project errors. + } finally { + if (activeGuestAdapter !== adapter || epoch !== draftAdapterEpoch) { + config.signal?.removeEventListener("abort", abortOwner); + await disposeGuestAdapter(adapter); + } else if (!initialized || adapter.status === "error") { + activeGuestAdapter = null; + activeGuestAbort = null; + activeGuestEventUnsub?.(); + activeGuestEventUnsub = null; + config.signal?.removeEventListener("abort", abortOwner); + activeGuestRouteAbortListener = null; + await disposeGuestAdapter(adapter); + } + } + }, + + resumeDraft: async (options = {}) => { + const routeToken = options.routeToken ?? 0; + if ( + resumeGuestDraftAttempt + && resumeGuestDraftAttempt.routeToken === routeToken + && resumeGuestDraftAttempt.signal === options.signal + ) { + return resumeGuestDraftAttempt.promise; + } + + const attempt: NonNullable = { + routeToken, + signal: options.signal, + promise: Promise.resolve("superseded"), + }; + const isCurrent = () => resumeGuestDraftAttempt === attempt && !options.signal?.aborted; + attempt.promise = (async (): Promise => { + if (options.signal?.aborted) return "superseded"; + const locator = loadActiveDraftGuest(); + if (!locator) return "absent"; + const session = await loadDraftGuestSession(locator.hostPeerId, locator); + if (!isCurrent()) return "superseded"; + if (!session) { + clearActiveDraftGuest(); + return "invalid"; + } + + await get().joinDraft({ + kind: "reconnect", + roomCode: locator.roomCode, + displayName: locator.displayName, + hostPeerId: locator.hostPeerId, + draftToken: session.draftToken, + signal: options.signal, + }); + if (!isCurrent()) return "superseded"; + if (activeGuestAdapter) return "resumed"; + if (get().guestRecoveryFailure?.kind === "invalid") { + clearActiveDraftGuest(); + return "invalid"; + } + return "failed"; + })(); + resumeGuestDraftAttempt = attempt; + try { + return await attempt.promise; + } finally { + if (resumeGuestDraftAttempt === attempt) resumeGuestDraftAttempt = null; } }, @@ -812,13 +1067,15 @@ export const useMultiplayerDraftStore = create< } const fullDeck = [...mainDeck, ...landCards]; - set({ submittedDeck: fullDeck }); - if (role === "host" && activeHostAdapter) { const view = await activeHostAdapter.submitDeck(fullDeck); - set({ view }); + set({ view, submittedDeck: fullDeck }); } else if (role === "guest" && activeGuestAdapter) { await activeGuestAdapter.submitDeck(fullDeck); + // The guest adapter resolves only on `draft_deck_submit_ack`, not after + // a DataChannel write. This keeps the deck builder honest across a + // reload between submit and host durability. + set({ submittedDeck: fullDeck }); } }, @@ -1159,25 +1416,26 @@ export const useMultiplayerDraftStore = create< }, leave: async (preserveSession = false) => { + const epoch = ++draftAdapterEpoch; // Dispose match adapter first (game P2P connection) disposeMatchAdapter(set); - if (activeHostAdapter) { - await activeHostAdapter.dispose({ preserveSession }); - activeHostAdapter = null; - if (!preserveSession) { - clearActiveDraftPod(); - } - } - if (activeGuestAdapter) { - await activeGuestAdapter.dispose(); - activeGuestAdapter = null; - } + const detached = detachDraftAdapters(); + const teardown = disposeDetachedDraftAdapters(detached, preserveSession); + retainDraftSessionTeardown(detached.hostPersistenceId, teardown); + await teardown; + if (epoch !== draftAdapterEpoch) return; + if (detached.host && !preserveSession) clearActiveDraftPod(); set(initialState); }, reset: () => { + ++draftAdapterEpoch; disposeMatchAdapter(set); + const detached = detachDraftAdapters(); + const teardown = disposeDetachedDraftAdapters(detached, true); + retainDraftSessionTeardown(detached.hostPersistenceId, teardown); + void teardown; set(initialState); }, }))); @@ -1465,7 +1723,9 @@ function handleGuestEvent(event: DraftPodGuestEvent, set: SetFn): void { set({ error: event.message }); break; case "reconnecting": + break; case "reconnectFailed": + set({ error: event.failure.message, guestRecoveryFailure: event.failure }); break; case "bo3SideboardPrompt": // No `phase` write — see `draftPodScreen`. The pod session is already diff --git a/client/vitest.config.ts b/client/vitest.config.ts index 96128f2202..c8c232afd0 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -38,8 +38,24 @@ function wasmStubPlugin(): Plugin { }; } +/** `virtual:pwa-register` is supplied by VitePWA in production but Vitest + * does not load that plugin. Keep the stub resolvable so updater tests can + * replace it with the same module contract. */ +function pwaRegisterStubPlugin(): Plugin { + const id = "\0virtual:pwa-register-stub"; + return { + name: "pwa-register-stub", + resolveId(source) { + return source === "virtual:pwa-register" ? id : undefined; + }, + load(source) { + return source === id ? "export const registerSW = () => async () => {};" : undefined; + }, + }; +} + export default defineConfig({ - plugins: [wasmStubPlugin()], + plugins: [wasmStubPlugin(), pwaRegisterStubPlugin()], define: { __SCRYFALL_DATA_URL__: JSON.stringify("/scryfall-data.json"), __SCRYFALL_TOKEN_IMAGES_URL__: JSON.stringify("/scryfall-token-images.json"),