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