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( 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');
});

View file

@ -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;
} }
/** /**

View file

@ -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…"

View file

@ -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;

View file

@ -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 = {

View file

@ -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>;

View file

@ -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", () => {

View file

@ -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,