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(
|
async function wsCreateRoom(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
options?: { preferredColor?: 'white' | 'black' | 'random' },
|
||||||
): Promise<{ code: string; token: string; color: string }> {
|
): 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 }>(
|
return new Promise<{ code: string; token: string; color: string }>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||||
|
|
@ -97,13 +98,17 @@ async function wsCreateRoom(
|
||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (opts?.preferredColor !== undefined) {
|
||||||
|
payload.preferredColor = opts.preferredColor;
|
||||||
|
}
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
type: 'room.create',
|
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 ctxA.close();
|
||||||
await ctxB.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())
|
// 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 {
|
export interface RoomCreatePayload {
|
||||||
rulesetIds?: string[];
|
rulesetIds?: string[];
|
||||||
/** Optional starting-layout selector. When omitted the server
|
/** Optional starting-layout selector. When omitted the server
|
||||||
|
|
@ -315,6 +325,11 @@ export interface RoomCreatePayload {
|
||||||
layout?: LayoutRequest;
|
layout?: LayoutRequest;
|
||||||
/** Optional inline modifier profile applied at room creation. */
|
/** Optional inline modifier profile applied at room creation. */
|
||||||
profile?: ModifierProfileWire;
|
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
|
// Suggested rules driven by LayoutPicker
|
||||||
const [presets, setPresets] = useState<PresetActivation[]>([]);
|
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<
|
const [additionalProfiles, setAdditionalProfiles] = useState<
|
||||||
ModifierProfile[]
|
ModifierProfile[]
|
||||||
|
|
@ -308,6 +315,13 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
if (presets.length > 0) {
|
if (presets.length > 0) {
|
||||||
createPayload.rulesetIds = presets.map(p => p.id);
|
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 } =
|
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
|
||||||
await oneShotRoomRequest('room.create', createPayload);
|
await oneShotRoomRequest('room.create', createPayload);
|
||||||
|
|
@ -441,6 +455,51 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
disabled={loading}
|
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
|
{/* Modifier profile picker (T26). The list is loaded from
|
||||||
the local library on mount and refreshed when the
|
the local library on mount and refreshed when the
|
||||||
ModifierProfileEditor closes. Selecting "Custom…"
|
ModifierProfileEditor closes. Selecting "Custom…"
|
||||||
|
|
|
||||||
|
|
@ -388,10 +388,14 @@ function handleRoomCreate(
|
||||||
}
|
}
|
||||||
|
|
||||||
const rulesetIds = payload.rulesetIds ?? [];
|
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(
|
const { code, token, color } = roomRegistry.createRoom(
|
||||||
[...rulesetIds],
|
[...rulesetIds],
|
||||||
resolvedLayout,
|
resolvedLayout,
|
||||||
profile,
|
profile,
|
||||||
|
payload.preferredColor,
|
||||||
);
|
);
|
||||||
sessionRegistry.create(code, rulesetIds, resolvedLayout, profile);
|
sessionRegistry.create(code, rulesetIds, resolvedLayout, profile);
|
||||||
ws.data.roomCode = code;
|
ws.data.roomCode = code;
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,44 @@ describe("validateMessage — rejections", () => {
|
||||||
// validateMessageString — raw-string entry point
|
// 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", () => {
|
describe("validateMessageString", () => {
|
||||||
it("parses a valid JSON string frame", () => {
|
it("parses a valid JSON string frame", () => {
|
||||||
const msg: ClientMessage = {
|
const msg: ClientMessage = {
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,21 @@ type _ModifierProfileKeyCheck = _AssertKeyEq<
|
||||||
const _modifierProfileKeyCheck: _ModifierProfileKeyCheck = true;
|
const _modifierProfileKeyCheck: _ModifierProfileKeyCheck = true;
|
||||||
void _modifierProfileKeyCheck;
|
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<typeof PreferredColorSchema>;
|
||||||
|
|
||||||
export const RoomCreatePayloadSchema = z.object({
|
export const RoomCreatePayloadSchema = z.object({
|
||||||
rulesetIds: z.array(z.string()).optional(),
|
rulesetIds: z.array(z.string()).optional(),
|
||||||
layout: LayoutRequestSchema.optional(),
|
layout: LayoutRequestSchema.optional(),
|
||||||
|
|
@ -295,6 +310,13 @@ export const RoomCreatePayloadSchema = z.object({
|
||||||
* turn boundary (server-enforced).
|
* turn boundary (server-enforced).
|
||||||
*/
|
*/
|
||||||
profile: ModifierProfileSchema.optional(),
|
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<typeof RoomCreatePayloadSchema>;
|
export type RoomCreatePayload = z.infer<typeof RoomCreatePayloadSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,67 @@ describe("RoomRegistry.createRoom", () => {
|
||||||
const { code } = reg.createRoom();
|
const { code } = reg.createRoom();
|
||||||
expect(reg.getRoom(code)?.rulesetIds).toEqual([]);
|
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<string, unknown> }).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<string>();
|
||||||
|
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", () => {
|
describe("RoomRegistry.joinRoom", () => {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,23 @@ export interface Room {
|
||||||
* their tab retains host permissions.
|
* their tab retains host permissions.
|
||||||
*/
|
*/
|
||||||
hostToken: string;
|
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. */
|
/** Unix milliseconds the room was created — used for TTL/eviction later. */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Rule presets activated for this game (from room.create payload). */
|
/** Rule presets activated for this game (from room.create payload). */
|
||||||
|
|
@ -131,7 +148,12 @@ export interface Room {
|
||||||
export type JoinResult =
|
export type JoinResult =
|
||||||
| {
|
| {
|
||||||
token: string;
|
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[];
|
activeRules: string[];
|
||||||
layout: StartingLayout;
|
layout: StartingLayout;
|
||||||
profile?: ModifierProfile;
|
profile?: ModifierProfile;
|
||||||
|
|
@ -183,8 +205,8 @@ export class RoomRegistry {
|
||||||
private readonly rooms = new Map<string, Room>();
|
private readonly rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a fresh room. The caller becomes white and receives a UUID v4
|
* Create a fresh room. The caller receives a UUID v4 token that
|
||||||
* token that authenticates every subsequent message.
|
* authenticates every subsequent message.
|
||||||
*
|
*
|
||||||
* `layout` is the resolved (already-validated) starting layout.
|
* `layout` is the resolved (already-validated) starting layout.
|
||||||
* Callers construct this via `resolveLayoutRequest` in ./layouts.ts.
|
* Callers construct this via `resolveLayoutRequest` in ./layouts.ts.
|
||||||
|
|
@ -193,23 +215,51 @@ export class RoomRegistry {
|
||||||
* start. Callers are responsible for validating it via
|
* start. Callers are responsible for validating it via
|
||||||
* `validateProfile` from @paratype/chess BEFORE handing it here —
|
* `validateProfile` from @paratype/chess BEFORE handing it here —
|
||||||
* this registry trusts its inputs.
|
* 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(
|
createRoom(
|
||||||
rulesetIds: string[] = [],
|
rulesetIds: string[] = [],
|
||||||
layout?: StartingLayout,
|
layout?: StartingLayout,
|
||||||
profile?: ModifierProfile,
|
profile?: ModifierProfile,
|
||||||
|
preferredColor: "white" | "black" | "random" = "white",
|
||||||
): {
|
): {
|
||||||
code: string;
|
code: string;
|
||||||
token: string;
|
token: string;
|
||||||
color: "white";
|
color: Color;
|
||||||
layout: StartingLayout;
|
layout: StartingLayout;
|
||||||
profile?: ModifierProfile;
|
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 code = this.allocateCode();
|
||||||
const token = crypto.randomUUID();
|
const token = crypto.randomUUID();
|
||||||
const player: RoomPlayer = {
|
const player: RoomPlayer = {
|
||||||
token,
|
token,
|
||||||
color: "white",
|
color: creatorColor,
|
||||||
connected: true,
|
connected: true,
|
||||||
lastSeq: 0,
|
lastSeq: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -222,6 +272,8 @@ export class RoomRegistry {
|
||||||
code,
|
code,
|
||||||
players: new Map([[token, player]]),
|
players: new Map([[token, player]]),
|
||||||
hostToken: token,
|
hostToken: token,
|
||||||
|
creatorColor,
|
||||||
|
joinerColor,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
// Defensive copy — callers shouldn't be able to mutate our state.
|
// Defensive copy — callers shouldn't be able to mutate our state.
|
||||||
rulesetIds: [...rulesetIds],
|
rulesetIds: [...rulesetIds],
|
||||||
|
|
@ -232,7 +284,7 @@ export class RoomRegistry {
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
token,
|
token,
|
||||||
color: "white",
|
color: creatorColor,
|
||||||
layout: resolvedLayout,
|
layout: resolvedLayout,
|
||||||
...(profile !== undefined ? { profile } : {}),
|
...(profile !== undefined ? { profile } : {}),
|
||||||
};
|
};
|
||||||
|
|
@ -250,16 +302,20 @@ export class RoomRegistry {
|
||||||
if (!room) return { error: "ROOM_NOT_FOUND" };
|
if (!room) return { error: "ROOM_NOT_FOUND" };
|
||||||
if (room.players.size >= 2) return { error: "ROOM_FULL" };
|
if (room.players.size >= 2) return { error: "ROOM_FULL" };
|
||||||
const token = crypto.randomUUID();
|
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 = {
|
const player: RoomPlayer = {
|
||||||
token,
|
token,
|
||||||
color: "black",
|
color,
|
||||||
connected: true,
|
connected: true,
|
||||||
lastSeq: 0,
|
lastSeq: 0,
|
||||||
};
|
};
|
||||||
room.players.set(token, player);
|
room.players.set(token, player);
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
color: "black",
|
color,
|
||||||
// Defensive copy so joiners can't mutate the room's ruleset list.
|
// Defensive copy so joiners can't mutate the room's ruleset list.
|
||||||
activeRules: [...room.rulesetIds],
|
activeRules: [...room.rulesetIds],
|
||||||
layout: room.layout,
|
layout: room.layout,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue