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:
parent
4789a479ad
commit
f2dff7e530
8 changed files with 372 additions and 11 deletions
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
*/
|
||||
// Suggested rules driven by LayoutPicker
|
||||
const [presets, setPresets] = useState<PresetActivation[]>([]);
|
||||
// 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. */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Play as
|
||||
</label>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Host color preference"
|
||||
>
|
||||
{(['white', 'black', 'random'] as const).map((choice) => {
|
||||
const isActive = preferredColor === choice;
|
||||
const label =
|
||||
choice === 'white'
|
||||
? 'White'
|
||||
: choice === 'black'
|
||||
? 'Black'
|
||||
: 'Random';
|
||||
return (
|
||||
<button
|
||||
key={choice}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
data-testid={`color-preference-${choice}`}
|
||||
onClick={() => setPreferredColor(choice)}
|
||||
disabled={loading}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-semibold border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isActive
|
||||
? 'bg-neutral-900 text-white border-neutral-900 hover:bg-neutral-800'
|
||||
: 'bg-white text-neutral-700 border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modifier profile picker (T26). The list is loaded from
|
||||
the local library on mount and refreshed when the
|
||||
ModifierProfileEditor closes. Selecting "Custom…"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue