diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 673ac40..37e865e 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -87,8 +87,9 @@ const drag = async (page: Page, from: string, to: string): Promise => { */ async function wsCreateRoom( page: Page, + options?: { preferredColor?: 'white' | 'black' | 'random' }, ): Promise<{ code: string; token: string; color: string }> { - return page.evaluate(async () => { + return page.evaluate(async (opts: { preferredColor?: 'white' | 'black' | 'random' } | undefined) => { return new Promise<{ code: string; token: string; color: string }>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); @@ -97,13 +98,17 @@ async function wsCreateRoom( 5000, ); ws.onopen = () => { + const payload: Record = {}; + if (opts?.preferredColor !== undefined) { + payload.preferredColor = opts.preferredColor; + } ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: 'room.create', - payload: {}, + payload, }), ); }; @@ -128,7 +133,7 @@ async function wsCreateRoom( }; }, ); - }); + }, options); } /** @@ -401,3 +406,104 @@ test('multiplayer presets: white-only knights-leap-twice applies to white, not b await ctxA.close(); await ctxB.close(); }); + +// --------------------------------------------------------------------------- +// F1 (post-epic-deferrals): host color preference +// --------------------------------------------------------------------------- + +test('F1 color preference: host picks black → host=black, joiner=white', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + await pageA.goto('/'); + await pageB.goto('/'); + + const createdA = await wsCreateRoom(pageA, { preferredColor: 'black' }); + expect(createdA.color).toBe('black'); + + const joinedB = await wsJoinRoom(pageB, createdA.code); + expect(joinedB.color).toBe('white'); + expect(joinedB.code).toBe(createdA.code); + + await ctxA.close(); + await ctxB.close(); +}); + +test('F1 color preference: host picks random → both colors assigned disjointly', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + await pageA.goto('/'); + await pageB.goto('/'); + + const createdA = await wsCreateRoom(pageA, { preferredColor: 'random' }); + expect(['white', 'black']).toContain(createdA.color); + + const joinedB = await wsJoinRoom(pageB, createdA.code); + expect(['white', 'black']).toContain(joinedB.color); + // Disjoint: exactly one is white, the other black — never the same. + expect(joinedB.color).not.toBe(createdA.color); + + await ctxA.close(); + await ctxB.close(); +}); + +test('F1 color preference: default (no field) keeps legacy creator=white', async ({ + browser, +}) => { + // Back-compat sanity: omitting `preferredColor` from the payload + // must produce the pre-F1 hardcoded assignment. + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + await pageA.goto('/'); + + const createdA = await wsCreateRoom(pageA); // no options → no field on wire + expect(createdA.color).toBe('white'); + + await ctxA.close(); +}); + +test('F1 color preference: UI buttons render in Lobby and are toggleable', async ({ + page, +}) => { + await page.goto('/'); + // All three buttons exist. + await expect( + page.locator('[data-testid="color-preference-white"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="color-preference-black"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="color-preference-random"]'), + ).toBeVisible(); + + // Default is white — aria-checked reflects radiogroup semantics. + await expect( + page.locator('[data-testid="color-preference-white"]'), + ).toHaveAttribute('aria-checked', 'true'); + await expect( + page.locator('[data-testid="color-preference-black"]'), + ).toHaveAttribute('aria-checked', 'false'); + + // Click black → now checked. + await page.locator('[data-testid="color-preference-black"]').click(); + await expect( + page.locator('[data-testid="color-preference-black"]'), + ).toHaveAttribute('aria-checked', 'true'); + await expect( + page.locator('[data-testid="color-preference-white"]'), + ).toHaveAttribute('aria-checked', 'false'); + + // Click random → cycles again. + await page.locator('[data-testid="color-preference-random"]').click(); + await expect( + page.locator('[data-testid="color-preference-random"]'), + ).toHaveAttribute('aria-checked', 'true'); +}); diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 42c7b37..2f6851c 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -308,6 +308,16 @@ export interface ErrorPayload { // Client → Server payloads (shape for send()) // --------------------------------------------------------------------------- +/** + * Color the room CREATOR wants to play: + * - "white" → creator=white, joiner=black (default; legacy behaviour). + * - "black" → creator=black, joiner=white. + * - "random" → server resolves at creation time. + * Joiner has no corresponding preference in v1 — they take whichever + * color the creator didn't take. + */ +export type PreferredColor = "white" | "black" | "random"; + export interface RoomCreatePayload { rulesetIds?: string[]; /** Optional starting-layout selector. When omitted the server @@ -315,6 +325,11 @@ export interface RoomCreatePayload { layout?: LayoutRequest; /** Optional inline modifier profile applied at room creation. */ profile?: ModifierProfileWire; + /** + * Color the creator wants to play. When omitted defaults to + * `"white"` — backward-compatible with pre-F1 clients. + */ + preferredColor?: PreferredColor; } /** diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index 0bd34a4..f7c47c7 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -93,6 +93,13 @@ export function Lobby({ chessState }: LobbyProps = {}) { */ // Suggested rules driven by LayoutPicker const [presets, setPresets] = useState([]); + // F1 (post-epic-deferrals): host color preference. `"white"` is the + // default to match pre-F1 server behaviour; users can explicitly + // pick `"black"` or `"random"` via the preference buttons below the + // layout picker. + const [preferredColor, setPreferredColor] = useState< + 'white' | 'black' | 'random' + >('white'); const [additionalProfiles, setAdditionalProfiles] = useState< ModifierProfile[] @@ -308,6 +315,13 @@ export function Lobby({ chessState }: LobbyProps = {}) { if (presets.length > 0) { createPayload.rulesetIds = presets.map(p => p.id); } + // F1 (post-epic-deferrals): host color preference. + // Omit the field when left at the default ("white") so pre-F1 + // servers happily ignore the payload. Only send when the host + // explicitly chose "black" or "random". + if (preferredColor !== 'white') { + createPayload.preferredColor = preferredColor; + } const { code, token, color, layout: resolvedLayout, profile: echoedProfile } = await oneShotRoomRequest('room.create', createPayload); @@ -441,6 +455,51 @@ export function Lobby({ chessState }: LobbyProps = {}) { disabled={loading} /> + {/* F1 (post-epic-deferrals) — host color preference. + `"white"` is the back-compat default; we send the + field on the wire only when the user picks something + else. `"random"` is resolved server-side at room + creation, so the server's `room.created` response + carries the concrete assigned color. */} +
+ +
+ {(['white', 'black', 'random'] as const).map((choice) => { + const isActive = preferredColor === choice; + const label = + choice === 'white' + ? 'White' + : choice === 'black' + ? 'Black' + : 'Random'; + return ( + + ); + })} +
+
+ {/* Modifier profile picker (T26). The list is loaded from the local library on mount and refreshed when the ModifierProfileEditor closes. Selecting "Custom…" diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 2955afd..899326d 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -388,10 +388,14 @@ function handleRoomCreate( } const rulesetIds = payload.rulesetIds ?? []; + // F1: thread the host's color preference through. Omission defaults + // to `"white"` matching legacy behaviour — no change for pre-F1 + // clients that never sent the field. const { code, token, color } = roomRegistry.createRoom( [...rulesetIds], resolvedLayout, profile, + payload.preferredColor, ); sessionRegistry.create(code, rulesetIds, resolvedLayout, profile); ws.data.roomCode = code; diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index e372372..c46277c 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -428,6 +428,44 @@ describe("validateMessage — rejections", () => { // validateMessageString — raw-string entry point // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// F1.1 — preferredColor on room.create (post-epic Feature 1) +// --------------------------------------------------------------------------- + +describe("RoomCreatePayloadSchema preferredColor", () => { + it("accepts 'white'", () => { + const r = RoomCreatePayloadSchema.safeParse({ preferredColor: "white" }); + expect(r.success).toBe(true); + }); + it("accepts 'black'", () => { + const r = RoomCreatePayloadSchema.safeParse({ preferredColor: "black" }); + expect(r.success).toBe(true); + }); + it("accepts 'random'", () => { + const r = RoomCreatePayloadSchema.safeParse({ preferredColor: "random" }); + expect(r.success).toBe(true); + }); + it("omitted is valid (backward compat with pre-F1 clients)", () => { + const r = RoomCreatePayloadSchema.safeParse({}); + expect(r.success).toBe(true); + }); + it("rejects invalid color strings", () => { + const r = RoomCreatePayloadSchema.safeParse({ preferredColor: "grey" }); + expect(r.success).toBe(false); + }); + it("rejects non-string preferredColor", () => { + const r = RoomCreatePayloadSchema.safeParse({ preferredColor: 1 }); + expect(r.success).toBe(false); + }); + it("composes with rulesetIds + layout + profile", () => { + const r = RoomCreatePayloadSchema.safeParse({ + preferredColor: "random", + rulesetIds: ["piece-hp"], + }); + expect(r.success).toBe(true); + }); +}); + describe("validateMessageString", () => { it("parses a valid JSON string frame", () => { const msg: ClientMessage = { diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 53621a7..4bb787a 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -285,6 +285,21 @@ type _ModifierProfileKeyCheck = _AssertKeyEq< const _modifierProfileKeyCheck: _ModifierProfileKeyCheck = true; void _modifierProfileKeyCheck; +/** + * Preference for which color the ROOM CREATOR plays. + * - "white" → creator plays white, joiner plays black (default, + * matches legacy behaviour for old clients). + * - "black" → creator plays black, joiner plays white. + * - "random" → server flips a coin at room creation; creator gets + * the result, joiner gets the other color. + * + * Joiner-side preference is NOT supported in v1 (see + * `.sisyphus/plans/post-epic-deferrals.md` Feature 1). The joiner + * always takes whichever color the creator didn't take. + */ +export const PreferredColorSchema = z.enum(["white", "black", "random"]); +export type PreferredColor = z.infer; + export const RoomCreatePayloadSchema = z.object({ rulesetIds: z.array(z.string()).optional(), layout: LayoutRequestSchema.optional(), @@ -295,6 +310,13 @@ export const RoomCreatePayloadSchema = z.object({ * turn boundary (server-enforced). */ profile: ModifierProfileSchema.optional(), + /** + * Color the creator wants to play. Server resolves `"random"` at + * room-creation time; the joiner takes the remaining color. + * Omitting the field defaults to `"white"` — backward-compatible + * with legacy clients that never sent a preference. + */ + preferredColor: PreferredColorSchema.optional(), }); export type RoomCreatePayload = z.infer; diff --git a/packages/server/src/rooms.test.ts b/packages/server/src/rooms.test.ts index 62f92ae..01fb5df 100644 --- a/packages/server/src/rooms.test.ts +++ b/packages/server/src/rooms.test.ts @@ -38,6 +38,67 @@ describe("RoomRegistry.createRoom", () => { const { code } = reg.createRoom(); expect(reg.getRoom(code)?.rulesetIds).toEqual([]); }); + + // F1 (post-epic-deferrals): host color preference. + + it("F1: defaults to creator=white when preferredColor omitted (back-compat)", () => { + const reg = new RoomRegistry(); + const { code, color } = reg.createRoom(); + expect(color).toBe("white"); + const room = reg.getRoom(code); + expect(room?.creatorColor).toBe("white"); + expect(room?.joinerColor).toBe("black"); + }); + + it("F1: preferredColor=\"white\" → creator=white, joiner=black", () => { + const reg = new RoomRegistry(); + const { color } = reg.createRoom([], undefined, undefined, "white"); + expect(color).toBe("white"); + const join = reg.joinRoom( + reg.getRoomCount() === 1 + ? [...(reg as unknown as { rooms: Map }).rooms.keys()][0]! + : "", + ); + if ("error" in join) throw new Error(join.error); + expect(join.color).toBe("black"); + }); + + it("F1: preferredColor=\"black\" → creator=black, joiner=white", () => { + const reg = new RoomRegistry(); + const { code, color } = reg.createRoom([], undefined, undefined, "black"); + expect(color).toBe("black"); + const room = reg.getRoom(code); + expect(room?.creatorColor).toBe("black"); + expect(room?.joinerColor).toBe("white"); + const join = reg.joinRoom(code); + if ("error" in join) throw new Error(join.error); + expect(join.color).toBe("white"); + }); + + it("F1: preferredColor=\"random\" produces both outcomes across many rooms", () => { + // Sample 40 rooms; a fair coin should yield AT LEAST one of each + // color well within this window (failure probability < 2^-39). + const reg = new RoomRegistry(); + const outcomes = new Set(); + for (let i = 0; i < 40; i++) { + const { color } = reg.createRoom([], undefined, undefined, "random"); + outcomes.add(color); + if (outcomes.size === 2) break; + } + expect(outcomes).toEqual(new Set(["white", "black"])); + }); + + it("F1: random-resolved color is stored (no re-rolling on reconnect)", () => { + const reg = new RoomRegistry(); + const { code, color } = reg.createRoom([], undefined, undefined, "random"); + const room = reg.getRoom(code); + expect(room?.creatorColor).toBe(color); + expect(room?.joinerColor).toBe(color === "white" ? "black" : "white"); + // Joining reads the stored joinerColor — never re-rolls. + const join = reg.joinRoom(code); + if ("error" in join) throw new Error(join.error); + expect(join.color).toBe(color === "white" ? "black" : "white"); + }); }); describe("RoomRegistry.joinRoom", () => { diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index d7087c2..aa1dca1 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -45,6 +45,23 @@ export interface Room { * their tab retains host permissions. */ hostToken: string; + /** + * Color the room creator plays. Resolved from the `preferredColor` + * field on `room.create` at creation time — in particular, + * `"random"` is collapsed to a concrete color here and never leaks + * beyond `createRoom`. + * + * Feature 1 of `post-epic-deferrals`. Prior to F1 this was + * hardcoded to `"white"`. + */ + creatorColor: Color; + /** + * Color the eventual joiner plays. Computed as the opposite of + * `creatorColor` at room creation and stored so `joinRoom()` can + * return it without re-deriving the complement. Pre-F1 this was + * hardcoded to `"black"`. + */ + joinerColor: Color; /** Unix milliseconds the room was created — used for TTL/eviction later. */ createdAt: number; /** Rule presets activated for this game (from room.create payload). */ @@ -131,7 +148,12 @@ export interface Room { export type JoinResult = | { token: string; - color: "black"; + /** + * Post-F1 (`post-epic-deferrals` Feature 1) this can be either + * color — whichever color the creator DIDN'T take. Was + * literal-typed `"black"` before F1. + */ + color: Color; activeRules: string[]; layout: StartingLayout; profile?: ModifierProfile; @@ -183,8 +205,8 @@ export class RoomRegistry { private readonly rooms = new Map(); /** - * Create a fresh room. The caller becomes white and receives a UUID v4 - * token that authenticates every subsequent message. + * Create a fresh room. The caller receives a UUID v4 token that + * authenticates every subsequent message. * * `layout` is the resolved (already-validated) starting layout. * Callers construct this via `resolveLayoutRequest` in ./layouts.ts. @@ -193,23 +215,51 @@ export class RoomRegistry { * start. Callers are responsible for validating it via * `validateProfile` from @paratype/chess BEFORE handing it here — * this registry trusts its inputs. + * + * `preferredColor` (Feature 1 of `post-epic-deferrals`) picks the + * creator's color: + * - `"white"` (DEFAULT when omitted) → creator plays white, + * joiner plays black. Matches pre-F1 hardcoded behaviour + * exactly so legacy clients are unaffected. + * - `"black"` → creator plays black, joiner plays white. + * - `"random"` → server flips a fair coin here; creator gets + * the result, joiner gets the complement. The random draw + * happens exactly once per room creation — the concrete + * assignment is then stored on the room so reconnects + * surface the same color. + * + * Joiner-side preference is NOT supported (plan decision 1b); + * `joinRoom` just returns whatever color the creator didn't take. */ createRoom( rulesetIds: string[] = [], layout?: StartingLayout, profile?: ModifierProfile, + preferredColor: "white" | "black" | "random" = "white", ): { code: string; token: string; - color: "white"; + color: Color; layout: StartingLayout; profile?: ModifierProfile; } { + // Resolve random → concrete color. Math.random() is fine here: + // this is gameplay randomness (the joiner shouldn't be able to + // predict the draw, but nobody is paying for a CSPRNG) — no + // cryptographic guarantees needed. + const creatorColor: Color = + preferredColor === "random" + ? Math.random() < 0.5 + ? "white" + : "black" + : preferredColor; + const joinerColor: Color = creatorColor === "white" ? "black" : "white"; + const code = this.allocateCode(); const token = crypto.randomUUID(); const player: RoomPlayer = { token, - color: "white", + color: creatorColor, connected: true, lastSeq: 0, }; @@ -222,6 +272,8 @@ export class RoomRegistry { code, players: new Map([[token, player]]), hostToken: token, + creatorColor, + joinerColor, createdAt: Date.now(), // Defensive copy — callers shouldn't be able to mutate our state. rulesetIds: [...rulesetIds], @@ -232,7 +284,7 @@ export class RoomRegistry { return { code, token, - color: "white", + color: creatorColor, layout: resolvedLayout, ...(profile !== undefined ? { profile } : {}), }; @@ -250,16 +302,20 @@ export class RoomRegistry { if (!room) return { error: "ROOM_NOT_FOUND" }; if (room.players.size >= 2) return { error: "ROOM_FULL" }; const token = crypto.randomUUID(); + // Post-F1: read the assigned color off the room. The creator + // picked at room.create (or let the server roll random) and the + // complement was stored as `joinerColor`. + const color = room.joinerColor; const player: RoomPlayer = { token, - color: "black", + color, connected: true, lastSeq: 0, }; room.players.set(token, player); return { token, - color: "black", + color, // Defensive copy so joiners can't mutate the room's ruleset list. activeRules: [...room.rulesetIds], layout: room.layout,