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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue