feat(chess): per-color preset scope, turn-limited duration, server-authoritative sync

Presets previously lived on a process-global singleton with only a
binary on/off toggle. Two bugs followed:

1. Multiplayer illegal-move errors — the client-side toggle didn`t
   reach the server, so optimistic moves legal under client rules got
   rejected by the server`s unmodified ChessEngine.
2. No way to apply a rule to just white or just black, or to time-box
   it for N turns.

Replaces the shared `PRESET_REGISTRY.active: Set<string>` with
instance-owned `ChessEngine.activePresets: ActivePresetSet`. Each
activation carries:

  - scope: `both` | `white` | `black`
  - turnsRemaining: positive int or null (permanent)

Engine reads `getForColor(color)` per piece, so scope=white never
contributes moves during black`s turn. `applyMove` calls
`tickAfterMove(moverColor)` which implements player-local counting:
white-only durations tick only when white moves.

Compatibility is the LOOSE rule — `incompatibleWith` blocks only when
the two activations have overlapping scopes. `scope=white` + `scope=black`
pair of otherwise-incompatible presets is allowed because the engine
never evaluates both for the same side.

Server changes: GameSession owns an ActivePresetSet. New protocol
messages:

  - client → server: `room.setPresets` with full activation list
  - server → client: `game.presets` broadcast on every set change
    (post-setPresets + post-move-with-expiry)

`game.state` snapshots now include `activations` so reconnects pick
up the current rule set without extra round-trips.

Client changes: PredictionManager applies `game.presets` to the base
engine`s ActivePresetSet and re-renders via onStateChange; cloneEngine
carries activations onto the predicted clone. New hook surface:

  - activations: readonly PresetActivation[]
  - setPresets(next): replace the active set

useMultiplayerGame dispatches setPresets through the socket
(server-authoritative); useChessEngine mutates in-place (local mode).

UI: RulesDrawer + RulesView render scope radios (Both/White/Black)
and a duration input per active preset. Empty duration means
permanent, positive integers last N player-local turns.

