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:
parent
5fb96647eb
commit
de059fe707
20 changed files with 1419 additions and 398 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue