Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions client/src/adapter/__tests__/p2pDraftHostBackup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 { generateP2pDraftCode, P2PDraftHost } from "../p2p-draft-host";
import type { DraftPlayerView } from "../draft-adapter";
import { saveDraftHostSession } from "../../services/draftPersistence";
import {
resolveP2pBackupEndpoint,
wsUrlToHttpOrigin,
} from "../../config/multiplayerServer";

describe("P2P draft backup contract", () => {
it("generateP2pDraftCode matches the server 6-char uppercase contract", () => {
const code = generateP2pDraftCode(() => new Uint8Array([0, 25, 26, 35, 1, 10]));
expect(code).toBe("AZ09BK");
expect(code).toMatch(/^[A-Z0-9]{6}$/);
});

it("wsUrlToHttpOrigin strips /ws for the backup HTTP base", () => {
expect(wsUrlToHttpOrigin("wss://lobby.phase-rs.dev/ws")).toBe(
"https://lobby.phase-rs.dev",
);
expect(wsUrlToHttpOrigin("ws://127.0.0.1:9374/ws")).toBe("http://127.0.0.1:9374");
expect(resolveP2pBackupEndpoint("wss://lobby.phase-rs.dev/ws")).toBe(
"https://lobby.phase-rs.dev",
);
});
});

describe("P2PDraftHost server backup", () => {
const BACKUP_URL = "https://backup.example";
const draftingView = {
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 unknown as DraftPlayerView;

let fetchMock: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
warnSpy.mockRestore();
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<string, unknown> }).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<void> {
await (host as unknown as { persistQueue: Promise<void> }).persistQueue;
}

it("uploads a server-valid draft code on draft start", 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(/^[A-Z0-9]{6}$/);
expect(body.draft_code).not.toMatch(/^draft-/);
});

it("logs non-2xx upload responses instead of treating them as success", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 400 });
const host = makeHost();
wireAdapter(host);

await host.startDraft();
await flushPersistQueue(host);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("server backup upload failed: HTTP 400"),
);
});
});
6 changes: 5 additions & 1 deletion client/src/adapter/draftPodHostAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DraftAdapter } from "./draft-adapter";
import type { DraftPlayerView, PairingView, PodPolicy, PoolInput, SeatPublicView, TournamentFormat } from "./draft-adapter";
import type { MatchScore } from "./types";
import { P2PDraftHost, type DraftHostEvent } from "./p2p-draft-host";
import { resolveP2pBackupEndpoint } from "../config/multiplayerServer";
import { hostRoom, type HostResult } from "../network/connection";
import type { DraftMatchLaunch, DraftPauseReason } from "../network/draftProtocol";
import type { BrokerClient, RegisterHostRequest } from "../services/brokerClient";
Expand Down Expand Up @@ -207,7 +208,9 @@ export class DraftPodHostAdapter {
await new DraftAdapter().loadCardDatabase(await resp.text());
}

