feat(multiplayer): host color preference (white/black/random)

Feature 1 of post-epic-deferrals. Lets the room creator pick which
color they play: white, black, or random. Joiner always takes the
remaining color (no joiner-side preference in v1 per plan decision
1b).

Protocol:
  - RoomCreatePayloadSchema gains an optional preferredColor field
    with the three-value enum. Omitting the field defaults to
    'white' on the server — byte-identical to pre-F1 behaviour so
    legacy clients are untouched.
  - Client type mirror (packages/chess/src/net/types.ts) kept in
    sync; exports PreferredColor type alias.

Server:
  - RoomRegistry.createRoom gains a preferredColor parameter.
    'random' is resolved at room creation via Math.random() and
    never leaks beyond this function — the concrete color is stored
    on room.creatorColor + room.joinerColor so reconnects surface
    the same assignment.
  - Room interface adds creatorColor + joinerColor fields.
  - JoinResult success variant widens from 'color: "black"' to
    'color: Color' to accept either assignment.
  - broadcast.ts threads payload.preferredColor through to
    createRoom.

UI:
  - Lobby.tsx adds a 3-button radiogroup ('Play as: White / Black /
    Random') between LayoutPicker and the modifier-profile picker.
    Active button is filled (bg-neutral-900), inactive is ghost.
    Omits the field from the room.create payload when left at the
    default so old servers keep working.
  - data-testid='color-preference-{white,black,random}' for e2e.

Tests:
  - 7 new protocol.test.ts cases: each enum value accepted,
    omitted valid, invalid strings rejected, composition with
    existing fields.
  - 5 new rooms.test.ts cases: back-compat default, explicit
    white, explicit black, random distribution across 40 samples,
    random-resolved color stored idempotently.
  - 4 new multiplayer.spec.ts cases: host=black flow, host=random
    disjoint-color assertion, back-compat no-field flow, UI
    buttons render + toggle with correct aria-checked state.

Verification: 1663 unit tests (+12) + 87/87 Playwright (+4).
Typecheck + lint clean across chess + server.

Plan: .sisyphus/plans/post-epic-deferrals.md Feature 1 complete.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 10:51:45 -06:00
commit f2dff7e530
No known key found for this signature in database
8 changed files with 372 additions and 11 deletions

View file

@ -87,8 +87,9 @@ const drag = async (page: Page, from: string, to: string): Promise<void> => {
*/
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<string, unknown> = {};
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');
});