Tests:

  - 15 new ActivePresetSet unit tests (scope, tick, loose compat,
    atomicity, clone)
  - 4 new engine-presets integration tests (per-color, duration,
    white-only vs black-only)
  - Migrated older preset tests from `PRESET_REGISTRY.activate` to
    the instance API
  - New E2E regression test: enable knights-leap-twice scope=white in
    multiplayer; verify the double-leap is accepted by the server,
    verify black`s knight cannot use it
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 14:23:37 -06:00
commit de059fe707
No known key found for this signature in database
20 changed files with 1419 additions and 398 deletions

View file

@ -26,8 +26,10 @@ import {
type ErrorCode,
type Fact as WireFact,
type GameMovePayload,
type PresetActivation,
type RoomCreatePayload,
type RoomJoinPayload,
type RoomSetPresetsPayload,
type ServerMessage,
} from "./protocol.js";
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
@ -228,11 +230,15 @@ export function handleMessage(
case "game.move":
handleGameMove(ws, msg.payload);
break;
case "room.setPresets":
handleSetPresets(ws, msg.payload);
break;
case "room.created":
case "room.joined":
case "game.state":
case "game.delta":
case "game.end":
case "game.presets":
case "error":
sendTo(
ws,
@ -357,6 +363,7 @@ function handleRoomJoin(
lastSeq: 0,
moveHistory: [],
activeRules: [...result.activeRules],
activations: session.getPresetActivations(),
// fen is a UI convenience for v1; we haven't wired FEN generation
// on the server yet, so we send an empty string. Clients that need
// FEN can derive it from `facts`.
@ -446,6 +453,7 @@ function handleReconnect(
lastSeq: missed.length > 0 ? (missed[missed.length - 1]?.seq ?? 0) : 0,
moveHistory: [],
activeRules: [...room.rulesetIds],
activations: session.getPresetActivations(),
fen: "",
}),
);
@ -529,6 +537,12 @@ function handleGameMove(
return;
}
// Snapshot the preset set before the move so we can detect whether
// any durations expired during `tickAfterMove`. We only broadcast
// `game.presets` when the set actually changed — otherwise the
// message is noise.
const presetsBefore = session.getPresetActivations();
const tickStart = performance.now();
const moveResult = session.applyMove(
payload.from,
@ -575,6 +589,16 @@ function handleGameMove(
broadcastToRoom(roomCode, deltaMsg);
bufferDeltaForDisconnected(roomCode, token, deltaMsg.seq, deltaPayload);
// If any preset durations expired during this move's tick, push the
// new set so clients stop rendering those rules. We skip the broadcast
// when the set is byte-identical to pre-move — the common case —
// so a vanilla game doesn't generate a `game.presets` message on
// every half-move.
const presetsAfter = session.getPresetActivations();
if (JSON.stringify(presetsBefore) !== JSON.stringify(presetsAfter)) {
broadcastPresets(roomCode, presetsAfter);
}
// Terminal positions also get an explicit game.end for clarity per
// PROTOCOL.md §game.end. `finalFen` is empty for v1 (see note above).
if (moveResult.gameOver !== null) {
@ -589,6 +613,52 @@ function handleGameMove(
}
}
function handleSetPresets(
ws: ServerWebSocket<ClientData>,
payload: RoomSetPresetsPayload,
): void {
const { roomCode, token } = ws.data;
if (roomCode === undefined || token === undefined) {
sendTo(
ws,
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
);
return;
}
const session = sessionRegistry.get(roomCode);
if (!session) {
sendTo(
ws,
errorMessage("INVALID_MESSAGE", "internal error: missing game session", true),
);
ws.close();
return;
}
// Validate by handing to the GameSession which delegates to
// ActivePresetSet. Bad inputs (unknown id, incompatible pair under
// overlapping scope, missing requirement) come back as structured
// errors that we surface as INVALID_MESSAGE so the client can show
// them without disconnecting.
const result = session.setPresets(payload.activations);
if (!result.ok) {
sendTo(ws, errorMessage("INVALID_MESSAGE", result.error, false));
return;
}
broadcastPresets(roomCode, session.getPresetActivations());
}
/** Broadcast the current preset set to everyone in `code`. Used both
* after an explicit `room.setPresets` and after a move whose duration
* tick expired some entries. */
function broadcastPresets(
code: string,
activations: PresetActivation[],
): void {
broadcastToRoom(code, envelope("game.presets", { activations }));
}
/**
* For every OTHER player in `code` whose slot is mid-grace-window
* (disconnected), buffer a copy of a just-broadcast delta so the

View file

@ -12,6 +12,9 @@
import {
ChessEngine,
algebraicToSquare,
PresetActivationError,
type ActivationRequest,
type PresetActivation,
type GameResult,
type PieceColor,
type PieceType,
@ -87,16 +90,64 @@ export class GameSession {
> | null = null;
/**
* @param _rulesetIds — activated preset IDs from room.create. v1: accepted
* for API shape but not yet wired to ChessEngine. Preset activation is
* tracked by PRESET_REGISTRY which is process-global today; per-room
* preset isolation is a follow-up (tracked in PROTOCOL.md).
* @param rulesetIds — preset IDs from room.create. Each is activated
* as `scope=both, turnsRemaining=null` (permanent) on the engine's
* private ActivePresetSet. Invalid ids are silently skipped here —
* the server validates them earlier at the protocol layer.
*/
constructor(_rulesetIds: readonly string[] = []) {
constructor(rulesetIds: readonly string[] = []) {
this.engine = new ChessEngine();
if (rulesetIds.length > 0) {
try {
this.engine.activePresets.replaceAll(
rulesetIds.map((id) => ({
id,
scope: "both" as const,
turnsRemaining: null,
})),
);
} catch {
// A bad initial rulesetId shouldn't prevent session creation —
// start empty and let clients reconfigure via room.setPresets.
this.engine.activePresets.clear();
}
}
this.prevFacts = this.snapshotFacts();
}
/**
* Replace the full active preset set. Returns `{ ok: true }` on
* success so the server can then broadcast `game.presets`; returns
* `{ ok: false, error }` on validation failure (unknown id,
* incompatibility, missing requirement) so the server can surface
* an `INVALID_MESSAGE` error to the requesting client.
*
* Rejections leave the pre-existing set intact (see
* ActivePresetSet.replaceAll for atomicity).
*/
setPresets(
activations: readonly ActivationRequest[],
): { ok: true } | { ok: false; error: string } {
try {
this.engine.activePresets.replaceAll(activations);
return { ok: true };
} catch (e) {
const msg =
e instanceof PresetActivationError
? `${e.code}: ${e.message}`
: e instanceof Error
? e.message
: String(e);
return { ok: false, error: msg };
}
}
/** Snapshot of the current active preset set, wire-shape. Used by
* broadcast to populate `game.state.activations` and `game.presets`. */
getPresetActivations(): PresetActivation[] {
return this.engine.activePresets.list();
}
/** Returns a fresh snapshot of current facts in deterministic order. */
getAllFacts(): Fact[] {
return this.snapshotFacts();

View file

@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({
});
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
// ---------------------------------------------------------------------------
// Preset scope + activation — used by both directions of the preset sync.
// ---------------------------------------------------------------------------
export const PresetScopeSchema = z.enum(["both", "white", "black"]);
export type PresetScope = z.infer<typeof PresetScopeSchema>;
/**
* Wire shape for one active preset. `turnsRemaining` is `null` for
* permanent activations; otherwise a positive integer count of
* player-local turns (see ActivePresetSet for counting policy).
*/
export const PresetActivationSchema = z.object({
id: z.string().min(1),
scope: PresetScopeSchema,
turnsRemaining: z.number().int().positive().nullable(),
});
export type PresetActivation = z.infer<typeof PresetActivationSchema>;
/**
* Client → server: replace the room's entire active preset set. Server
* validates (catalog lookup, pairwise compatibility under the scope-overlap
* rule, requires) and either accepts — broadcasting `game.presets` to all
* players — or rejects with `INVALID_MESSAGE`.
*
* We chose "replace whole set" over "toggle one" because it sidesteps
* ordering issues when two clients race toggles and gives us idempotency
* for reconnect: the server just re-sends the current set on game.state.
*/
export const RoomSetPresetsPayloadSchema = z.object({
activations: z.array(PresetActivationSchema),
});
export type RoomSetPresetsPayload = z.infer<typeof RoomSetPresetsPayloadSchema>;
// ---------------------------------------------------------------------------
// Server → Client payloads
// ---------------------------------------------------------------------------
@ -133,11 +167,27 @@ export const GameStatePayloadSchema = z.object({
turn: ColorSchema,
lastSeq: z.number().int().nonnegative(),
moveHistory: z.array(z.string()),
/** Legacy rule-id list (v1 compat). Present alongside `activations`
* which is the new authoritative shape. */
activeRules: z.array(z.string()),
/** Full preset activation set authoritative on the server. Optional
* on the wire so older servers don't break the schema check. */
activations: z.array(PresetActivationSchema).optional(),
fen: z.string(),
});
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
/**
* Server → client: authoritative preset set just changed. Sent after a
* successful `room.setPresets` or after a move that expired durations.
* Clients apply this directly to their local engines' ActivePresetSet
* — never store rules locally, always mirror what the server says.
*/
export const GamePresetsPayloadSchema = z.object({
activations: z.array(PresetActivationSchema),
});
export type GamePresetsPayload = z.infer<typeof GamePresetsPayloadSchema>;
export const GameOverSchema = z.object({
winner: WinnerSchema,
reason: GameEndReasonSchema,
@ -188,12 +238,17 @@ export const RoomCreateMessageSchema = msg(
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
export const RoomSetPresetsMessageSchema = msg(
"room.setPresets",
RoomSetPresetsPayloadSchema,
);
export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
RoomJoinMessageSchema,
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
]);
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
@ -208,6 +263,10 @@ export const RoomJoinedMessageSchema = msg(
export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema);
export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema);
export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema);
export const GamePresetsMessageSchema = msg(
"game.presets",
GamePresetsPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -216,6 +275,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -225,11 +285,13 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
RoomJoinMessageSchema,
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -239,11 +301,13 @@ export const KNOWN_MESSAGE_TYPES = [
"room.join",
"room.leave",
"game.move",
"room.setPresets",
"room.created",
"room.joined",
"game.state",
"game.delta",
"game.end",
"game.presets",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];