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
|
|
@ -317,3 +317,83 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate",
|
|||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Scope bug regression test.
|
||||
*
|
||||
* Before per-color preset scoping shipped, a client that enabled
|
||||
* `knights-leap-twice` locally would predict a double-leap move
|
||||
* (legal under the client's rule set), send it to the server, and
|
||||
* receive ILLEGAL_MOVE because the server's ChessEngine didn't know
|
||||
* the preset was active. The snap-back rolled the board back and the
|
||||
* user saw an error toast.
|
||||
*
|
||||
* With per-room authoritative rule state + `room.setPresets`, the
|
||||
* client dispatches the activation to the server, which broadcasts
|
||||
* `game.presets` to both players. The next move calc on both sides
|
||||
* uses the same rule set, so predictions and validations agree.
|
||||
*
|
||||
* The test specifically exercises `scope=white`: only white's knights
|
||||
* can double-leap, proving both that the server respects the preset
|
||||
* AND that the scope narrowing reaches the server.
|
||||
*/
|
||||
test('multiplayer presets: white-only knights-leap-twice applies to white, not black', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const pageA = await ctxA.newPage();
|
||||
const pageB = await ctxB.newPage();
|
||||
|
||||
// 1. Room create/join.
|
||||
await pageA.goto('http://localhost:5173/');
|
||||
const roomA = await wsCreateRoom(pageA);
|
||||
await pageA.evaluate((r) => {
|
||||
sessionStorage.setItem('room-code', r.code);
|
||||
sessionStorage.setItem('room-token', r.token);
|
||||
sessionStorage.setItem('player-color', r.color);
|
||||
}, roomA);
|
||||
await pageA.goto('http://localhost:5173/game');
|
||||
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
|
||||
|
||||
await pageB.goto('http://localhost:5173/');
|
||||
const roomB = await wsJoinRoom(pageB, roomA.code);
|
||||
await pageB.evaluate((r) => {
|
||||
sessionStorage.setItem('room-code', r.code);
|
||||
sessionStorage.setItem('room-token', r.token);
|
||||
sessionStorage.setItem('player-color', r.color);
|
||||
}, roomB);
|
||||
await pageB.goto('http://localhost:5173/game');
|
||||
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
|
||||
|
||||
// 2. White enables knights-leap-twice with scope=white via the drawer.
|
||||
await pageA.locator('[data-action="open-rules-drawer"]').click();
|
||||
const knightRow = pageA.locator('[data-preset="knights-leap-twice"]');
|
||||
await knightRow.locator('[data-role="toggle"]').click();
|
||||
// Default scope is 'both' — change to 'white'.
|
||||
await knightRow.locator('[data-role="scope-white"]').click();
|
||||
await pageA.locator('[data-action="close-rules-drawer"]').click();
|
||||
|
||||
// 3. Wait for pageB to receive the `game.presets` broadcast and render
|
||||
// the active-preset count in its drawer pill.
|
||||
await expect(pageB.locator('[data-action="open-rules-drawer"]')).toContainText('1');
|
||||
|
||||
// 4. White drags g1 knight to d4 — a double-leap that's ONLY legal
|
||||
// with knights-leap-twice active. Before the fix this produced an
|
||||
// ILLEGAL_MOVE; after the fix the move is accepted.
|
||||
await drag(pageA, 'g1', 'd4');
|
||||
await expect(pageA.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
|
||||
await expect(pageB.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
|
||||
|
||||
// 5. Black attempts the same double-leap with its g8 knight. Preset
|
||||
// scope is white-only so this must NOT be legal for black.
|
||||
// `dragTo` still performs the mouse movement but the client's
|
||||
// legal-move set won't include it, so the board stays unchanged.
|
||||
await drag(pageB, 'g8', 'd5');
|
||||
await expect(pageB.locator('[data-square="d5"]')).not.toContainText('black-knight');
|
||||
// The knight should still be on g8.
|
||||
await expect(pageB.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible();
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
/**
|
||||
* Integration tests proving that toggling presets on PRESET_REGISTRY
|
||||
* actually changes what ChessEngine.getAllLegalMoves() returns.
|
||||
* Integration tests proving that mutating a ChessEngine's
|
||||
* `activePresets` set actually changes what `getAllLegalMoves()`
|
||||
* returns — both for adding moves (getExtraMoves) and removing them
|
||||
* (filterMoves).
|
||||
*
|
||||
* These tests cross the preset → engine boundary, so they live at the
|
||||
* package root rather than under presets/.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ChessEngine } from "./engine.js";
|
||||
import { PRESET_REGISTRY } from "./presets/index.js";
|
||||
import "./presets/index.js";
|
||||
import { algebraicToSquare } from "./coord.js";
|
||||
|
||||
describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||
beforeEach(() => PRESET_REGISTRY.clear());
|
||||
afterEach(() => PRESET_REGISTRY.clear());
|
||||
|
||||
describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Move e2 → e4 (standard two-square advance)
|
||||
const advance = engine.findMove(
|
||||
algebraicToSquare("e2"),
|
||||
algebraicToSquare("e4"),
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
expect(advance).not.toBeNull();
|
||||
engine.applyMove(advance!);
|
||||
|
||||
// Black plays something random so it's white's turn again
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
|
||||
// Now try to move e4 → e3 (backward). Standard rules forbid it.
|
||||
const backward = engine.findMove(
|
||||
algebraicToSquare("e4"),
|
||||
algebraicToSquare("e3"),
|
||||
|
|
@ -37,11 +29,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
|||
expect(backward).toBeNull();
|
||||
});
|
||||
|
||||
it("pawns-move-backward preset: e4 pawn CAN move back to e3", () => {
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
it("pawns-move-backward (scope=both): e4 pawn CAN move back to e3", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Same setup: push e2 → e4, black plays e7 → e5
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
|
|
@ -49,65 +42,55 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
|||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
|
||||
// NOW backward move should be legal with preset active.
|
||||
const backward = engine.findMove(
|
||||
algebraicToSquare("e4"),
|
||||
algebraicToSquare("e3"),
|
||||
);
|
||||
expect(backward).not.toBeNull();
|
||||
expect(backward!.from).toBe(algebraicToSquare("e4"));
|
||||
expect(backward!.to).toBe(algebraicToSquare("e3"));
|
||||
expect(backward!.isCapture).toBe(false);
|
||||
});
|
||||
|
||||
it("deactivating a preset mid-session removes its extra moves immediately", () => {
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
|
||||
// Preset active: backward legal
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||
).not.toBeNull();
|
||||
|
||||
// Deactivate — engine should immediately reflect the change
|
||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
||||
engine.activePresets.replaceAll([]);
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("pawn-diagonal-no-capture preset ('Slanting Pawns'): adds diagonal quiet moves to empty squares", () => {
|
||||
it("pawn-diagonal-no-capture (scope=both): adds diagonal quiet moves", () => {
|
||||
const engine = new ChessEngine();
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("f3")),
|
||||
).toBeNull();
|
||||
|
||||
// Before activation: a pawn on e2 cannot move diagonally to an empty
|
||||
// d3 or f3 square (those squares are empty from the starting position,
|
||||
// and FIDE pawns only go diagonal when capturing).
|
||||
const diagonalEmptyBefore = engine.findMove(
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
const diag = engine.findMove(
|
||||
algebraicToSquare("e2"),
|
||||
algebraicToSquare("f3"),
|
||||
);
|
||||
expect(diagonalEmptyBefore).toBeNull();
|
||||
|
||||
// With preset active: e2 → f3 (empty, diagonal) becomes legal.
|
||||
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
||||
const diagonalEmptyAfter = engine.findMove(
|
||||
algebraicToSquare("e2"),
|
||||
algebraicToSquare("f3"),
|
||||
);
|
||||
expect(diagonalEmptyAfter).not.toBeNull();
|
||||
expect(diagonalEmptyAfter!.isCapture).toBe(false);
|
||||
expect(diag).not.toBeNull();
|
||||
expect(diag!.isCapture).toBe(false);
|
||||
});
|
||||
|
||||
it("mid-game toggle: activating a preset after moves already played affects the very next move calculation", () => {
|
||||
it("mid-game toggle: activating a preset affects the very next move calc", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Play 3 moves of a normal game — no presets active.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
|
|
@ -118,31 +101,21 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
|||
engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!,
|
||||
);
|
||||
|
||||
// Black to move. Under standard rules the black e5 pawn cannot
|
||||
// retreat to e6.
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")),
|
||||
).toBeNull();
|
||||
|
||||
// Activate the preset NOW — no board reset, same engine instance.
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
|
||||
// Same engine, same position — but the backward pawn move is now legal
|
||||
// because `getAllLegalMoves` reads the registry fresh on every call.
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
const retreat = engine.findMove(
|
||||
algebraicToSquare("e5"),
|
||||
algebraicToSquare("e6"),
|
||||
);
|
||||
expect(retreat).not.toBeNull();
|
||||
|
||||
// Black actually plays the retreat and the engine accepts it.
|
||||
engine.applyMove(retreat!);
|
||||
expect(engine.getCurrentTurn()).toBe("white");
|
||||
|
||||
// Toggle off — next move calculation drops the extra move set.
|
||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
||||
// After the retreat, black's pawn is now on e6. It's white's turn.
|
||||
// Play a white move, then check black can no longer retreat e6→e7.
|
||||
engine.activePresets.replaceAll([]);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
||||
);
|
||||
|
|
@ -152,11 +125,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
|||
});
|
||||
|
||||
it("multiple presets compose: backward + diagonal-no-capture both apply", () => {
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Move a pawn up and back — both legal under this combo.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
|
|
@ -164,10 +138,94 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
|||
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||
);
|
||||
|
||||
const backward = engine.findMove(
|
||||
algebraicToSquare("e4"),
|
||||
algebraicToSquare("e3"),
|
||||
);
|
||||
expect(backward).not.toBeNull();
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
describe("per-color scope", () => {
|
||||
it("white-only preset does NOT apply to black's moves", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "white", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// White to move — double-leap should be among the legal moves for
|
||||
// the white g1 knight (g1 → e3 via f3 intermediate for instance).
|
||||
const whiteMoves = engine.getAllLegalMoves();
|
||||
const whiteKnightDoubleLeaps = whiteMoves.filter(
|
||||
(m) => m.from === algebraicToSquare("g1"),
|
||||
);
|
||||
// Standard single leaps: f3, h3. Double-leap adds extras.
|
||||
expect(whiteKnightDoubleLeaps.length).toBeGreaterThan(2);
|
||||
|
||||
// Apply a neutral white move so it's black's turn.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
||||
);
|
||||
|
||||
// Now black to move — the preset is white-scope, so black's
|
||||
// knights should only have the 2 standard single leaps.
|
||||
const blackMoves = engine.getAllLegalMoves();
|
||||
const blackKnightMoves = blackMoves.filter(
|
||||
(m) => m.from === algebraicToSquare("g8"),
|
||||
);
|
||||
expect(blackKnightMoves).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("black-only preset does NOT apply to white's moves", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "black", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
const whiteKnightMoves = engine
|
||||
.getAllLegalMoves()
|
||||
.filter((m) => m.from === algebraicToSquare("g1"));
|
||||
// Standard knight has only 2 legal leaps from g1.
|
||||
expect(whiteKnightMoves).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn-limited duration", () => {
|
||||
it("preset expires after its duration elapses", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: 2 },
|
||||
]);
|
||||
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
// After white's move, scope=both ticks → 1 remaining.
|
||||
expect(
|
||||
engine.activePresets.list()[0]?.turnsRemaining,
|
||||
).toBe(1);
|
||||
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||
);
|
||||
// After black's move, 0 → removed.
|
||||
expect(engine.activePresets.list()).toHaveLength(0);
|
||||
|
||||
// Further backward move attempts should fail.
|
||||
expect(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("scope=white duration ticks only on white's turns", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "white", turnsRemaining: 1 },
|
||||
]);
|
||||
|
||||
// Black moves first? No — white always moves first. Play a white
|
||||
// move — that ticks the white counter from 1 → expired.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
expect(engine.activePresets.list()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ import {
|
|||
} from "./rules/draws.js";
|
||||
import { applyCapture } from "./rules/capture.js";
|
||||
import type { LegalMove } from "./rules/types.js";
|
||||
import { PRESET_REGISTRY } from "./presets/index.js";
|
||||
import { ActivePresetSet } from "./presets/active-set.js";
|
||||
// Importing from the barrel guarantees every preset module's
|
||||
// side-effect registration has run before the first engine is created.
|
||||
import "./presets/index.js";
|
||||
|
||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||
|
||||
|
|
@ -70,11 +73,24 @@ export type GameResult =
|
|||
|
||||
export class ChessEngine {
|
||||
public readonly session: Session;
|
||||
/**
|
||||
* Per-engine preset activation. Every engine owns its own set so two
|
||||
* concurrent games (most importantly: the authoritative server session
|
||||
* and a client's predicted clone) can hold different rule sets without
|
||||
* a shared-module dance.
|
||||
*
|
||||
* Mutable by design — UIs and servers replace its contents via
|
||||
* `.replaceAll()`. The engine reads it fresh on every call to
|
||||
* `getAllLegalMoves()` so mid-game toggles take effect on the next
|
||||
* move calculation with no reset.
|
||||
*/
|
||||
public readonly activePresets: ActivePresetSet;
|
||||
|
||||
constructor() {
|
||||
constructor(activePresets?: ActivePresetSet) {
|
||||
this.session = new Session({ autoFire: false });
|
||||
generateStartingPosition(this.session);
|
||||
recordPosition(this.session);
|
||||
this.activePresets = activePresets ?? new ActivePresetSet();
|
||||
}
|
||||
|
||||
getCurrentTurn(): PieceColor {
|
||||
|
|
@ -130,11 +146,15 @@ export class ChessEngine {
|
|||
}
|
||||
}
|
||||
|
||||
// Apply active preset rules: add extra moves, then run filter hooks.
|
||||
// Apply active preset rules for the color whose turn it is. The
|
||||
// per-color filter implements `scope=white` / `scope=black` — a
|
||||
// white-only preset never contributes moves while black is on move.
|
||||
//
|
||||
// Order matters — `getExtraMoves` contributes to the set that
|
||||
// `filterMoves` operates on, so every active preset sees the full
|
||||
// aggregated set (including prior presets' additions).
|
||||
for (const preset of PRESET_REGISTRY.getActive()) {
|
||||
const activePresets = this.activePresets.getForColor(color);
|
||||
for (const preset of activePresets) {
|
||||
if (preset.getExtraMoves) {
|
||||
pieceMoves = [
|
||||
...pieceMoves,
|
||||
|
|
@ -142,7 +162,7 @@ export class ChessEngine {
|
|||
];
|
||||
}
|
||||
}
|
||||
for (const preset of PRESET_REGISTRY.getActive()) {
|
||||
for (const preset of activePresets) {
|
||||
if (preset.filterMoves) {
|
||||
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
||||
}
|
||||
|
|
@ -219,6 +239,12 @@ export class ChessEngine {
|
|||
// Record position for threefold repetition
|
||||
recordPosition(this.session);
|
||||
|
||||
// Tick preset durations with the color that JUST moved. Player-local
|
||||
// turn counting: a `scope=white` preset with 3 turns remaining
|
||||
// ticks only when white plays; a `scope=both` ticks on every
|
||||
// half-move. Entries reaching 0 are removed.
|
||||
this.activePresets.tickAfterMove(color);
|
||||
|
||||
return this.checkGameResult();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ChessEngine, type GameResult } from '../engine';
|
|||
import type { PieceType } from '../schema';
|
||||
import { saveAutoSave } from '../persist/autosave.js';
|
||||
import * as audio from '../audio';
|
||||
import type { PresetActivation } from '../net/types';
|
||||
|
||||
export function useChessEngine() {
|
||||
const engineRef = useRef<ChessEngine | null>(null);
|
||||
|
|
@ -97,6 +98,18 @@ export function useChessEngine() {
|
|||
setTick(t => t + 1);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Replace the local engine's active preset set. Mirrors the
|
||||
* multiplayer hook's shape so GameView / RulesDrawer can share a
|
||||
* single setter. Unlike multiplayer, the write is synchronous and
|
||||
* the local UI updates immediately (no server round-trip).
|
||||
*/
|
||||
const setPresets = useCallback((activations: PresetActivation[]) => {
|
||||
engine.activePresets.replaceAll(activations);
|
||||
saveAutoSave(engine.session.allFacts());
|
||||
setTick(t => t + 1);
|
||||
}, [engine]);
|
||||
|
||||
return {
|
||||
engine,
|
||||
turn: getTurn(),
|
||||
|
|
@ -109,5 +122,7 @@ export function useChessEngine() {
|
|||
loadEngine,
|
||||
refresh,
|
||||
lastMove,
|
||||
activations: engine.activePresets.list(),
|
||||
setPresets,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
|
|||
import type { GameResult } from '../engine';
|
||||
import { GameClient } from '../net/client';
|
||||
import { PredictionManager } from '../net/prediction';
|
||||
import type { Color, PromotionPiece } from '../net/types';
|
||||
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
|
||||
import * as audio from '../audio';
|
||||
|
||||
const WS_URL =
|
||||
|
|
@ -215,6 +215,17 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Replace the room's preset set. The server validates and, on
|
||||
* success, broadcasts `game.presets` which PredictionManager applies
|
||||
* to the base engine and re-renders us via the tick bump. We do NOT
|
||||
* flip local UI state here — the UI should mirror the server's echo,
|
||||
* not the request, so that rejected requests leave the UI unchanged.
|
||||
*/
|
||||
const setPresets = useCallback((activations: PresetActivation[]) => {
|
||||
clientRef.current?.sendSetPresets(activations);
|
||||
}, []);
|
||||
|
||||
const loadEngine = useCallback(() => {
|
||||
// Also a no-op: authoritative state is server-driven. The UI should
|
||||
// not have any need to swap the engine under multiplayer — all state
|
||||
|
|
@ -234,6 +245,12 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
// because `engine.session.allFacts()` is called fresh each render.
|
||||
void tick;
|
||||
|
||||
// Current authoritative preset activations. Read fresh each render
|
||||
// so the UI reflects server echoes immediately after `setTick` fires.
|
||||
const activations: PresetActivation[] = engine
|
||||
? engine.activePresets.list()
|
||||
: [];
|
||||
|
||||
return {
|
||||
engine,
|
||||
turn,
|
||||
|
|
@ -246,6 +263,8 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
loadEngine,
|
||||
refresh,
|
||||
lastMove,
|
||||
activations,
|
||||
setPresets,
|
||||
// Multiplayer-only metadata. GameView uses these to gate drag and
|
||||
// render a "waiting for opponent" / error overlay.
|
||||
myColor: meta.myColor,
|
||||
|
|
|
|||
|
|
@ -29,3 +29,11 @@ export {
|
|||
type ChessFact,
|
||||
} from "./schema.js";
|
||||
export type { LegalMove } from "./rules/types.js";
|
||||
export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js";
|
||||
export {
|
||||
ActivePresetSet,
|
||||
PresetActivationError,
|
||||
type ActivationRequest,
|
||||
type PresetActivation,
|
||||
type PresetScope,
|
||||
} from "./presets/active-set.js";
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ import type {
|
|||
GameDeltaPayload,
|
||||
GameEndPayload,
|
||||
GameMovePayload,
|
||||
GamePresetsPayload,
|
||||
GameStatePayload,
|
||||
PresetActivation,
|
||||
PromotionPiece,
|
||||
RoomCreatedPayload,
|
||||
RoomJoinedPayload,
|
||||
RoomSetPresetsPayload,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -30,6 +33,7 @@ export type GameClientEvent =
|
|||
| { type: "game.state"; payload: GameStatePayload }
|
||||
| { type: "game.delta"; payload: GameDeltaPayload }
|
||||
| { type: "game.end"; payload: GameEndPayload }
|
||||
| { type: "game.presets"; payload: GamePresetsPayload }
|
||||
| { type: "room.created"; payload: RoomCreatedPayload }
|
||||
| { type: "room.joined"; payload: RoomJoinedPayload }
|
||||
| { type: "error"; payload: ErrorPayload }
|
||||
|
|
@ -260,6 +264,17 @@ export class GameClient {
|
|||
this.send({ type: "game.move", payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: send `room.setPresets` to replace the room's entire
|
||||
* active preset set. The server validates and — on success —
|
||||
* broadcasts `game.presets` to both players; on failure the caller
|
||||
* gets a non-fatal `error` event with `INVALID_MESSAGE`.
|
||||
*/
|
||||
sendSetPresets(activations: PresetActivation[]): void {
|
||||
const payload: RoomSetPresetsPayload = { activations };
|
||||
this.send({ type: "room.setPresets", payload });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors (primarily for tests & reconnect logic)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -414,6 +429,9 @@ export class GameClient {
|
|||
case "game.end":
|
||||
this.emit({ type, payload: payload as GameEndPayload });
|
||||
return;
|
||||
case "game.presets":
|
||||
this.emit({ type, payload: payload as GamePresetsPayload });
|
||||
return;
|
||||
case "room.created":
|
||||
this.emit({ type, payload: payload as RoomCreatedPayload });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import type { GameClient } from "./client.js";
|
|||
import type {
|
||||
Fact,
|
||||
GameDeltaPayload,
|
||||
GamePresetsPayload,
|
||||
GameStatePayload,
|
||||
PresetActivation,
|
||||
PromotionPiece,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
@ -112,6 +114,7 @@ export class PredictionManager {
|
|||
private attachListeners(): void {
|
||||
this.client.on("game.state", (e) => this.applyFullState(e.payload));
|
||||
this.client.on("game.delta", (e) => this.reconcile(e.payload));
|
||||
this.client.on("game.presets", (e) => this.applyPresets(e.payload));
|
||||
this.client.on("error", (e) => {
|
||||
// Fatal errors tear down the session; the app restarts from a fresh
|
||||
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
|
||||
|
|
@ -121,12 +124,49 @@ export class PredictionManager {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the authoritative preset set onto the base engine. Any current
|
||||
* prediction is discarded: the preset change might invalidate
|
||||
* previously-legal optimistic moves, and re-validating them here would
|
||||
* duplicate server logic. Simpler to let the next user action
|
||||
* re-predict against the freshly-synced base.
|
||||
*/
|
||||
private applyPresets(payload: GamePresetsPayload): void {
|
||||
try {
|
||||
this.baseEngine.activePresets.replaceAll(payload.activations);
|
||||
} catch {
|
||||
// A bad set from the server shouldn't crash the client; the server
|
||||
// already validated, so this branch is defensive only. We clear
|
||||
// instead of keeping stale rules.
|
||||
this.baseEngine.activePresets.clear();
|
||||
}
|
||||
this.predictedEngine = null;
|
||||
this.pendingMoves = [];
|
||||
this.onStateChange(this.baseEngine);
|
||||
}
|
||||
|
||||
/** The current authoritative preset activation list. Used by hooks to
|
||||
* surface it to the UI. */
|
||||
getPresetActivations(): readonly PresetActivation[] {
|
||||
return this.baseEngine.activePresets.list();
|
||||
}
|
||||
|
||||
private applyFullState(state: GameStatePayload): void {
|
||||
// Full snapshot from the server (on join or after a large gap). Replace
|
||||
// `baseEngine` entirely and drop any outstanding predictions — the
|
||||
// server view supersedes everything.
|
||||
const next = freshEngine();
|
||||
loadFacts(next, state.facts);
|
||||
// Also apply the authoritative preset set. `activations` is a newer
|
||||
// field; tolerate older servers that don't include it by defaulting
|
||||
// to an empty set.
|
||||
if (Array.isArray(state.activations)) {
|
||||
try {
|
||||
next.activePresets.replaceAll(state.activations);
|
||||
} catch {
|
||||
next.activePresets.clear();
|
||||
}
|
||||
}
|
||||
this.baseEngine = next;
|
||||
this.predictedEngine = null;
|
||||
this.pendingMoves = [];
|
||||
|
|
@ -179,6 +219,11 @@ function cloneEngine(src: ChessEngine): ChessEngine {
|
|||
attr: f.attr as string,
|
||||
value: f.value,
|
||||
})));
|
||||
// Preset state must be copied so the prediction's tickAfterMove on
|
||||
// applyMove doesn't mutate the base's duration counters. `replaceAll`
|
||||
// with the base's current list is the public way to do this; since
|
||||
// the base's list already passed validation, the call can't throw.
|
||||
next.activePresets.replaceAll(src.activePresets.list());
|
||||
return next;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,15 +39,34 @@ export interface Fact {
|
|||
// Server → Client payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PresetScope = "both" | "white" | "black";
|
||||
|
||||
export interface PresetActivation {
|
||||
id: string;
|
||||
scope: PresetScope;
|
||||
turnsRemaining: number | null;
|
||||
}
|
||||
|
||||
export interface GameStatePayload {
|
||||
facts: Fact[];
|
||||
turn: Color;
|
||||
lastSeq: number;
|
||||
moveHistory: string[];
|
||||
activeRules: string[];
|
||||
/** Optional for wire compat with older servers. Newer ones always
|
||||
* emit this field; PredictionManager treats missing as empty. */
|
||||
activations?: PresetActivation[];
|
||||
fen: string;
|
||||
}
|
||||
|
||||
export interface GamePresetsPayload {
|
||||
activations: PresetActivation[];
|
||||
}
|
||||
|
||||
export interface RoomSetPresetsPayload {
|
||||
activations: PresetActivation[];
|
||||
}
|
||||
|
||||
export interface GameOver {
|
||||
winner: Winner;
|
||||
reason: GameEndReason;
|
||||
|
|
@ -121,6 +140,7 @@ export type ServerMessage =
|
|||
| MessageEnvelope<"game.state", GameStatePayload>
|
||||
| MessageEnvelope<"game.delta", GameDeltaPayload>
|
||||
| MessageEnvelope<"game.end", GameEndPayload>
|
||||
| MessageEnvelope<"game.presets", GamePresetsPayload>
|
||||
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
||||
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
||||
| MessageEnvelope<"error", ErrorPayload>;
|
||||
|
|
@ -129,4 +149,5 @@ export type ClientMessage =
|
|||
| MessageEnvelope<"room.create", RoomCreatePayload>
|
||||
| MessageEnvelope<"room.join", RoomJoinPayload>
|
||||
| MessageEnvelope<"room.leave", Record<string, never>>
|
||||
| MessageEnvelope<"game.move", GameMovePayload>;
|
||||
| MessageEnvelope<"game.move", GameMovePayload>
|
||||
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>;
|
||||
|
|
|
|||
189
packages/chess/src/presets/active-set.test.ts
Normal file
189
packages/chess/src/presets/active-set.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import "./index.js";
|
||||
import {
|
||||
ActivePresetSet,
|
||||
PresetActivationError,
|
||||
} from "./active-set.js";
|
||||
|
||||
describe("ActivePresetSet", () => {
|
||||
let set: ActivePresetSet;
|
||||
|
||||
beforeEach(() => {
|
||||
set = new ActivePresetSet();
|
||||
});
|
||||
|
||||
describe("replaceAll — single-entry validation", () => {
|
||||
it("rejects unknown preset ids", () => {
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "does-not-exist", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(PresetActivationError);
|
||||
});
|
||||
|
||||
it("rejects invalid scope strings", () => {
|
||||
// TS would normally catch this, but wire traffic can pass anything.
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{
|
||||
id: "knights-leap-twice",
|
||||
// @ts-expect-error — deliberately invalid for runtime test
|
||||
scope: "sideways",
|
||||
turnsRemaining: null,
|
||||
},
|
||||
]),
|
||||
).toThrow(/invalid scope/);
|
||||
});
|
||||
|
||||
it("rejects zero / negative / non-integer turnsRemaining", () => {
|
||||
for (const bad of [0, -1, 1.5, Number.NaN]) {
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: bad },
|
||||
]),
|
||||
).toThrow(/turnsRemaining/);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts null turnsRemaining as permanent", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(set.has("knights-leap-twice")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts positive integer turnsRemaining", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 3 },
|
||||
]);
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceAll — loose compatibility", () => {
|
||||
it("blocks incompatible presets when scopes overlap (both+any)", () => {
|
||||
// pawns-move-backward is incompatibleWith double-pawn-sprint
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
{ id: "double-pawn-sprint", scope: "white", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(/incompatible/);
|
||||
});
|
||||
|
||||
it("allows incompatible presets when scopes are disjoint", () => {
|
||||
// White-only pawns-move-backward + black-only double-pawn-sprint —
|
||||
// per the loose rule these never overlap, so it's allowed.
|
||||
set.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "white", turnsRemaining: null },
|
||||
{ id: "double-pawn-sprint", scope: "black", turnsRemaining: null },
|
||||
]);
|
||||
expect(set.has("pawns-move-backward")).toBe(true);
|
||||
expect(set.has("double-pawn-sprint")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks when both are the same explicit color", () => {
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "white", turnsRemaining: null },
|
||||
{ id: "double-pawn-sprint", scope: "white", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(/incompatible/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceAll — atomicity", () => {
|
||||
it("leaves existing set untouched if validation fails", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||
{ id: "does-not-exist", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(PresetActivationError);
|
||||
// The original single-entry set must still be intact.
|
||||
expect(set.list()).toHaveLength(1);
|
||||
expect(set.has("knights-leap-twice")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getForColor", () => {
|
||||
it("returns `both`-scoped presets for either color", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(set.getForColor("white").map((p) => p.id)).toEqual([
|
||||
"knights-leap-twice",
|
||||
]);
|
||||
expect(set.getForColor("black").map((p) => p.id)).toEqual([
|
||||
"knights-leap-twice",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters color-scoped presets", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "white", turnsRemaining: null },
|
||||
{ id: "bishops-ignore-color", scope: "black", turnsRemaining: null },
|
||||
]);
|
||||
expect(set.getForColor("white").map((p) => p.id)).toEqual([
|
||||
"knights-leap-twice",
|
||||
]);
|
||||
expect(set.getForColor("black").map((p) => p.id)).toEqual([
|
||||
"bishops-ignore-color",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tickAfterMove — player-local turn counting", () => {
|
||||
it("ticks `both`-scoped presets on every half-move", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: 2 },
|
||||
]);
|
||||
set.tickAfterMove("white");
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||
set.tickAfterMove("black");
|
||||
// Second tick on `both` scope expires it.
|
||||
expect(set.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ticks white-scoped presets only after white moves", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 2 },
|
||||
]);
|
||||
set.tickAfterMove("black");
|
||||
// Not our color; duration unchanged.
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(2);
|
||||
set.tickAfterMove("white");
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||
set.tickAfterMove("black");
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||
set.tickAfterMove("white");
|
||||
expect(set.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never ticks permanent (turnsRemaining=null) entries", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
set.tickAfterMove(i % 2 === 0 ? "white" : "black");
|
||||
}
|
||||
expect(set.has("knights-leap-twice")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clone", () => {
|
||||
it("returns an independent deep copy", () => {
|
||||
set.replaceAll([
|
||||
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 5 },
|
||||
]);
|
||||
const copy = set.clone();
|
||||
copy.tickAfterMove("white");
|
||||
// Original unaffected by copy's tick.
|
||||
expect(set.list()[0]?.turnsRemaining).toBe(5);
|
||||
expect(copy.list()[0]?.turnsRemaining).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
packages/chess/src/presets/active-set.ts
Normal file
233
packages/chess/src/presets/active-set.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Per-game active preset tracking.
|
||||
*
|
||||
* Earlier iterations of the preset system used a module-level
|
||||
* `PRESET_REGISTRY.active: Set<string>` which was shared by every
|
||||
* ChessEngine in the process — fine for the browser, but wrong for the
|
||||
* server where a single process hosts multiple concurrent rooms with
|
||||
* different rule sets. It also couldn't express per-color scope or
|
||||
* turn-limited durations.
|
||||
*
|
||||
* `ActivePresetSet` replaces that shared Set with an INSTANCE held by
|
||||
* each `GameSession` / `ChessEngine`. It tracks:
|
||||
*
|
||||
* - which presets are active,
|
||||
* - for which color they apply (`both` | `white` | `black`),
|
||||
* - how many of that color's upcoming turns they remain active
|
||||
* (`null` = permanent).
|
||||
*
|
||||
* The per-color-turn counting policy matches the user's confirmed
|
||||
* design: for a `white`-scoped preset, one "tick" happens after each
|
||||
* white move (not each half-move). For `both`-scoped presets we tick
|
||||
* on every half-move. This is what the plan calls "player-local turns".
|
||||
*
|
||||
* Compatibility uses the LOOSE rule: two presets that list each other
|
||||
* in `incompatibleWith` may still coexist if their SCOPES don't
|
||||
* overlap — e.g. `A scope=white` + `B scope=black` is allowed even
|
||||
* when A and B are nominally incompatible, because the engine only
|
||||
* ever evaluates one side's rules at a time.
|
||||
*/
|
||||
|
||||
import type { PresetDef } from "./registry.js";
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
|
||||
export type PresetScope = "both" | "white" | "black";
|
||||
|
||||
export interface PresetActivation {
|
||||
readonly id: string;
|
||||
readonly scope: PresetScope;
|
||||
/** Number of player-local turns remaining. `null` = permanent. */
|
||||
readonly turnsRemaining: number | null;
|
||||
}
|
||||
|
||||
/** Input-shape when the UI / wire protocol asks to (re)activate a preset. */
|
||||
export interface ActivationRequest {
|
||||
readonly id: string;
|
||||
readonly scope: PresetScope;
|
||||
/** Player-local turns the preset should last. `null` = permanent. */
|
||||
readonly turnsRemaining: number | null;
|
||||
}
|
||||
|
||||
/** Wire-serializable snapshot — identical shape today; kept as its own
|
||||
* name so downstream protocol types can alias it. */
|
||||
export type ActivationEntry = PresetActivation;
|
||||
|
||||
/**
|
||||
* Returns true iff `a`'s scope and `b`'s scope intersect — i.e. there is
|
||||
* at least one color where BOTH presets are active and would be applied
|
||||
* to the same side's moves in the same turn.
|
||||
*/
|
||||
function scopesOverlap(a: PresetScope, b: PresetScope): boolean {
|
||||
if (a === "both" || b === "both") return true;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted for testing and for the server to reject bad requests with a
|
||||
* specific reason. Thrown from `ActivePresetSet.activate`.
|
||||
*/
|
||||
export class PresetActivationError extends Error {
|
||||
readonly code:
|
||||
| "UNKNOWN_PRESET"
|
||||
| "INCOMPATIBLE"
|
||||
| "MISSING_REQUIREMENT"
|
||||
| "INVALID_SCOPE"
|
||||
| "INVALID_DURATION";
|
||||
constructor(code: PresetActivationError["code"], message: string) {
|
||||
super(message);
|
||||
this.name = "PresetActivationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class ActivePresetSet {
|
||||
private readonly entries = new Map<string, PresetActivation>();
|
||||
|
||||
/**
|
||||
* Replace the entire active set with `requests`. Used by the server's
|
||||
* authoritative path and by client sync on `game.presets`. Validates
|
||||
* each entry individually AND the whole set together (so cross-preset
|
||||
* incompatibilities are caught before any mutation).
|
||||
*
|
||||
* On failure the existing set is untouched — errors abort the entire
|
||||
* replacement so the caller never observes a half-applied state.
|
||||
*/
|
||||
replaceAll(requests: readonly ActivationRequest[]): void {
|
||||
// Validate each request in isolation first (preset exists, scope
|
||||
// legal, duration legal).
|
||||
for (const r of requests) {
|
||||
this.validateSingle(r);
|
||||
}
|
||||
|
||||
// Then pairwise: `incompatibleWith` blocks only when scopes overlap;
|
||||
// `requires` must hold with overlapping scope.
|
||||
for (const r of requests) {
|
||||
const def = PRESET_REGISTRY.get(r.id);
|
||||
if (!def) continue; // validateSingle already threw if missing
|
||||
|
||||
for (const other of requests) {
|
||||
if (other.id === r.id) continue;
|
||||
if (def.incompatibleWith.includes(other.id) && scopesOverlap(r.scope, other.scope)) {
|
||||
throw new PresetActivationError(
|
||||
"INCOMPATIBLE",
|
||||
`Preset "${r.id}" (scope=${r.scope}) is incompatible with "${other.id}" (scope=${other.scope}) under overlapping scope.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dep of def.requires) {
|
||||
const met = requests.some(
|
||||
(rq) => rq.id === dep && scopesOverlap(rq.scope, r.scope),
|
||||
);
|
||||
if (!met) {
|
||||
throw new PresetActivationError(
|
||||
"MISSING_REQUIREMENT",
|
||||
`Preset "${r.id}" requires "${dep}" to also be active under an overlapping scope.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All valid — swap atomically.
|
||||
this.entries.clear();
|
||||
for (const r of requests) {
|
||||
this.entries.set(r.id, {
|
||||
id: r.id,
|
||||
scope: r.scope,
|
||||
turnsRemaining: r.turnsRemaining,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private validateSingle(r: ActivationRequest): void {
|
||||
const def = PRESET_REGISTRY.get(r.id);
|
||||
if (!def) {
|
||||
throw new PresetActivationError(
|
||||
"UNKNOWN_PRESET",
|
||||
`Preset "${r.id}" is not registered.`,
|
||||
);
|
||||
}
|
||||
if (r.scope !== "both" && r.scope !== "white" && r.scope !== "black") {
|
||||
throw new PresetActivationError(
|
||||
"INVALID_SCOPE",
|
||||
`Preset "${r.id}" has invalid scope "${String(r.scope)}".`,
|
||||
);
|
||||
}
|
||||
if (r.turnsRemaining !== null) {
|
||||
if (
|
||||
!Number.isInteger(r.turnsRemaining) ||
|
||||
r.turnsRemaining <= 0
|
||||
) {
|
||||
throw new PresetActivationError(
|
||||
"INVALID_DURATION",
|
||||
`Preset "${r.id}" has invalid turnsRemaining "${String(r.turnsRemaining)}" (must be a positive integer or null).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of preset definitions that apply to moves made by
|
||||
* the given color right now. Presets with `scope=both` are included for
|
||||
* both colors; `scope=white` only for white; `scope=black` only for
|
||||
* black. This is what ChessEngine.getAllLegalMoves calls per piece.
|
||||
*/
|
||||
getForColor(color: "white" | "black"): PresetDef[] {
|
||||
const out: PresetDef[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.scope !== "both" && entry.scope !== color) continue;
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
if (def) out.push(def);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by ChessEngine.applyMove AFTER the move is applied, with the
|
||||
* color that just moved (NOT the new side to move). Decrements every
|
||||
* entry whose scope applies to `moverColor` and removes entries whose
|
||||
* `turnsRemaining` reaches 0.
|
||||
*
|
||||
* This implements the "player-local turns" policy: a `scope=white`
|
||||
* preset ticks only after white moves; `scope=both` ticks on every
|
||||
* half-move.
|
||||
*/
|
||||
tickAfterMove(moverColor: "white" | "black"): void {
|
||||
const toRemove: string[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.scope !== "both" && entry.scope !== moverColor) continue;
|
||||
if (entry.turnsRemaining === null) continue;
|
||||
const next = entry.turnsRemaining - 1;
|
||||
if (next <= 0) {
|
||||
toRemove.push(entry.id);
|
||||
} else {
|
||||
this.entries.set(entry.id, { ...entry, turnsRemaining: next });
|
||||
}
|
||||
}
|
||||
for (const id of toRemove) this.entries.delete(id);
|
||||
}
|
||||
|
||||
/** All active entries, in registration order. Used by UI + wire sync. */
|
||||
list(): PresetActivation[] {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
|
||||
/** Fast "is X active under any scope?" query — mainly for tests. */
|
||||
has(id: string): boolean {
|
||||
return this.entries.has(id);
|
||||
}
|
||||
|
||||
/** Remove everything — used on new-game / engine swap. */
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Deep copy — used by PredictionManager when cloning engines. */
|
||||
clone(): ActivePresetSet {
|
||||
const next = new ActivePresetSet();
|
||||
for (const entry of this.entries.values()) {
|
||||
next.entries.set(entry.id, { ...entry });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,14 @@
|
|||
* Tests for preset rule 1 (pawns-move-backward) and registry invariants
|
||||
* required by P3.4.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { PRESET_REGISTRY } from "./index.js";
|
||||
import { ActivePresetSet } from "./active-set.js";
|
||||
import type { Square } from "../schema.js";
|
||||
|
||||
describe("Preset registry (P3.4)", () => {
|
||||
beforeEach(() => {
|
||||
PRESET_REGISTRY.clear();
|
||||
});
|
||||
|
||||
describe("Preset registry (catalog)", () => {
|
||||
it("pawns-move-backward preset is registered", () => {
|
||||
const all = PRESET_REGISTRY.getAll();
|
||||
expect(all.some((p) => p.id === "pawns-move-backward")).toBe(true);
|
||||
|
|
@ -26,36 +23,27 @@ describe("Preset registry (P3.4)", () => {
|
|||
expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("activate / isActive / deactivate round-trip", () => {
|
||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(true);
|
||||
expect(PRESET_REGISTRY.getActive().map((p) => p.id)).toContain(
|
||||
"pawns-move-backward",
|
||||
);
|
||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
|
||||
it("activating an unknown preset throws via ActivePresetSet", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "nonexistent-rule", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(/not registered/);
|
||||
});
|
||||
|
||||
it("activating an unknown preset throws", () => {
|
||||
expect(() => PRESET_REGISTRY.activate("nonexistent-rule")).toThrow(
|
||||
/Unknown preset/,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot activate two mutually-incompatible presets", () => {
|
||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
||||
expect(() => PRESET_REGISTRY.activate("double-pawn-sprint")).toThrow(
|
||||
/incompatible/,
|
||||
);
|
||||
it("cannot activate two mutually-incompatible presets under overlapping scope", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||
{ id: "double-pawn-sprint", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(/incompatible/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Preset: pawns-move-backward", () => {
|
||||
beforeEach(() => {
|
||||
PRESET_REGISTRY.clear();
|
||||
});
|
||||
|
||||
const preset = () =>
|
||||
PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
* Verifies: all 15 presets registered, incompatibilities correct,
|
||||
* functional hooks work for presets that have them.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PRESET_REGISTRY } from "./index.js";
|
||||
import { ActivePresetSet } from "./active-set.js";
|
||||
import { Session } from "@paratype/rete";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
|
|
@ -95,8 +96,6 @@ describe("Requires declarations", () => {
|
|||
});
|
||||
|
||||
describe("Functional hook: pawns-move-backward", () => {
|
||||
beforeEach(() => { PRESET_REGISTRY.clear(); });
|
||||
|
||||
it("adds backward move for pawn on rank ≥2", () => {
|
||||
const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!;
|
||||
const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 20 }]); // e3
|
||||
|
|
@ -152,15 +151,32 @@ describe("Functional hook: wrap-board", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("PRESET_REGISTRY.activate with incompatibility check", () => {
|
||||
beforeEach(() => { PRESET_REGISTRY.clear(); });
|
||||
|
||||
it("activating incompatible pair throws", () => {
|
||||
PRESET_REGISTRY.activate("rook-warp");
|
||||
expect(() => PRESET_REGISTRY.activate("wrap-board")).toThrow();
|
||||
describe("ActivePresetSet incompatibility + requires", () => {
|
||||
it("activating incompatible pair (overlapping scope) throws", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "rook-warp", scope: "both", turnsRemaining: null },
|
||||
{ id: "wrap-board", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("activating without dependency throws", () => {
|
||||
expect(() => PRESET_REGISTRY.activate("king-heals")).toThrow(/requires/);
|
||||
it("activating without required dependency throws", () => {
|
||||
const set = new ActivePresetSet();
|
||||
expect(() =>
|
||||
set.replaceAll([
|
||||
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||
]),
|
||||
).toThrow(/requires/);
|
||||
});
|
||||
|
||||
it("activating with satisfied requirement succeeds", () => {
|
||||
const set = new ActivePresetSet();
|
||||
set.replaceAll([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(set.has("king-heals")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,54 +31,29 @@ export interface PresetDef {
|
|||
) => LegalMove[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure catalog of preset DEFINITIONS. Does NOT track activation any more
|
||||
* — activation is per-engine via `ChessEngine.activePresets: ActivePresetSet`
|
||||
* so concurrent games in the same process (e.g. server rooms) can hold
|
||||
* different rule sets without sharing module-level state.
|
||||
*/
|
||||
class PresetRegistryClass {
|
||||
private readonly presets = new Map<string, PresetDef>();
|
||||
private readonly active = new Set<string>();
|
||||
|
||||
register(preset: PresetDef): void {
|
||||
this.presets.set(preset.id, preset);
|
||||
}
|
||||
|
||||
activate(id: string): void {
|
||||
const preset = this.presets.get(id);
|
||||
if (!preset) throw new Error(`Unknown preset: ${id}`);
|
||||
for (const other of this.active) {
|
||||
const otherDef = this.presets.get(other);
|
||||
if (otherDef?.incompatibleWith.includes(id)) {
|
||||
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
|
||||
}
|
||||
if (preset.incompatibleWith.includes(other)) {
|
||||
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
|
||||
}
|
||||
}
|
||||
for (const dep of preset.requires) {
|
||||
if (!this.active.has(dep)) {
|
||||
throw new Error(`Preset ${id} requires ${dep} to be active first`);
|
||||
}
|
||||
}
|
||||
this.active.add(id);
|
||||
}
|
||||
|
||||
deactivate(id: string): void {
|
||||
this.active.delete(id);
|
||||
}
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.active.has(id);
|
||||
}
|
||||
|
||||
getActive(): PresetDef[] {
|
||||
return [...this.active]
|
||||
.map((id) => this.presets.get(id))
|
||||
.filter((p): p is PresetDef => p !== undefined);
|
||||
}
|
||||
|
||||
getAll(): PresetDef[] {
|
||||
return [...this.presets.values()];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.active.clear();
|
||||
/**
|
||||
* Look up a preset definition by id. Used by ActivePresetSet to resolve
|
||||
* entries into callable hooks. Returns undefined for unknown ids.
|
||||
*/
|
||||
get(id: string): PresetDef | undefined {
|
||||
return this.presets.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { RulesDrawer } from './RulesDrawer';
|
|||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||
import type { Color } from '../net/types';
|
||||
import type { Color, PresetActivation } from '../net/types';
|
||||
import type { GameResult } from '../engine';
|
||||
import type { LegalMove } from '../rules/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
|
@ -28,6 +28,8 @@ interface GameEngineState {
|
|||
canUndo: boolean;
|
||||
lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||
refresh: () => void;
|
||||
activations: PresetActivation[];
|
||||
setPresets: (activations: PresetActivation[]) => void;
|
||||
}
|
||||
|
||||
interface GameViewProps {
|
||||
|
|
@ -106,7 +108,19 @@ function GameLayout({
|
|||
state: GameEngineState;
|
||||
myColor: Color | null;
|
||||
}) {
|
||||
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state;
|
||||
const {
|
||||
facts,
|
||||
legalMoves,
|
||||
turn,
|
||||
result,
|
||||
applyMove,
|
||||
undo,
|
||||
canUndo,
|
||||
lastMove,
|
||||
refresh,
|
||||
activations,
|
||||
setPresets,
|
||||
} = state;
|
||||
|
||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||
applyMove(from, to, promoteTo || 'queen');
|
||||
|
|
@ -171,7 +185,11 @@ function GameLayout({
|
|||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
|
||||
>
|
||||
<RulesDrawer onRulesChanged={refresh} />
|
||||
<RulesDrawer
|
||||
activations={activations}
|
||||
setPresets={setPresets}
|
||||
onRulesChanged={refresh}
|
||||
/>
|
||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||
|
||||
{/* Header/Info section */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||
import type { PresetScope } from '../presets/active-set.js';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Settings2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -7,57 +9,121 @@ import { toast } from 'sonner';
|
|||
/**
|
||||
* A collapsible side-drawer for toggling preset rules mid-game.
|
||||
*
|
||||
* Writes straight to PRESET_REGISTRY on every toggle. The ChessEngine reads
|
||||
* PRESET_REGISTRY.getActive() fresh on every call to getAllLegalMoves(), so
|
||||
* a toggle takes effect on the very next move calculation — no reset, no
|
||||
* reload, no navigation.
|
||||
*
|
||||
* Mounts a small tick counter to force a re-render when the registry
|
||||
* changes externally (e.g. via the /rules page), so this drawer stays in
|
||||
* sync if both UIs are open.
|
||||
* Server-authoritative: every toggle/scope/duration change builds a
|
||||
* full replacement list and dispatches via `setPresets`. In multiplayer
|
||||
* that goes to the server which echoes `game.presets` back; in local
|
||||
* mode it mutates the engine directly. Either way the UI reads
|
||||
* `activations` as its source of truth — we never flip a toggle locally
|
||||
* and then try to reconcile.
|
||||
*/
|
||||
interface RulesDrawerProps {
|
||||
/** Called after any toggle so the parent can recompute derived UI state
|
||||
* (legal-move highlights, etc.) that depends on the active preset set. */
|
||||
/** Current authoritative preset activations (from hook). */
|
||||
activations: readonly PresetActivation[];
|
||||
/** Replace the active set on the engine / server. */
|
||||
setPresets: (activations: PresetActivation[]) => void;
|
||||
/** Called after any toggle so the parent can recompute derived UI
|
||||
* state (legal-move highlights, etc) — local mode only. */
|
||||
onRulesChanged?: () => void;
|
||||
}
|
||||
|
||||
export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||
/** Build a fresh activation list that reflects a single edit. */
|
||||
function applyEdit(
|
||||
current: readonly PresetActivation[],
|
||||
id: string,
|
||||
patch: Partial<PresetActivation> & { remove?: boolean },
|
||||
): PresetActivation[] {
|
||||
if (patch.remove) {
|
||||
return current.filter((a) => a.id !== id);
|
||||
}
|
||||
const idx = current.findIndex((a) => a.id === id);
|
||||
if (idx < 0) {
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
scope: patch.scope ?? 'both',
|
||||
turnsRemaining: patch.turnsRemaining ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const next = [...current];
|
||||
const existing = next[idx]!;
|
||||
next[idx] = {
|
||||
id: existing.id,
|
||||
scope: patch.scope ?? existing.scope,
|
||||
turnsRemaining:
|
||||
patch.turnsRemaining === undefined
|
||||
? existing.turnsRemaining
|
||||
: patch.turnsRemaining,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
export function RulesDrawer({
|
||||
activations,
|
||||
setPresets,
|
||||
onRulesChanged,
|
||||
}: RulesDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Force-refresh trigger. The registry itself has no event emitter, so we
|
||||
// bump this counter on every open and every toggle click to re-read the
|
||||
// active set. The value is never read directly — only the setter is used
|
||||
// to trigger a re-render of this component.
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// Re-read active set on every open so external changes reflect.
|
||||
useEffect(() => {
|
||||
if (open) setTick((t) => t + 1);
|
||||
}, [open]);
|
||||
|
||||
const presets = PRESET_REGISTRY.getAll();
|
||||
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
|
||||
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
|
||||
const activeById = useMemo(() => {
|
||||
const map = new Map<string, PresetActivation>();
|
||||
for (const a of activations) map.set(a.id, a);
|
||||
return map;
|
||||
}, [activations]);
|
||||
|
||||
const toggle = (id: string, name: string) => {
|
||||
const isActivating = !activeIds.has(id);
|
||||
if (!isActivating) {
|
||||
PRESET_REGISTRY.deactivate(id);
|
||||
toast(`Preset disabled: ${name}`);
|
||||
} else {
|
||||
try {
|
||||
PRESET_REGISTRY.activate(id);
|
||||
toast.info(`Preset enabled: ${name}`);
|
||||
} catch (err) {
|
||||
// activate() throws for missing requires or incompatibilities. We
|
||||
// surface the reason via the UI only for the user's next refresh.
|
||||
console.warn(`Could not activate ${id}:`, err);
|
||||
toast.error(`Could not activate ${name}`);
|
||||
}
|
||||
const currently = activeById.get(id);
|
||||
const next: PresetActivation[] = currently
|
||||
? applyEdit(activations, id, { remove: true })
|
||||
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null });
|
||||
try {
|
||||
setPresets(next);
|
||||
toast(
|
||||
currently ? `Preset disabled: ${name}` : `Preset enabled: ${name}`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Local mode throws synchronously; multiplayer rejects async via
|
||||
// the server's error event. We tell the user either way.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Could not update ${name}: ${msg}`);
|
||||
}
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
const setScope = (id: string, scope: PresetScope) => {
|
||||
const next = applyEdit(activations, id, { scope });
|
||||
try {
|
||||
setPresets(next);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Could not set scope: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
const setDuration = (
|
||||
id: string,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const raw = event.target.value.trim();
|
||||
// Empty string === permanent (null). Otherwise must parse to a
|
||||
// positive integer; invalid input is silently ignored so a user
|
||||
// mid-typing "12" doesn't crash on intermediate "1".
|
||||
let turnsRemaining: number | null = null;
|
||||
if (raw !== '') {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) return;
|
||||
turnsRemaining = n;
|
||||
}
|
||||
try {
|
||||
setPresets(applyEdit(activations, id, { turnsRemaining }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Could not set duration: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
// Ask the parent engine state to recompute legalMoves — otherwise the
|
||||
// Board's highlighted drop targets won't reflect the new rule set
|
||||
// until the next move bumps the hook's internal tick.
|
||||
onRulesChanged?.();
|
||||
};
|
||||
|
||||
|
|
@ -75,14 +141,13 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
>
|
||||
<Settings2 size={16} />
|
||||
Rules
|
||||
{activeIds.size > 0 && (
|
||||
{activeById.size > 0 && (
|
||||
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
|
||||
{activeIds.size}
|
||||
{activeById.size}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Backdrop + drawer */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
|
|
@ -103,9 +168,11 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
>
|
||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">
|
||||
Live Rules
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
Toggle rules mid-game
|
||||
Toggle rules mid-game · scope + duration per rule
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -121,16 +188,8 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{presets.map((preset) => {
|
||||
const isOn = activeIds.has(preset.id);
|
||||
const blockedBy = preset.incompatibleWith.filter((id) =>
|
||||
activeIds.has(id),
|
||||
);
|
||||
const missingReqs = preset.requires.filter(
|
||||
(id) => !activeIds.has(id),
|
||||
);
|
||||
const blocked =
|
||||
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
|
||||
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
|
|
@ -138,8 +197,6 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||
isOn
|
||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||
: blocked
|
||||
? 'border-neutral-100 bg-neutral-50/50 opacity-60'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -151,25 +208,14 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
{blockedBy.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Conflicts with active: {blockedBy.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{missingReqs.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Requires: {missingReqs.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={blocked}
|
||||
onClick={() => toggle(preset.id, preset.name)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
} ${blocked ? 'cursor-not-allowed' : ''}`}
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
|
|
@ -182,15 +228,87 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
|||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
|
||||
{/* Scope radios */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||
Apply to
|
||||
</label>
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
|
||||
role="radiogroup"
|
||||
aria-label={`${preset.name} scope`}
|
||||
data-role="scope-group"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map(
|
||||
(scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`px-3 py-1 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration input */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
|
||||
>
|
||||
Duration
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => setDuration(preset.id, e)}
|
||||
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{active.turnsRemaining === null
|
||||
? 'permanent'
|
||||
: 'turns'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
|
||||
{activeIds.size === 0
|
||||
{activeById.size === 0
|
||||
? 'Standard FIDE chess — no presets active'
|
||||
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
|
||||
: `${activeById.size} preset${activeById.size === 1 ? '' : 's'} active`}
|
||||
</footer>
|
||||
</motion.aside>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,107 +1,96 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||
import { ChessEngine } from '../engine.js';
|
||||
import { clearAutoSave } from '../persist/autosave.js';
|
||||
import type { useChessEngine } from '../hooks/useChessEngine.js';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
import type { PresetScope } from '../presets/active-set.js';
|
||||
|
||||
interface RulesViewProps {
|
||||
/** Only required when rules should drive a new game start. */
|
||||
chessState?: ReturnType<typeof useChessEngine>;
|
||||
/** Required — the view always shows the engine's current active set
|
||||
* and delegates mutation back to `chessState.setPresets`. */
|
||||
chessState: ReturnType<typeof useChessEngine>;
|
||||
isGameActive?: boolean;
|
||||
}
|
||||
|
||||
/** Build a fresh activation list that reflects a single edit. */
|
||||
function applyEdit(
|
||||
current: readonly PresetActivation[],
|
||||
id: string,
|
||||
patch: Partial<PresetActivation> & { remove?: boolean },
|
||||
): PresetActivation[] {
|
||||
if (patch.remove) return current.filter((a) => a.id !== id);
|
||||
const idx = current.findIndex((a) => a.id === id);
|
||||
if (idx < 0) {
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
scope: patch.scope ?? 'both',
|
||||
turnsRemaining: patch.turnsRemaining ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const next = [...current];
|
||||
const existing = next[idx]!;
|
||||
next[idx] = {
|
||||
id: existing.id,
|
||||
scope: patch.scope ?? existing.scope,
|
||||
turnsRemaining:
|
||||
patch.turnsRemaining === undefined
|
||||
? existing.turnsRemaining
|
||||
: patch.turnsRemaining,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
||||
const navigate = useNavigate();
|
||||
// Seed local selection from whatever is already active on the registry so
|
||||
// navigating to /rules mid-game reflects the real state (not a fresh set).
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(PRESET_REGISTRY.getActive().map((p) => p.id)),
|
||||
);
|
||||
|
||||
const presets = PRESET_REGISTRY.getAll();
|
||||
|
||||
const hasIncompatibilities = useMemo(() => {
|
||||
for (const a of selected) {
|
||||
const aDef = presets.find((p) => p.id === a);
|
||||
if (!aDef) continue;
|
||||
for (const b of selected) {
|
||||
if (a === b) continue;
|
||||
if (aDef.incompatibleWith.includes(b)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [selected, presets]);
|
||||
|
||||
const missingRequires = useMemo(() => {
|
||||
const missing: Array<{ id: string; needs: string }> = [];
|
||||
for (const a of selected) {
|
||||
const def = presets.find((p) => p.id === a);
|
||||
if (!def) continue;
|
||||
for (const need of def.requires) {
|
||||
if (!selected.has(need)) missing.push({ id: a, needs: need });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}, [selected, presets]);
|
||||
const { activations, setPresets } = chessState;
|
||||
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
|
||||
const activeById = useMemo(() => {
|
||||
const map = new Map<string, PresetActivation>();
|
||||
for (const a of activations) map.set(a.id, a);
|
||||
return map;
|
||||
}, [activations]);
|
||||
|
||||
const togglePreset = (id: string) => {
|
||||
if (isGameActive) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const current = activeById.get(id);
|
||||
setPresets(
|
||||
current
|
||||
? applyEdit(activations, id, { remove: true })
|
||||
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null }),
|
||||
);
|
||||
};
|
||||
|
||||
// Keep the singleton registry in sync with the user's current selection on
|
||||
// every change. This means toggles take effect live — players navigating
|
||||
// back and forth between /rules and /game see the new rules immediately,
|
||||
// and any newly-created engine (P4 server path or /save load) will pick
|
||||
// up the active presets automatically.
|
||||
useEffect(() => {
|
||||
// Deactivate first so the activation loop can't trip the registry's
|
||||
// incompatibility guard against stale entries.
|
||||
for (const p of PRESET_REGISTRY.getActive()) {
|
||||
if (!selected.has(p.id)) PRESET_REGISTRY.deactivate(p.id);
|
||||
const setScope = (id: string, scope: PresetScope) => {
|
||||
if (isGameActive) return;
|
||||
setPresets(applyEdit(activations, id, { scope }));
|
||||
};
|
||||
|
||||
const setDuration = (id: string, raw: string) => {
|
||||
if (isGameActive) return;
|
||||
if (raw === '') {
|
||||
setPresets(applyEdit(activations, id, { turnsRemaining: null }));
|
||||
return;
|
||||
}
|
||||
// Activate in dependency-friendly order: items whose `requires` are
|
||||
// already active first. Simple fixpoint loop — stops when no more
|
||||
// progress can be made, at which point remaining items are either
|
||||
// already active or blocked by missing prerequisites / conflicts.
|
||||
const toActivate = [...selected].filter((id) => !PRESET_REGISTRY.isActive(id));
|
||||
let progress = true;
|
||||
while (progress && toActivate.length > 0) {
|
||||
progress = false;
|
||||
for (let i = toActivate.length - 1; i >= 0; i--) {
|
||||
const id = toActivate[i]!;
|
||||
try {
|
||||
PRESET_REGISTRY.activate(id);
|
||||
toActivate.splice(i, 1);
|
||||
progress = true;
|
||||
} catch {
|
||||
/* requires not met yet, or incompatible — try again next pass */
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selected]);
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) return;
|
||||
setPresets(applyEdit(activations, id, { turnsRemaining: n }));
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
// Starting a new game with the selected ruleset: reset the engine so
|
||||
// opening moves are generated under the active presets. Clear the
|
||||
// autosave too — otherwise a full-page reload would re-hydrate the
|
||||
// previous in-progress game. The registry is already synced via the
|
||||
// useEffect above, so the fresh engine will see the active presets
|
||||
// on its first getAllLegalMoves() call.
|
||||
// Starting a new game preserving the currently configured rule set.
|
||||
clearAutoSave();
|
||||
if (chessState) {
|
||||
chessState.loadEngine(new ChessEngine());
|
||||
}
|
||||
const newEngine = new ChessEngine();
|
||||
newEngine.activePresets.replaceAll(activations);
|
||||
chessState.loadEngine(newEngine);
|
||||
navigate('/game');
|
||||
};
|
||||
|
||||
const activeCount = selected.size;
|
||||
const activeCount = activeById.size;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
||||
|
|
@ -109,7 +98,8 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Toggle custom chess rules. Changes apply to the next game.
|
||||
Toggle custom chess rules. Each rule can target a color
|
||||
(white/black/both) and run for a limited number of turns.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -121,95 +111,114 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
|||
</button>
|
||||
</header>
|
||||
|
||||
{isGameActive && (
|
||||
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
||||
Rules can only be changed between games
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasIncompatibilities && (
|
||||
<div
|
||||
data-testid="compat-warning"
|
||||
className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold"
|
||||
>
|
||||
Warning: Some selected rules are mutually incompatible — they will not both apply.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingRequires.length > 0 && (
|
||||
<div
|
||||
data-testid="requires-warning"
|
||||
className="bg-amber-100 text-amber-800 p-4 rounded-md"
|
||||
>
|
||||
Some selected rules are missing prerequisites:
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
{missingRequires.map((m) => (
|
||||
<li key={`${m.id}-${m.needs}`}>
|
||||
<code>{m.id}</code> requires <code>{m.needs}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-lg p-4 flex items-center justify-between transition-colors ${
|
||||
isGameActive ? 'opacity-50 grayscale' : 'hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="pr-4">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
|
||||
{preset.incompatibleWith.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Incompatible with:{' '}
|
||||
{preset.incompatibleWith.map((x) => (
|
||||
<code key={x} className="mx-1">
|
||||
{x}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{preset.requires.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Requires:{' '}
|
||||
{preset.requires.map((x) => (
|
||||
<code key={x} className="mx-1">
|
||||
{x}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
{presets.map((preset) => {
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
isGameActive
|
||||
? 'opacity-50 grayscale'
|
||||
: 'hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="pr-4">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={isGameActive}
|
||||
onClick={() => togglePreset(preset.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
isOn ? 'bg-blue-600' : 'bg-gray-200'
|
||||
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2">
|
||||
Apply to
|
||||
</label>
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-sm font-medium"
|
||||
role="radiogroup"
|
||||
data-role="scope-group"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map((scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`flex-1 px-3 py-1.5 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2"
|
||||
>
|
||||
Duration (turns, blank = permanent)
|
||||
</label>
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => setDuration(preset.id, e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-neutral-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={isGameActive}
|
||||
onClick={() => togglePreset(preset.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
selected.has(preset.id) ? 'bg-blue-600' : 'bg-gray-200'
|
||||
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={selected.has(preset.id)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selected.has(preset.id) ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-6 space-y-3">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
|
||||
<strong>Tip:</strong> toggles above take effect <em>immediately</em> — including in the middle of a game. No reset required.
|
||||
<strong>Tip:</strong> edits above take effect <em>immediately</em> —
|
||||
including in the middle of a game. No reset required.
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{activeCount === 0
|
||||
|
|
|
|||
|
|
@ -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