// 4. Create P2PDraftHost
// 4. Create P2PDraftHost. Wire the phase-server HTTP origin so
// best-effort `/p2p-draft-backup` uploads actually run in production
// (omitting backupEndpoint left the upload gate permanently closed).
const host = new P2PDraftHost(
hostResult.peer,
hostResult.onGuestConnected,
Expand All @@ -220,6 +223,7 @@ export class DraftPodHostAdapter {
undefined, // default grace period
config.persistenceId,
hostResult.roomCode,
resolveP2pBackupEndpoint() ?? undefined,
);

// 4. Wire host events
Expand Down
42 changes: 38 additions & 4 deletions client/src/adapter/p2p-draft-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ function hashStringToSeed(value: string): number {
return hash >>> 0;
}

/** Alphabet shared with `server_core::generate_draft_code` / `is_valid_draft_code`. */
const DRAFT_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

/**
* Generate a 6-character uppercase alphanumeric draft code matching the
* phase-server `/p2p-draft-backup` validator.
*/
export function generateP2pDraftCode(
randomValues: (size: number) => Uint8Array = (size) =>
crypto.getRandomValues(new Uint8Array(size)),
): string {
const values = randomValues(6);
let code = "";
for (let i = 0; i < 6; i++) {
code += DRAFT_CODE_ALPHABET[values[i]! % DRAFT_CODE_ALPHABET.length]!;
}
return code;
}

function sideboardFromPool(
session: ExportedDraftSession,
seat: number,
Expand Down Expand Up @@ -464,7 +483,9 @@ export class P2PDraftHost {

const seed = Math.floor(Math.random() * 0xffffffff);
this.draftSeed = seed;
const draftCode = `draft-${seed.toString(16).padStart(8, "0")}`;
// Must match server_core::is_valid_draft_code (6 uppercase alnum).
// The legacy `draft-xxxxxxxx` shape is rejected by phase-server with 400.
const draftCode = generateP2pDraftCode();
const seats: MultiplayerSeatDescriptor[] = [];
for (let i = 0; i < this.podSize; i++) {
const displayName = this.seatNames.get(i);
Expand Down Expand Up @@ -508,6 +529,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 = P2PDraftHost.BACKUP_INTERVAL_PICKS;
this.persistSession();
const freshHostView = await this.adapter.getViewForSeat(0);
this.emit({ type: "draftStarted", view: freshHostView });
Expand Down Expand Up @@ -1396,12 +1420,12 @@ export class P2PDraftHost {

/**
* Upload a backup snapshot to the phase-server (best-effort, D-08).
* Failures are silently logged — P2P works without server backup.
* Failures are logged — P2P works without server backup.
*/
private async uploadBackupSnapshot(snapshot: PersistedDraftHostSession): Promise<void> {
if (!this.backupEndpoint || !this.draftCode) return;
try {
await fetch(`${this.backupEndpoint}/p2p-draft-backup`, {
const response = await fetch(`${this.backupEndpoint}/p2p-draft-backup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand All @@ -1410,6 +1434,11 @@ export class P2PDraftHost {
snapshot_json: JSON.stringify(snapshot),
}),
});
if (!response.ok) {
console.warn(
`[P2PDraftHost] server backup upload failed: HTTP ${response.status}`,
);
}
} catch (err) {
console.warn("[P2PDraftHost] server backup upload failed:", err);
}
Expand All @@ -1422,10 +1451,15 @@ export class P2PDraftHost {
if (!this.backupEndpoint || !this.draftCode) return;
try {
const params = new URLSearchParams({ host_peer_id: this.hostPeer.id });
await fetch(
const response = await fetch(
`${this.backupEndpoint}/p2p-draft-backup/${this.draftCode}?${params}`,
{ method: "DELETE" },
);
if (!response.ok) {
console.warn(
`[P2PDraftHost] server backup cleanup failed: HTTP ${response.status}`,
);
}
} catch {
// Best-effort cleanup
}
Expand Down
40 changes: 40 additions & 0 deletions client/src/config/multiplayerServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,43 @@ export function isOfficialMultiplayerServerUrl(value: string): boolean {
return false;
}
}

/**
* Convert a multiplayer WebSocket URL to the HTTP origin that serves
* `/p2p-draft-backup` (and `/health`). Strips a trailing `/ws` path.
*
* Examples:
* - `wss://lobby.phase-rs.dev/ws` → `https://lobby.phase-rs.dev`
* - `ws://127.0.0.1:9374/ws` → `http://127.0.0.1:9374`
*/
export function wsUrlToHttpOrigin(wsUrl: string): string | null {
try {
const url = new URL(wsUrl);
if (url.protocol === "wss:") {
url.protocol = "https:";
} else if (url.protocol === "ws:") {
url.protocol = "http:";
} else {
return null;
}
if (url.pathname === "/ws" || url.pathname.endsWith("/ws")) {
url.pathname = url.pathname.replace(/\/ws\/?$/, "") || "/";
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle WebSocket URLs ending in /ws/.

For wss://host/ws/, pathname is /ws/, so the condition is false and backups are sent to /ws/p2p-draft-backup instead of /p2p-draft-backup. Match the optional trailing slash in the predicate and add that case to the converter test.

Proposed fix
-    if (url.pathname === "/ws" || url.pathname.endsWith("/ws")) {
+    if (/\/ws\/?$/.test(url.pathname)) {
       url.pathname = url.pathname.replace(/\/ws\/?$/, "") || "/";
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (url.pathname === "/ws" || url.pathname.endsWith("/ws")) {
url.pathname = url.pathname.replace(/\/ws\/?$/, "") || "/";
if (/\/ws\/?$/.test(url.pathname)) {
url.pathname = url.pathname.replace(/\/ws\/?$/, "") || "/";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/config/multiplayerServer.ts` around lines 35 - 36, Update the
WebSocket URL predicate in the multiplayer server URL converter to recognize
both `/ws` and `/ws/` suffixes, while preserving the existing pathname
normalization. Extend the converter tests to cover a URL ending in `/ws/` and
verify it produces the root backup path.

Source: Path instructions

}
url.search = "";
url.hash = "";
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return `${url.origin}${path}`;
} catch {
return null;
}
}

/**
* HTTP base URL for best-effort P2P draft server backups.
* Prefer an explicit Vite override, else the official multiplayer lobby.
*/
export function resolveP2pBackupEndpoint(
wsUrl: string = import.meta.env.VITE_WS_URL ?? OFFICIAL_MULTIPLAYER_SERVER_URL,
): string | null {
return wsUrlToHttpOrigin(wsUrl);
}
12 changes: 2 additions & 10 deletions crates/phase-server/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,12 @@ use serde_json::Value;
use tracing::{info, warn};

use server_core::{
guard_p2p_backup, guard_p2p_backup_overwrite, redact_p2p_backup_snapshot_secrets,
validate_p2p_backup_host_peer_id,
guard_p2p_backup, guard_p2p_backup_overwrite, is_valid_draft_code,
redact_p2p_backup_snapshot_secrets, validate_p2p_backup_host_peer_id,
};

use crate::AppState;

/// Validate draft code format: exactly 6 alphanumeric uppercase chars.
fn is_valid_draft_code(code: &str) -> bool {
code.len() == 6
&& code
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
}

/// GET /admin/drafts — List all active draft sessions with summary info.
pub async fn admin_list_drafts(State(app_state): State<AppState>) -> Json<Value> {
let drafts = app_state.draft_sessions.lock().await;
Expand Down
Loading
Loading