From 3ce9e96cf9e9d2800d8b4c6936da64fe8d6725a4 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 8 Jul 2026 22:26:58 +0800 Subject: [PATCH 1/4] fix: seed first P2P backup immediately to prevent row squatting Co-authored-by: Cursor --- client/src/adapter/p2p-draft-host.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 26fede35a9..0f0ad55103 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -508,6 +508,9 @@ export class P2PDraftHost { } } + // Force the first persisted state to upload immediately so the host + // claims the backup row before the normal N-picks interval. + this.picksSinceLastBackup = BACKUP_INTERVAL_PICKS; this.persistSession(); const freshHostView = await this.adapter.getViewForSeat(0); this.emit({ type: "draftStarted", view: freshHostView }); From 24d20db967e7c44aadbfa7e0cce2b341be84152c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 8 Jul 2026 22:43:46 +0800 Subject: [PATCH 2/4] fix: qualify backup interval static member reference Co-authored-by: Cursor --- client/src/adapter/p2p-draft-host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 0f0ad55103..fb1886e0dc 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -510,7 +510,7 @@ export class P2PDraftHost { // Force the first persisted state to upload immediately so the host // claims the backup row before the normal N-picks interval. - this.picksSinceLastBackup = BACKUP_INTERVAL_PICKS; + this.picksSinceLastBackup = P2PDraftHost.BACKUP_INTERVAL_PICKS; this.persistSession(); const freshHostView = await this.adapter.getViewForSeat(0); this.emit({ type: "draftStarted", view: freshHostView }); From 6fdf4512f20c52ef05412010e2f34f4d6e40b7fc Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 10 Jul 2026 10:19:39 +0800 Subject: [PATCH 3/4] test(client): assert immediate P2P backup upload on draft start Co-authored-by: Cursor --- .../__tests__/p2pDraftHostBackup.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 client/src/adapter/__tests__/p2pDraftHostBackup.test.ts diff --git a/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts new file mode 100644 index 0000000000..4f1068aa46 --- /dev/null +++ b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../draft-adapter", () => ({ + DraftAdapter: vi.fn().mockImplementation(function () { + return {}; + }), +})); + +vi.mock("../../services/draftPersistence", () => ({ + saveDraftHostSession: vi.fn().mockResolvedValue(undefined), + clearDraftHostSession: vi.fn(), +})); + +import { P2PDraftHost } from "../p2p-draft-host"; +import type { DraftPlayerView } from "../draft-adapter"; +import { saveDraftHostSession } from "../../services/draftPersistence"; + +describe("P2PDraftHost server backup", () => { + const BACKUP_URL = "https://backup.example"; + const draftingView: DraftPlayerView = { + status: "Drafting", + pick_number: 1, + seats: [ + { seat_index: 0, is_bot: false, display_name: "Host", picks: [] }, + { seat_index: 1, is_bot: true, display_name: "Bot 1", picks: [] }, + ], + current_pack: [], + pairings: [], + current_round: 1, + } as DraftPlayerView; + + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function makeHost(): P2PDraftHost { + return new P2PDraftHost( + { id: "host-peer-abc" } as never, + () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Premier", + 2, + "Host", + "Swiss", + "Casual", + undefined, + "persist-backup-test", + "ROOM01", + BACKUP_URL, + ); + } + + function wireAdapter(host: P2PDraftHost): void { + const adapter = (host as unknown as { adapter: Record }).adapter; + adapter.createMultiplayerDraft = vi.fn().mockResolvedValue(undefined); + adapter.getViewForSeat = vi.fn(async () => draftingView); + adapter.exportSession = vi.fn().mockResolvedValue('{"status":"Drafting"}'); + } + + async function flushPersistQueue(host: P2PDraftHost): Promise { + await (host as unknown as { persistQueue: Promise }).persistQueue; + } + + it("uploads the first backup snapshot when the draft starts", async () => { + const host = makeHost(); + wireAdapter(host); + + await host.startDraft(); + await flushPersistQueue(host); + + expect(saveDraftHostSession).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + `${BACKUP_URL}/p2p-draft-backup`, + expect.objectContaining({ method: "POST" }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.host_peer_id).toBe("host-peer-abc"); + expect(body.draft_code).toMatch(/^draft-[0-9a-f]{8}$/); + }); +}); From bb2ef2cc0a6b143fc5efb5a9f1a7c464e90c3a62 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 10 Jul 2026 10:29:22 +0800 Subject: [PATCH 4/4] fix(test): satisfy DraftPlayerView cast under stricter TS on main Co-authored-by: Cursor --- client/src/adapter/__tests__/p2pDraftHostBackup.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts index 4f1068aa46..ed9e6230ea 100644 --- a/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts +++ b/client/src/adapter/__tests__/p2pDraftHostBackup.test.ts @@ -17,7 +17,7 @@ import { saveDraftHostSession } from "../../services/draftPersistence"; describe("P2PDraftHost server backup", () => { const BACKUP_URL = "https://backup.example"; - const draftingView: DraftPlayerView = { + const draftingView = { status: "Drafting", pick_number: 1, seats: [ @@ -27,7 +27,7 @@ describe("P2PDraftHost server backup", () => { current_pack: [], pairings: [], current_round: 1, - } as DraftPlayerView; + } as unknown as DraftPlayerView; let fetchMock: ReturnType;