diff --git a/packages/chess/e2e/full-flow.spec.d.ts b/packages/chess/e2e/full-flow.spec.d.ts
new file mode 100644
index 0000000..c22876c
--- /dev/null
+++ b/packages/chess/e2e/full-flow.spec.d.ts
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=full-flow.spec.d.ts.map
\ No newline at end of file
diff --git a/packages/chess/e2e/full-flow.spec.d.ts.map b/packages/chess/e2e/full-flow.spec.d.ts.map
new file mode 100644
index 0000000..2782dc2
--- /dev/null
+++ b/packages/chess/e2e/full-flow.spec.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"full-flow.spec.d.ts","sourceRoot":"","sources":["full-flow.spec.ts"],"names":[],"mappings":""}
\ No newline at end of file
diff --git a/packages/chess/e2e/full-flow.spec.js b/packages/chess/e2e/full-flow.spec.js
new file mode 100644
index 0000000..c544bda
--- /dev/null
+++ b/packages/chess/e2e/full-flow.spec.js
@@ -0,0 +1,105 @@
+import { test, expect } from '@playwright/test';
+/**
+ * Full Phase-3 gate scenario:
+ * preset-toggle → Scholar's Mate play → undo → save/reload → export → import → checkmate
+ *
+ * Undo is exercised on the *original* page before reload, where moveHistoryRef
+ * still holds all moves since start (loadEngine resets history, so undo is only
+ * meaningful there). Export/import is verified by restoring that same 8-move
+ * position in a fresh browser context, then completing the Scholar's Mate.
+ */
+test("full game flow: preset toggle, Scholar's Mate, save/reload, export/import, undo, checkmate", async ({ page, browser }) => {
+ // ── Step 1: Preset toggles ─────────────────────────────────────────────────
+ await page.goto('/rules');
+ // pawns-move-backward: incompatibleWith ["double-pawn-sprint"] only
+ await page.locator('[data-preset="pawns-move-backward"] [data-role="toggle"]').click();
+ // knights-leap-twice: incompatibleWith []
+ await page.locator('[data-preset="knights-leap-twice"] [data-role="toggle"]').click();
+ // These two presets are compatible — no compat warning
+ await expect(page.locator('[data-testid="compat-warning"]')).not.toBeVisible();
+ await page.locator('[data-action="start-new-game"]').click();
+ await page.waitForURL('**/game');
+ // ── Step 2: Play all 8 moves before any reload ────────────────────────────
+ //
+ // We play ALL 8 moves here on the pristine page so that moveHistoryRef still
+ // holds the full sequence. loadEngine() resets moveHistoryRef to [], so any
+ // subsequent reload/import invalidates the undo chain; doing undo NOW (before
+ // the first reload) is the only way to test it correctly without source changes.
+ //
+ // Targeting `[data-square="${from}"] [data-piece]` places the drag origin on
+ // the draggable piece element so that dragstart fires on it. The target is the
+ // square div that has onDrop/onDragOver.
+ const drag = async (from, to) => {
+ await page
+ .locator(`[data-square="${from}"] [data-piece]`)
+ .dragTo(page.locator(`[data-square="${to}"]`));
+ };
+ // Scholar's Mate setup — moves 1-5
+ await drag('e2', 'e4'); // 1. e4
+ await drag('e7', 'e5'); // 1...e5
+ await drag('f1', 'c4'); // 2. Bc4
+ await drag('b8', 'c6'); // 2...Nc6
+ await drag('d1', 'h5'); // 3. Qh5
+ await expect(page.locator('[data-testid="turn-indicator"]')).toContainText('Black');
+ // Continuation — moves 6-8
+ await drag('d7', 'd6'); // 3...d6 (random; Qf7 still covers d7 via rank-7)
+ await drag('a2', 'a3'); // 4. a3 (random)
+ await drag('g8', 'f6'); // 4...Nf6?? — will be undone
+ // ── Step 3: Undo Nf6, verify knight back on g8 ────────────────────────────
+ // History = [e4,e5,Bc4,Nc6,Qh5,d6,a3,Nf6] → pop → [e4,e5,Bc4,Nc6,Qh5,d6,a3]
+ // Rebuild replays all 7 moves from initial; g8 kingside knight is untouched
+ // (only the b8 queenside knight moved to c6 in move 4).
+ await page.locator('[data-action="undo"]').click();
+ await expect(page.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible();
+ // Re-play Nf6 to restore the 8-move export state (White's turn)
+ await drag('g8', 'f6'); // 4...Nf6?? again
+ // ── Step 4: Save named slot ────────────────────────────────────────────────
+ await page.goto('/save');
+ await page.fill('input[type="text"]', 'scholars-test');
+ await page.locator('[data-action="save"]').click();
+ await expect(page.locator('[data-testid="save-slot-scholars-test"]')).toBeVisible();
+ // ── Step 5: Reload → autosave restore ─────────────────────────────────────
+ // Each applyMove() calls saveAutoSave(); App.useEffect restores it on mount.
+ // The last applyMove was Nf6 → autosave = 8-move state.
+ // Queen on h5 and bishop on c4 are present in that state (neither moved
+ // during moves 6-8).
+ await page.reload();
+ await page.goto('/game');
+ await expect(page.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible();
+ await expect(page.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible();
+ // ── Step 6: Export JSON (8-move state, White's turn) ──────────────────────
+ await page.goto('/save');
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('[data-action="export"]').click();
+ const download = await downloadPromise;
+ const exportPath = `/tmp/chess-export-${Date.now()}.json`;
+ await download.saveAs(exportPath);
+ // ── Step 7: Import in a fresh browser context ──────────────────────────────
+ const freshContext = await browser.newContext();
+ const freshPage = await freshContext.newPage();
+ await freshPage.goto('http://localhost:5173/save');
+ // setInputFiles works on hidden inputs; onChange → FileReader → onLoad(facts)
+ // → handleLoad in SaveWrapper → loadEngine → saveAutoSave → navigate('/game')
+ const fileInput = freshPage.locator('input[type="file"]');
+ await fileInput.setInputFiles(exportPath);
+ await freshPage.waitForURL('**/game');
+ // Verify the 8-move position was restored in the fresh context
+ await expect(freshPage.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible();
+ await expect(freshPage.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible();
+ // ── Step 8: Scholar's Mate — Qxf7# ───────────────────────────────────────
+ // It is White's turn (after Black's Nf6 in the imported 8-move state).
+ // h5→f7 is a 2-square diagonal via empty g6. f7 has the black f7 pawn.
+ // Queen on f7 is defended by Bc4 (c4-d5-e6-f7 diagonal).
+ // Black king on e8 cannot escape:
+ // d8 = own queen (occupied), d7 = attacked by Qf7 along rank-7,
+ // e7 = attacked by Qf7 (adjacent on rank-7), f8 = own bishop (occupied),
+ // f7 = queen (defended by Bc4).
+ // Nf6 cannot capture f7 (not an L-shaped jump from f6).
+ await freshPage
+ .locator('[data-square="h5"] [data-piece]')
+ .dragTo(freshPage.locator('[data-square="f7"]'));
+ // ── Step 9: Assert checkmate ──────────────────────────────────────────────
+ await expect(freshPage.locator('[data-testid="game-over"]')).toBeVisible();
+ await freshContext.close();
+});
+//# sourceMappingURL=full-flow.spec.js.map
\ No newline at end of file
diff --git a/packages/chess/e2e/full-flow.spec.js.map b/packages/chess/e2e/full-flow.spec.js.map
new file mode 100644
index 0000000..885823e
--- /dev/null
+++ b/packages/chess/e2e/full-flow.spec.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"full-flow.spec.js","sourceRoot":"","sources":["full-flow.spec.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx
index dbf9ecd..e6e117b 100644
--- a/packages/chess/src/app/App.tsx
+++ b/packages/chess/src/app/App.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
+import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'
import { Lobby } from '../ui/Lobby'
import { GameView, MultiplayerGameView } from '../ui/GameView'
import { RulesView } from '../ui/RulesView'
@@ -8,9 +8,10 @@ import { ImportExport } from '../ui/ImportExport'
import { useChessEngine } from '../hooks/useChessEngine'
import { ChessEngine } from '../engine'
import { loadAutoSave } from '../persist/autosave.js'
+import { oneShotRoomRequest } from '../net/lobby-request'
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
import { AnimatePresence, motion } from 'motion/react'
-import { Toaster } from 'sonner'
+import { Toaster, toast } from 'sonner'
export function App() {
const chessState = useChessEngine()
@@ -34,6 +35,7 @@ export function App() {
} />
} />
+ } />
} />
} />
@@ -43,29 +45,102 @@ export function App() {
}
/**
- * /game route dispatcher. Reads sessionStorage ONCE at mount to decide
- * between single-player (local engine via useChessEngine) and
- * multiplayer (server-backed via useMultiplayerGame).
+ * /game and /game/:code route dispatcher.
*
- * We snapshot into state so that a subsequent sessionStorage mutation
- * (e.g. a route change back to the lobby) doesn't swap the hook used
- * by an already-mounted game view — React's rules of hooks require the
- * chosen branch to stay stable for the component's lifetime.
+ * Four ways to land here:
+ * 1. /game with creds in sessionStorage — existing mid-game reload.
+ * We redirect to the canonical /game/:code URL so the address bar
+ * reflects the room, but otherwise render MultiplayerGameView.
+ * 2. /game with no creds — solo local game.
+ * 3. /game/:code with MATCHING creds in sessionStorage — standard
+ * path after Create / Join clicked on the Lobby. Mount MP view.
+ * 4. /game/:code with NO (or mismatched) creds — shared link flow.
+ * We auto-fire a room.join for :code, store the returned creds,
+ * and mount the MP view. On failure we redirect to / with a toast.
*
- * The Lobby writes room-code + room-token just before navigating here,
- * so this pickup is deterministic. If either is missing we render the
- * local mode.
+ * We snapshot mpCreds into state so that a subsequent sessionStorage
+ * mutation (e.g. route change back to the lobby) doesn't swap the hook
+ * used by an already-mounted game view — React's rules of hooks require
+ * the chosen branch to stay stable for the component's lifetime.
*/
function GameRoute({
chessState,
}: {
chessState: ReturnType
}) {
- const [mpCreds] = useState(() => {
+ const { code: urlCode } = useParams<{ code?: string }>()
+ const navigate = useNavigate()
+
+ // `joining` gates rendering while we fire the auto-join request for
+ // a shared-link arrival. Null = no join in flight. During this window
+ // the page shows a minimal "Joining…" placeholder.
+ const [joining, setJoining] = useState(null)
+
+ const [mpCreds, setMpCreds] = useState(() => {
const code = sessionStorage.getItem('room-code')
const token = sessionStorage.getItem('room-token')
- return code !== null && token !== null ? { code, token } : null
+ // Creds are only valid if they match the URL (when one is present).
+ // A stale cred for a DIFFERENT room from the URL must be replaced.
+ if (code === null || token === null) return null
+ if (urlCode !== undefined && urlCode.toUpperCase() !== code) return null
+ return { code, token }
})
+
+ // Case 1: /game (no code param) but we have creds → canonicalise URL.
+ useEffect(() => {
+ if (urlCode === undefined && mpCreds !== null) {
+ navigate(`/game/${mpCreds.code}`, { replace: true })
+ }
+ }, [urlCode, mpCreds, navigate])
+
+ // Case 4: /game/:code with no matching creds → auto-join the room
+ // and stash the returned creds. Run once per URL code.
+ //
+ // We deliberately INCLUDE `mpCreds`/`joining` in the dep array so the
+ // effect is always consistent with the exhaustive-deps lint rule, and
+ // use the top-of-effect guards to prevent re-entry:
+ // - returns early if URL has no code
+ // - returns early once mpCreds is populated (we're done)
+ // - returns early if a join for this exact code is already in flight
+ //
+ // After a successful join, `setMpCreds({...})` triggers a re-render,
+ // the effect re-runs, and the `mpCreds !== null` guard short-circuits
+ // it cleanly without loops.
+ useEffect(() => {
+ if (urlCode === undefined) return
+ if (mpCreds !== null) return
+ const normalised = urlCode.toUpperCase()
+ if (joining === normalised) return
+ setJoining(normalised)
+ oneShotRoomRequest('room.join', { code: normalised })
+ .then(({ code, token, color }) => {
+ sessionStorage.setItem('room-code', code)
+ sessionStorage.setItem('room-token', token)
+ sessionStorage.setItem('player-color', color)
+ setMpCreds({ code, token })
+ setJoining(null)
+ })
+ .catch((err: unknown) => {
+ const msg =
+ err instanceof Error ? err.message : 'Could not join room'
+ toast.error(`Join failed: ${msg}`)
+ navigate('/', { replace: true })
+ })
+ }, [urlCode, mpCreds, joining, navigate])
+
+ if (urlCode !== undefined && mpCreds === null) {
+ return (
+
+
+ Joining room {urlCode.toUpperCase()}…
+
+
+ )
+ }
+
if (mpCreds !== null) {
return
}
diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts
index c18e2bf..382d0c7 100644
--- a/packages/chess/src/engine.ts
+++ b/packages/chess/src/engine.ts
@@ -25,7 +25,6 @@ import {
getEnPassantMoves,
setEnPassantTarget,
clearEnPassantTarget,
- applyEnPassantCapture,
} from "./rules/enpassant.js";
import {
isPromotionMove,
@@ -45,7 +44,8 @@ import {
recordPosition,
isThreefoldRepetition,
} from "./rules/draws.js";
-import { applyCapture } from "./rules/capture.js";
+import { PIECE_ATTRS } from "./rules/capture.js";
+import type { DamageContext } from "./presets/registry.js";
import type { LegalMove } from "./rules/types.js";
import {
ActivePresetSet,
@@ -166,6 +166,61 @@ export class ChessEngine {
return consumed;
}
+ /**
+ * Deal `amount` damage to `target`, running it through the preset
+ * damage pipeline before applying the default kill-on-damage rule.
+ *
+ * This is the primitive that lets presets compose cleanly:
+ * - Without any damage-interceptor preset (standard chess,
+ * explosive-rook alone, etc.), damage ≥ 1 retracts the target.
+ * - With `piece-hp` active, the hook decrements the target's Hp
+ * fact and only retracts when Hp would go ≤ 0.
+ *
+ * Callers should use this instead of retracting piece facts directly
+ * whenever they want to compose with HP/armor/shield-like presets.
+ * See `explosive-rook` (AoE calls dealDamage on every victim) and
+ * `poisoned-squares` (tick calls dealDamage on every occupant of a
+ * poisoned square) for the canonical call sites.
+ *
+ * The first preset whose `onDamage` returns `consume: true` wins —
+ * further presets are not consulted for that event. This is
+ * deliberate: HP is the ONE authority on damage→death; two damage
+ * interceptors would race and produce zombie state (the bug this
+ * pipeline was designed to eliminate).
+ *
+ * Returns `{ died: true }` if the target was retracted (either by a
+ * hook that killed or by the default path), `{ died: false }` if it
+ * survived (HP absorbed it, or amount was 0).
+ */
+ dealDamage(
+ target: EntityId,
+ amount: number,
+ ctx: DamageContext,
+ ): { died: boolean } {
+ if (amount <= 0) return { died: false };
+
+ // Consult damage interceptors in active-preset list order. First
+ // consumer wins. Scope is not considered here because damage is a
+ // board-level event that can strike any piece regardless of whose
+ // turn it is (e.g. explosive rook hitting friendly pieces).
+ for (const entry of this.activePresets.list()) {
+ const def = PRESET_REGISTRY.get(entry.id);
+ const result = def?.onDamage?.(this, target, amount, ctx);
+ if (result?.consume === true) {
+ return { died: result.died === true };
+ }
+ }
+
+ // Default: any damage is lethal. Retract all piece attributes so
+ // downstream queries see the piece as truly gone.
+ for (const attr of PIECE_ATTRS) {
+ if (this.session.contains(target, attr)) {
+ this.session.retract(target, attr);
+ }
+ }
+ return { died: true };
+ }
+
getCurrentTurn(): PieceColor {
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
}
@@ -244,8 +299,18 @@ export class ChessEngine {
moves.push(...pieceMoves);
}
- // Filter self-check moves
- return filterSelfCheckMoves(this.session, moves, color);
+ // Self-check filter: skip it iff any active preset (for this color)
+ // explicitly opts out via shouldFilterSelfCheck returning false.
+ // This is how `piece-hp` allows the king to stay on an attacked
+ // square — a "hit" costs HP rather than losing the game.
+ let applyFilter = true;
+ for (const preset of this.activePresets.getForColor(color)) {
+ if (preset.shouldFilterSelfCheck?.(this, color) === false) {
+ applyFilter = false;
+ break;
+ }
+ }
+ return applyFilter ? filterSelfCheckMoves(this.session, moves, color) : moves;
}
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
@@ -266,41 +331,73 @@ export class ChessEngine {
if (isEnPassant) {
// En passant captures the pawn on the SKIPPED square, not on
- // `move.to`. Dispatch the preset hook against that off-square
- // target so e.g. piece-hp can decrement HP on the captured pawn.
+ // `move.to`. We still fire `onBeforeCapture` first so presets
+ // like queen-splits / explosive-rook get a chance to transform
+ // the capture wholesale. If none consume, we route the target
+ // through `dealDamage` so the HP pipeline gets to absorb it.
const capturedSquare =
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
const capturedId = this.getPieceAt(capturedSquare);
- const consumed =
- capturedId !== null &&
- this.tryInterceptCapture(move.pieceId, capturedId, color);
- if (consumed) {
- // Preset handled the capture (e.g. damaged the pawn). The
- // attacker does NOT move — consuming the move as a "poke"
- // ends the turn without a positional change.
- } else {
- applyEnPassantCapture(this.session, move, color);
+ let consumed = false;
+ let targetDied = true; // default: no captured pawn found (degenerate), treat as "died"
+ if (capturedId !== null) {
+ consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
+ if (!consumed) {
+ const result = this.dealDamage(capturedId, 1, {
+ kind: "capture",
+ attacker: move.pieceId,
+ });
+ targetDied = result.died;
+ }
+ }
+ // Attacker advances diagonally unless a preset consumed the
+ // capture (it owns positioning) or the captured pawn survived
+ // HP damage (poke — attacker stays).
+ if (!consumed && targetDied) {
+ this.session.insert(move.pieceId, "Position", move.to);
+ this.session.insert(move.pieceId, "HasMoved", true);
}
} else if (isCastling) {
applyCastlingMove(this.session, move as CastlingMove);
} else {
- // Normal move: handle capture, then update position. The preset
- // capture hook is our chance to short-circuit the default
- // retract-and-move behaviour (used by piece-hp for non-lethal
- // damage). If any preset consumes the capture we skip BOTH the
- // retraction AND the attacker's move: the preset turned the
- // capture into a "poke" that just ends the turn.
+ // Normal move. Two intercept layers:
+ // 1. onBeforeCapture — lets a preset replace the capture
+ // mechanic entirely (queen-splits fission, explosive-rook
+ // AoE, capture-to-win winner-recording). If consumed the
+ // engine does nothing else for this capture.
+ // 2. dealDamage — runs after, only if nothing consumed. This
+ // is the pipeline HP hooks, and is what makes "rook
+ // captures pawn" decrement the pawn's Hp by 1 rather than
+ // instantly killing. On default (no interceptor) the target
+ // is retracted and `died=true` is returned.
+ //
+ // The attacker advances onto the target square iff the target
+ // ACTUALLY DIED. Non-lethal captures leave the attacker in
+ // place; turn still advances so the move counts as a "poke".
let consumed = false;
+ let captureResolved = true; // default: no capture happened, treat as "died"
if (move.isCapture) {
const capturedId = this.getPieceAt(move.to);
if (capturedId !== null) {
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
if (!consumed) {
- applyCapture(this.session, capturedId);
+ const { died } = this.dealDamage(capturedId, 1, {
+ kind: "capture",
+ attacker: move.pieceId,
+ });
+ captureResolved = died;
+ } else {
+ // A consuming preset owns ALL post-capture behaviour,
+ // including whether the attacker moved. We don't run the
+ // standard advance below.
+ captureResolved = false;
}
}
}
- if (!consumed) {
+ // Advance attacker unless:
+ // - a preset consumed the capture (it handled positioning), OR
+ // - the capture was non-lethal (attacker stays, target stands)
+ if (!consumed && captureResolved) {
this.session.insert(move.pieceId, "Position", move.to);
this.session.insert(move.pieceId, "HasMoved", true);
}
diff --git a/packages/chess/src/net/lobby-request.ts b/packages/chess/src/net/lobby-request.ts
new file mode 100644
index 0000000..ba7eae9
--- /dev/null
+++ b/packages/chess/src/net/lobby-request.ts
@@ -0,0 +1,91 @@
+/**
+ * One-shot room create/join helper.
+ *
+ * Opens a fresh WebSocket, sends a single `room.create` or `room.join`
+ * envelope, awaits the server's `room.created` / `room.joined` reply,
+ * and closes the socket. Rejects on `error` envelope or connection
+ * timeout.
+ *
+ * This is intentionally separate from `GameClient`: the lobby and the
+ * shared-link auto-join flow both need a single request/response with
+ * no reconnect, no persistent state, no event bus. GameClient is wired
+ * up downstream by the game view once we have a token.
+ *
+ * Extracted from `Lobby.tsx` so the App-level route dispatcher can
+ * reuse it without pulling in the Lobby component.
+ */
+
+const WS_URL =
+ (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ??
+ 'ws://localhost:7357/ws';
+
+interface RoomPayload {
+ code?: string;
+ token?: string;
+ color?: string;
+ message?: string;
+}
+
+interface ServerMsg {
+ type: string;
+ payload: RoomPayload;
+}
+
+export function oneShotRoomRequest(
+ type: 'room.create' | 'room.join',
+ extraPayload: Record,
+): Promise<{ code: string; token: string; color: string }> {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(WS_URL);
+ const timeout = window.setTimeout(() => {
+ ws.close();
+ reject(new Error('Could not connect to server'));
+ }, 5000);
+
+ ws.onopen = () => {
+ ws.send(
+ JSON.stringify({
+ v: 1,
+ seq: 1,
+ ts: Date.now(),
+ type,
+ payload: extraPayload,
+ }),
+ );
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data as string) as ServerMsg;
+ if (
+ (msg.type === 'room.created' || msg.type === 'room.joined') &&
+ typeof msg.payload.code === 'string' &&
+ typeof msg.payload.token === 'string' &&
+ typeof msg.payload.color === 'string'
+ ) {
+ clearTimeout(timeout);
+ ws.close();
+ resolve({
+ code: msg.payload.code,
+ token: msg.payload.token,
+ color: msg.payload.color,
+ });
+ } else if (msg.type === 'error') {
+ clearTimeout(timeout);
+ ws.close();
+ reject(new Error(msg.payload.message ?? 'Server error'));
+ }
+ } catch {
+ /* ignore malformed frames */
+ }
+ };
+
+ ws.onerror = () => {
+ clearTimeout(timeout);
+ reject(new Error('Could not connect to server'));
+ };
+ ws.onclose = () => {
+ clearTimeout(timeout);
+ };
+ });
+}
diff --git a/packages/chess/src/presets/damage-pipeline.test.ts b/packages/chess/src/presets/damage-pipeline.test.ts
new file mode 100644
index 0000000..dfc3763
--- /dev/null
+++ b/packages/chess/src/presets/damage-pipeline.test.ts
@@ -0,0 +1,382 @@
+/**
+ * Tests for the damage pipeline introduced to decouple piece-hp from
+ * other capture-triggered presets.
+ *
+ * What's being tested:
+ * - ChessEngine.dealDamage() default path (damage ≥ 1 retracts the
+ * target) when no preset intercepts.
+ * - piece-hp.onDamage absorbs damage while Hp > amount, kills when
+ * Hp would go ≤ 0.
+ * - explosive-rook + piece-hp composition: the blast deals 1 HP to
+ * each victim in range rather than instant-killing, so pieces
+ * with HP > 1 survive the detonation. This was impossible under
+ * the previous design (the two presets were declared
+ * incompatible).
+ * - queen-splits + piece-hp: fission only happens on a killing
+ * blow; a queen that "pokes" a 2-HP target stays a queen.
+ * - poisoned-squares + piece-hp: damage flows through the pipeline
+ * with ctx.kind === "poison" so future presets could filter it,
+ * but the observable HP decrement is unchanged.
+ *
+ * These tests drive the engine directly (not through the server /
+ * PredictionManager) so they focus strictly on the preset composition
+ * semantics.
+ */
+import { describe, it, expect } from "vitest";
+import "./index.js";
+import { ChessEngine } from "../engine.js";
+import { algebraicToSquare } from "../coord.js";
+import type { EntityId } from "@paratype/rete";
+
+function pieceAt(engine: ChessEngine, sq: string): EntityId | null {
+ const target = algebraicToSquare(sq);
+ for (const f of engine.session.allFacts()) {
+ if (f.attr === "Position" && f.value === target) return f.id;
+ }
+ return null;
+}
+
+function hpOf(engine: ChessEngine, id: EntityId): number | null {
+ if (!engine.session.contains(id, "Hp")) return null;
+ return engine.session.get(id, "Hp") as number;
+}
+
+function exists(engine: ChessEngine, id: EntityId): boolean {
+ return engine.session.contains(id, "PieceType");
+}
+
+// ─────────────────────────────────────────────────────────────────────
+// dealDamage: default (no interceptor) path
+// ─────────────────────────────────────────────────────────────────────
+
+describe("engine.dealDamage — default path (no HP preset)", () => {
+ it("damage >= 1 retracts the target and returns died:true", () => {
+ const engine = new ChessEngine();
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, 1, { kind: "capture" });
+ expect(result.died).toBe(true);
+ expect(exists(engine, pawn)).toBe(false);
+ });
+
+ it("damage 0 is a no-op (survived)", () => {
+ const engine = new ChessEngine();
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, 0, { kind: "capture" });
+ expect(result.died).toBe(false);
+ expect(exists(engine, pawn)).toBe(true);
+ });
+
+ it("negative damage is a no-op (survived)", () => {
+ const engine = new ChessEngine();
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, -3, { kind: "capture" });
+ expect(result.died).toBe(false);
+ expect(exists(engine, pawn)).toBe(true);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────
+// piece-hp.onDamage
+// ─────────────────────────────────────────────────────────────────────
+
+describe("piece-hp.onDamage intercepts", () => {
+ it("target with Hp=2 survives 1 damage with Hp=1", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, 1, { kind: "capture" });
+ expect(result.died).toBe(false);
+ expect(hpOf(engine, pawn)).toBe(1);
+ expect(exists(engine, pawn)).toBe(true);
+ });
+
+ it("target with Hp=1 dies from 1 damage", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+ const pawn = pieceAt(engine, "e2")!;
+ engine.session.insert(pawn, "Hp", 1);
+ const result = engine.dealDamage(pawn, 1, { kind: "capture" });
+ expect(result.died).toBe(true);
+ expect(exists(engine, pawn)).toBe(false);
+ });
+
+ it("amount is respected: Hp=2 dies from 2 damage in one blow", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, 2, { kind: "capture" });
+ expect(result.died).toBe(true);
+ expect(exists(engine, pawn)).toBe(false);
+ });
+
+ it("amount > current Hp also kills (overkill)", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+ const pawn = pieceAt(engine, "e2")!;
+ const result = engine.dealDamage(pawn, 99, { kind: "explosion" });
+ expect(result.died).toBe(true);
+ expect(exists(engine, pawn)).toBe(false);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────
+// explosive-rook + piece-hp composition
+// ─────────────────────────────────────────────────────────────────────
+
+describe("explosive-rook + piece-hp: the combination that used to be forbidden", () => {
+ /**
+ * Build a minimalist position: two lone pieces plus a white rook
+ * positioned to capture, with an "innocent bystander" adjacent to
+ * the target to test AoE. This avoids legality issues with move
+ * scripts and lets us focus on the damage composition.
+ *
+ * Board sketch (white to move; other pieces cleared):
+ * . . . . . . . . (rank 8)
+ * . . . . . . . . (rank 7)
+ * . . . . . . . . (rank 6)
+ * p . . . . . . . a5: black pawn (target)
+ * p . . . . . . . a4: white pawn (friendly bystander)
+ * . . . . . . . . (rank 3)
+ * . . . . . . . . (rank 2)
+ * R . . . . . . . a1: white rook
+ * Rxa5 captures the black pawn; blast hits a4 (dist 1) and a-file
+ * up to a7 (dist 2) — a7 is empty. Also captures within 2 on the
+ * 5th rank — all empty.
+ */
+ function clearAndSetup(engine: ChessEngine): {
+ rook: EntityId;
+ targetPawn: EntityId;
+ friendlyPawn: EntityId;
+ } {
+ // Clear every piece except kings, then inject the trio.
+ const facts = engine.session.allFacts();
+ const toClear: EntityId[] = [];
+ for (const f of facts) {
+ if (f.attr !== "PieceType") continue;
+ if ((f.id as number) <= 0) continue;
+ if (f.value === "king") continue;
+ toClear.push(f.id);
+ }
+ for (const id of toClear) {
+ for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
+ if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
+ }
+ }
+ // Spawn three pieces via direct session inserts with fresh ids.
+ const rook = engine.session.nextId();
+ engine.session.insert(rook, "PieceType", "rook");
+ engine.session.insert(rook, "Color", "white");
+ engine.session.insert(rook, "Position", algebraicToSquare("a1"));
+ const friendlyPawn = engine.session.nextId();
+ engine.session.insert(friendlyPawn, "PieceType", "pawn");
+ engine.session.insert(friendlyPawn, "Color", "white");
+ engine.session.insert(friendlyPawn, "Position", algebraicToSquare("a4"));
+ engine.session.insert(friendlyPawn, "HasMoved", true);
+ const targetPawn = engine.session.nextId();
+ engine.session.insert(targetPawn, "PieceType", "pawn");
+ engine.session.insert(targetPawn, "Color", "black");
+ engine.session.insert(targetPawn, "Position", algebraicToSquare("a5"));
+ engine.session.insert(targetPawn, "HasMoved", true);
+ return { rook, targetPawn, friendlyPawn };
+ }
+
+ it("rook blast deals 1 HP to each victim; pieces with Hp>1 survive", () => {
+ const engine = new ChessEngine();
+ const { rook, targetPawn, friendlyPawn } = clearAndSetup(engine);
+ // Activate presets AFTER setup so piece-hp.onActivate seeds Hp=2
+ // on everyone present (including our injected pieces).
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ { id: "explosive-rook", scope: "both", turnsRemaining: null },
+ ]);
+ expect(hpOf(engine, rook)).toBe(2);
+ expect(hpOf(engine, targetPawn)).toBe(2);
+ expect(hpOf(engine, friendlyPawn)).toBe(2);
+
+ // Rxa5 — legal since a2/a3 are empty and a4 is a friendly
+ // (blocks... wait no, a4 is same color — that blocks the rook).
+ //
+ // So "a4 friendly" stops the rook from even reaching a5 on a
+ // normal move. I need the friendly elsewhere (e.g. b4).
+ //
+ // Delete the previous setup on friendlyPawn; put it on b4 which
+ // is still within rank/file range when the rook reaches a5 via
+ // damage radius on the 5th rank only if on 5th rank or a-file.
+ //
+ // Actually b4 is NOT on the a-file nor the 5th rank with the
+ // blast at a5 — distance to a5 is sqrt(1^2 + 1^2) = diagonal 1,
+ // which orthogonal blast ignores.
+ //
+ // Re-configure: friendly on a7 (adjacent to target on a-file
+ // distance 2). Also need to move it off the rook's path — a7 is
+ // not on the path a1→a5 so safe.
+ engine.session.retract(friendlyPawn, "Position");
+ engine.session.insert(friendlyPawn, "Position", algebraicToSquare("a7"));
+ // Also need to make sure a7 gets Hp seeded — setActivePresets
+ // already did it, so hpOf(friendlyPawn) still 2.
+ expect(hpOf(engine, friendlyPawn)).toBe(2);
+
+ const capture = engine.findMove(
+ algebraicToSquare("a1"),
+ algebraicToSquare("a5"),
+ );
+ expect(capture).not.toBeNull();
+ engine.applyMove(capture!);
+
+ // Expectations:
+ // - Target on a5 survived (Hp 2→1), so the rook STAYED on a1.
+ // - Friendly on a7 also chipped (Hp 2→1), alive.
+ // - Rook unchanged at Hp=2.
+ expect(pieceAt(engine, "a1")).toBe(rook);
+ expect(hpOf(engine, targetPawn)).toBe(1);
+ expect(hpOf(engine, friendlyPawn)).toBe(1);
+ expect(hpOf(engine, rook)).toBe(2);
+ });
+
+ it("without HP, rook blast still nukes (default kills)", () => {
+ const engine = new ChessEngine();
+ const { rook, targetPawn, friendlyPawn } = clearAndSetup(engine);
+ // Same layout; move friendly to a7 so the rook can see a5.
+ engine.session.retract(friendlyPawn, "Position");
+ engine.session.insert(friendlyPawn, "Position", algebraicToSquare("a7"));
+ engine.setActivePresets([
+ { id: "explosive-rook", scope: "both", turnsRemaining: null },
+ ]);
+
+ const capture = engine.findMove(
+ algebraicToSquare("a1"),
+ algebraicToSquare("a5"),
+ );
+ expect(capture).not.toBeNull();
+ engine.applyMove(capture!);
+
+ // No HP — blast kills everyone. Rook advances onto a5.
+ expect(exists(engine, targetPawn)).toBe(false);
+ expect(exists(engine, friendlyPawn)).toBe(false);
+ expect(pieceAt(engine, "a5")).toBe(rook);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────
+// queen-splits + piece-hp composition
+// ─────────────────────────────────────────────────────────────────────
+
+describe("queen-splits + piece-hp: fission only on kills", () => {
+ /** Minimal setup: white queen on d1, black pawn on d4 (clear file). */
+ function clearAndSetup(engine: ChessEngine): {
+ queen: EntityId;
+ target: EntityId;
+ } {
+ const facts = engine.session.allFacts();
+ const toClear: EntityId[] = [];
+ for (const f of facts) {
+ if (f.attr !== "PieceType") continue;
+ if ((f.id as number) <= 0) continue;
+ if (f.value === "king") continue;
+ toClear.push(f.id);
+ }
+ for (const id of toClear) {
+ for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
+ if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
+ }
+ }
+ const queen = engine.session.nextId();
+ engine.session.insert(queen, "PieceType", "queen");
+ engine.session.insert(queen, "Color", "white");
+ engine.session.insert(queen, "Position", algebraicToSquare("d1"));
+ const target = engine.session.nextId();
+ engine.session.insert(target, "PieceType", "pawn");
+ engine.session.insert(target, "Color", "black");
+ engine.session.insert(target, "Position", algebraicToSquare("d4"));
+ engine.session.insert(target, "HasMoved", true);
+ return { queen, target };
+ }
+
+ it("queen pokes Hp=2 target: no fission, queen stays, target Hp=1", () => {
+ const engine = new ChessEngine();
+ const { queen, target } = clearAndSetup(engine);
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ { id: "queen-splits", scope: "both", turnsRemaining: null },
+ ]);
+ expect(hpOf(engine, target)).toBe(2);
+
+ const capture = engine.findMove(
+ algebraicToSquare("d1"),
+ algebraicToSquare("d4"),
+ );
+ expect(capture).not.toBeNull();
+ engine.applyMove(capture!);
+
+ // Target survived (HP absorbed). Queen stays put, didn't fission.
+ expect(pieceAt(engine, "d1")).toBe(queen);
+ expect(pieceAt(engine, "d4")).toBe(target);
+ expect(hpOf(engine, target)).toBe(1);
+ expect(engine.session.get(queen, "PieceType")).toBe("queen");
+ });
+
+ it("queen kills Hp=1 target: fission fires normally", () => {
+ const engine = new ChessEngine();
+ const { queen, target } = clearAndSetup(engine);
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ { id: "queen-splits", scope: "both", turnsRemaining: null },
+ ]);
+ engine.session.insert(target, "Hp", 1); // pre-weaken
+
+ const capture = engine.findMove(
+ algebraicToSquare("d1"),
+ algebraicToSquare("d4"),
+ );
+ expect(capture).not.toBeNull();
+ engine.applyMove(capture!);
+
+ // Target died, queen fissioned: rook on d4, queen entity gone,
+ // bishop on first empty adjacent clockwise-from-N. With a
+ // minimal board the N square (d5) is empty → bishop spawns on d5.
+ expect(exists(engine, queen)).toBe(false);
+ const d4After = pieceAt(engine, "d4")!;
+ expect(engine.session.get(d4After, "PieceType")).toBe("rook");
+ const d5After = pieceAt(engine, "d5");
+ expect(d5After).not.toBeNull();
+ expect(engine.session.get(d5After!, "PieceType")).toBe("bishop");
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────
+// poisoned-squares: damage pipeline integration
+// ─────────────────────────────────────────────────────────────────────
+
+describe("poisoned-squares routes through the damage pipeline", () => {
+ it("each tick deals 1 HP damage via the pipeline; piece dies when Hp hits 0", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ { id: "poisoned-squares", scope: "both", turnsRemaining: null },
+ ]);
+
+ // 1. e4 lands the white e-pawn on e4, a poisoned square. End of
+ // half-move → poison tick → e4 pawn Hp 2->1.
+ engine.applyMove(
+ engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
+ );
+ const e4Pawn = pieceAt(engine, "e4")!;
+ expect(hpOf(engine, e4Pawn)).toBe(1);
+
+ // 2...somewhere e.g. 1...a6. Black moves, white's pawn doesn't
+ // move, but still stands on e4 at the end of this half-move →
+ // another tick → Hp 1->0 → piece dies.
+ engine.applyMove(
+ engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
+ );
+ expect(exists(engine, e4Pawn)).toBe(false);
+ });
+});
diff --git a/packages/chess/src/presets/explosive-rook.ts b/packages/chess/src/presets/explosive-rook.ts
index 247c8f9..e93b7f0 100644
--- a/packages/chess/src/presets/explosive-rook.ts
+++ b/packages/chess/src/presets/explosive-rook.ts
@@ -1,28 +1,25 @@
/**
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
*
- * When a rook captures, it detonates on the target square, removing
- * every piece (friendly or enemy, excluding the capturing rook itself)
- * within Chebyshev distance <= 2 on the same rank or file. Diagonal
- * neighbours are NOT affected — detonation propagates along orthogonal
- * rays only, matching the rook's own movement.
+ * When a rook captures, it detonates on the target square, dealing 1
+ * damage to every piece (friendly or enemy, excluding the capturing
+ * rook itself) within Chebyshev distance <= 2 on the same rank or
+ * file. Diagonal neighbours are NOT affected — detonation propagates
+ * along orthogonal rays only, matching the rook's own movement.
*
- * Implementation via `onBeforeCapture`:
- * - If the attacker isn't a rook, do nothing (default capture runs).
- * - Otherwise consume the hook, then manually:
- * 1. Remove the target piece.
- * 2. Remove every piece within distance 2 on the target's rank or
- * file (friendly or enemy; rook itself excluded).
- * 3. Move the rook onto the target square (so the explosion
- * visually "lands" there).
+ * Composes with HP
+ * ────────────────
+ * Damage is applied via `engine.dealDamage()` rather than direct
+ * retraction. Without HP, 1 damage kills instantly (engine default).
+ * With HP active, every victim takes 1 HP and survives if it had >1
+ * HP — so combining Hit Points with Detonating Rook produces a
+ * natural "chip damage blast" rather than being impossible. This is
+ * the whole point of the damage pipeline: presets don't know about HP,
+ * and HP doesn't know about them.
*
* Explosion does NOT chain — if a captured piece happened to be a
* second rook, its detonation does not re-trigger. Keeping the
* mechanic finite.
- *
- * Incompatible with `piece-hp` — HP's "capture deals 1 damage" and
- * explosive-rook's "capture wipes AoE" are contradictory capture
- * resolutions.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
@@ -32,20 +29,6 @@ import type { Square } from "../schema.js";
const DETONATION_RADIUS = 2;
-const PIECE_ATTRS = [
- "PieceType",
- "Color",
- "Position",
- "HasMoved",
- "Hp",
-] as const;
-
-function retractEntity(session: Session, id: EntityId): void {
- for (const attr of PIECE_ATTRS) {
- if (session.contains(id, attr)) session.retract(id, attr);
- }
-}
-
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
@@ -60,8 +43,8 @@ PRESET_REGISTRY.register({
id: "explosive-rook",
name: "Detonating Rook",
description:
- "When a Rook captures, it detonates: every piece within 2 squares on the same rank or file is removed (friend and foe alike). Diagonals are spared.",
- incompatibleWith: ["piece-hp"],
+ "When a Rook captures, it detonates: every piece within 2 squares on the same rank or file takes 1 damage (friend and foe alike). Diagonals are spared. Composes with Hit Points — pieces with HP>1 survive the blast.",
+ incompatibleWith: [],
requires: [],
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
@@ -77,11 +60,13 @@ PRESET_REGISTRY.register({
const targetFile = fileOf(targetPos);
const targetRank = rankOf(targetPos);
- // Collect detonation victims (orthogonal neighbours within radius).
- // We include the target itself — it gets removed first. We skip
- // the attacker so the rook survives.
- const victims = new Set();
- victims.add(target);
+ // Collect detonation victims (orthogonal neighbours within radius)
+ // BEFORE dealing any damage — we snapshot the Position-facts
+ // because dealDamage may retract pieces mid-loop, which would
+ // otherwise mess up iteration order. The attacker is always
+ // excluded. The target (centre of blast) is included.
+ const victims: EntityId[] = [];
+ victims.push(target);
for (let d = 1; d <= DETONATION_RADIUS; d++) {
for (const [df, dr] of [
@@ -94,18 +79,33 @@ PRESET_REGISTRY.register({
const id = pieceAtSquare(session, sq);
if (id === null) continue;
if (id === attacker) continue;
- victims.add(id);
+ if (id === target) continue; // already added
+ victims.push(id);
}
}
- // Retract every victim's facts — their pieces are gone.
- for (const v of victims) retractEntity(session, v);
+ // Damage the target first — we care specifically about whether
+ // IT died, to decide if the rook advances onto its square. The
+ // other victims just get sprayed with the blast; we don't track
+ // their survival here.
+ const { died: targetDied } = engine.dealDamage(target, 1, {
+ kind: "explosion",
+ attacker,
+ });
+ for (const v of victims) {
+ if (v === target) continue; // already handled
+ engine.dealDamage(v, 1, { kind: "explosion", attacker });
+ }
- // The rook still "captures" by moving onto the target square and
- // has HasMoved set. Default engine path is consumed, so we apply
- // these mutations ourselves.
- session.insert(attacker, "Position", targetPos);
- session.insert(attacker, "HasMoved", true);
+ // Advance the rook only if the target died — matches HP's general
+ // "non-lethal capture = attacker stays" semantics. Without HP,
+ // targetDied is always true so the rook always advances (standard
+ // detonating-rook behaviour). With HP, a target that absorbed the
+ // hit keeps the rook off the square.
+ if (targetDied) {
+ session.insert(attacker, "Position", targetPos);
+ session.insert(attacker, "HasMoved", true);
+ }
return { consume: true };
},
diff --git a/packages/chess/src/presets/fleshed-presets.test.ts b/packages/chess/src/presets/fleshed-presets.test.ts
index 6fa5613..81f2f04 100644
--- a/packages/chess/src/presets/fleshed-presets.test.ts
+++ b/packages/chess/src/presets/fleshed-presets.test.ts
@@ -396,3 +396,100 @@ describe("queen-splits", () => {
expect(typeOf(engine, onD5)).toBe("pawn");
});
});
+
+// ─────────────────────────────────────────────────────────────────────
+// piece-hp + self-check override
+// ─────────────────────────────────────────────────────────────────────
+
+describe("piece-hp: self-check filter override", () => {
+ it("with HP active, moves that leave the king in check are legal", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+
+ // Build a check scenario: 1. e4 e5 2. Nf3 Nc6 3. Bb5 — now Bb5 pins
+ // Nc6 against black's king. Standard chess would forbid Nc6xBb5's
+ // move-partners (not applicable here — we need to show moves NOT
+ // addressing the check are allowed). Easier: set up an explicit
+ // check with an unrelated legal black move available.
+ //
+ // 1. e4 ... 2. Qh5 ... 3. Bc4 ... 4. Qxf7+ puts black in check.
+ engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("d1"), algebraicToSquare("h5"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("a6"), algebraicToSquare("a5"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("f1"), algebraicToSquare("c4"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("a5"), algebraicToSquare("a4"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("h5"), algebraicToSquare("f7"))!);
+ // Black is now "in check" from Qf7 but the queen has only dealt 1 HP
+ // damage (king still has 1 HP left). Standard chess would force
+ // black to address check — with HP, black should be free to make
+ // any legal-by-geometry move, including one that ignores the check
+ // and doesn't block/escape/capture the attacker.
+
+ const a4Square = algebraicToSquare("a4");
+ const a3Square = algebraicToSquare("a3");
+ const blackMoves = engine.getAllLegalMoves();
+ // Find the a4-pawn's moves — normally blocked by self-check filter
+ // because pushing the pawn doesn't address the check on e8.
+ const a4Push = blackMoves.find(
+ (m) => m.from === a4Square && m.to === a3Square,
+ );
+ expect(a4Push).toBeDefined();
+ });
+
+ it("the game is not declared checkmate just because the king is attacked", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+
+ // Standard back-rank mate setup doesn't apply since defenders can
+ // just tank the hit. Fool's Mate almost: 1. f3 e5 2. g4 Qh4#.
+ // Normally this is checkmate; with HP it should NOT be.
+ engine.applyMove(engine.findMove(algebraicToSquare("f2"), algebraicToSquare("f3"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("g2"), algebraicToSquare("g4"))!);
+ engine.applyMove(engine.findMove(algebraicToSquare("d8"), algebraicToSquare("h4"))!);
+ expect(engine.checkGameResult()).toBe("ongoing");
+ });
+
+ it("capturing the enemy king ends the game (white-wins / black-wins)", () => {
+ const engine = new ChessEngine();
+ engine.setActivePresets([
+ { id: "piece-hp", scope: "both", turnsRemaining: null },
+ ]);
+
+ // Shortcut: directly reduce black's king to 1 HP and capture it.
+ const blackKing = pieceAt(engine, "e8")!;
+ engine.session.insert(blackKing, "Hp", 1);
+ // Put a white queen next to black's king on d8 so it can capture.
+ // Clear d8 first.
+ for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
+ const occupant = engine.session
+ .allFacts()
+ .find((f) => f.attr === "Position" && f.value === algebraicToSquare("d8"));
+ if (!occupant) break;
+ if (engine.session.contains(occupant.id, attr)) {
+ engine.session.retract(occupant.id, attr);
+ }
+ }
+ // White queen on d1 → move to d8 via teleport by setting Position.
+ const whiteQueen = pieceAt(engine, "d1")!;
+ engine.session.insert(whiteQueen, "Position", algebraicToSquare("d8"));
+ // Now it's still white's turn (we didn't call applyMove). Find the
+ // Qxe8 capture move.
+ const capture = engine
+ .getAllLegalMoves()
+ .find(
+ (m) =>
+ m.pieceId === whiteQueen &&
+ m.to === algebraicToSquare("e8") &&
+ m.isCapture,
+ );
+ expect(capture).toBeDefined();
+ const result = engine.applyMove(capture!);
+ expect(result).toBe("white-wins");
+ });
+});
diff --git a/packages/chess/src/presets/piece-hp.ts b/packages/chess/src/presets/piece-hp.ts
index 21dcb33..c8d5573 100644
--- a/packages/chess/src/presets/piece-hp.ts
+++ b/packages/chess/src/presets/piece-hp.ts
@@ -1,35 +1,57 @@
/**
* Preset: `piece-hp` (Hit Points, RULES.md rule #13)
*
- * Every piece starts with 2 HP. A capture deals 1 HP damage; the
- * target only dies (is removed from the board) when its HP reaches 0.
- * While the target still has HP, the capturing piece does NOT move —
- * the capture attempt becomes a "poke": turn consumed, target damaged,
- * attacker stays put. This is the canonical variant semantics from
- * the v0 design notes.
+ * Every piece starts with 2 HP. A damage event deals 1 HP; the target
+ * only dies (is retracted from the board) when its HP hits 0. The
+ * engine's capture-path already routes captures through `dealDamage`,
+ * so "capturing a piece" and "taking poison/explosion damage" all
+ * flow through the same hook — piece-hp is the sole arbiter of
+ * damage-to-death for the whole game.
*
* How it integrates
* ─────────────────
- * - onActivate: assert `Hp = 2` on every existing entity on the board.
- * Idempotent: only assigns to entities that don't already have an Hp
- * fact, so replaying activate on an already-HP-loaded session (e.g.
- * after loading server state that included Hp) doesn't reset everyone.
+ * - onActivate: assert `Hp = 2` on every existing entity on the
+ * board. Idempotent: only assigns to entities that don't already
+ * have an Hp fact, so replaying activate on an already-HP-loaded
+ * session (e.g. after loading server state that included Hp)
+ * doesn't reset everyone.
*
* - onDeactivate: retract Hp from every entity. Symmetric cleanup so
- * toggling the preset off mid-game returns the board to the standard
- * "captures are lethal" behaviour without leaving stale attributes.
+ * toggling the preset off mid-game returns the board to standard
+ * "damage kills" behaviour without leaving stale attributes.
*
- * - onBeforeCapture: decrement the target's Hp. If the new Hp is still
- * positive, consume the capture (engine skips the default retract +
- * attacker-move path). If Hp reaches 0, return without consuming so
- * the engine falls through to `applyCapture` and the piece is removed
- * normally.
+ * - onDamage: decrement the target's Hp by `amount`. If the new Hp
+ * is still positive, return `{ consume: true, died: false }` — the
+ * damage pipeline reports "target survived" so the engine knows
+ * not to advance the attacker (capture = poke). If Hp would go to
+ * 0 or below, retract the target ourselves and return `{ consume:
+ * true, died: true }` so the engine advances the attacker as
+ * normal.
*
- * Incompatibilities: explosive-rook (different capture resolution model
- * — AoE instant removal vs. single-target damage).
+ * We DO consume the lethal case rather than falling through to the
+ * engine's default retract, because if we didn't, the default path
+ * would also retract and we'd end up with two retract passes (safe
+ * but wasteful) AND `died` semantics would be fragile — other
+ * presets that read died to cascade triggers (fission, etc) would
+ * see `died=true` coming from the engine's fallback rather than
+ * from HP's explicit decision.
+ *
+ * - shouldFilterSelfCheck: opt out of the engine's "you can't leave
+ * your king in check" move filter. With HP active, being in check
+ * just means your next hit costs HP — moves that ignore the check
+ * are legal (see README for the full variant ruleset).
+ *
+ * - onCheckGameResult: declare terminal game result based on whether
+ * a king has been retracted (won → by the color whose king remains).
+ * Suppresses default checkmate/stalemate because those aren't
+ * meaningful when kings can stand in check.
+ *
+ * Compatibility: now plays nicely with every other damage-dealing
+ * preset (explosive-rook, queen-splits, poisoned-squares, ...) because
+ * they all use the damage pipeline. No more hardcoded incompatibilities.
*/
import { PRESET_REGISTRY } from "./registry.js";
-import type { ChessEngine } from "../engine.js";
+import type { ChessEngine, GameResult } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
/** Starting HP for every piece. Future work: make this per-type so
@@ -50,12 +72,16 @@ function iteratePieceIds(session: Session): EntityId[] {
return [...ids];
}
+/** Piece attributes cleared when HP hits 0 (piece dies). Kept in
+ * lockstep with the canonical list in rules/capture.ts. */
+const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
+
PRESET_REGISTRY.register({
id: "piece-hp",
name: "Hit Points",
description:
- "Every piece has 2 HP. Captures deal 1 damage instead of removing the target. A piece only dies when its HP hits 0; otherwise the capturing piece stays put and turn passes.",
- incompatibleWith: ["explosive-rook"],
+ "Every piece has 2 HP. Damage (capture, explosion, poison…) deals 1 HP instead of immediately killing. A piece only dies when its HP hits 0; until then the attacker doesn't advance — the hit becomes a 'poke'.",
+ incompatibleWith: [],
requires: [],
onActivate(engine: ChessEngine) {
@@ -78,26 +104,121 @@ PRESET_REGISTRY.register({
}
},
- onBeforeCapture(engine: ChessEngine, _attacker: EntityId, target: EntityId) {
+ /**
+ * The damage pipeline's single consumer. Every damage event — whether
+ * from a capture, an AoE explosion, a poisoned square, or something
+ * not yet invented — comes through here when HP is active.
+ *
+ * `amount` is respected: a hit for 2 from a hypothetical "heavy
+ * attack" preset would kill a 2-HP piece in one blow. This makes
+ * piece-hp composable with damage sources that vary in strength
+ * without needing per-source special cases.
+ */
+ onDamage(engine: ChessEngine, target: EntityId, amount: number, _ctx) {
+ // The engine short-circuits amount<=0 before we ever get here,
+ // but be defensive: if somehow invoked with 0, treat it as a
+ // no-op survival.
+ if (amount <= 0) return { consume: true, died: false };
+
const session = engine.session;
- // If for any reason the target lacks an Hp fact (shouldn't happen
- // once onActivate ran, but defensive), install the default so we
- // still behave predictably.
const current = session.contains(target, "Hp")
? (session.get(target, "Hp") as number)
: DEFAULT_HP;
- const next = current - 1;
+ const next = current - amount;
if (next > 0) {
- // Non-lethal: update HP, consume the capture so the engine
- // skips its default retract-and-move path.
session.insert(target, "Hp", next);
- return { consume: true };
+ return { consume: true, died: false };
}
- // Lethal: let the engine fall through to its default capture.
- // The attacker moves onto the target's square and the target is
- // retracted (including its Hp fact, because PIECE_ATTRS includes
- // "Hp"). No return value needed; undefined === don't consume.
- return;
+
+ // Lethal. Retract all piece attrs ourselves so the engine doesn't
+ // need a second retract pass. Returning died:true tells upstream
+ // callers (capture path) that the attacker should advance.
+ for (const attr of PIECE_ATTRS) {
+ if (session.contains(target, attr)) session.retract(target, attr);
+ }
+ return { consume: true, died: true };
+ },
+
+ /**
+ * Opt out of the engine's default self-check filter. With HP active,
+ * being "in check" just means your next hit costs you 1 HP — it
+ * doesn't end the game, and it shouldn't force you to respond. This
+ * lets players move freely even while their king is under attack,
+ * and lets them deliberately move their king into an attacked
+ * square (paying HP when the attacker follows through).
+ */
+ shouldFilterSelfCheck(): boolean {
+ return false;
+ },
+
+ /**
+ * Terminal-state override: whoever lost their king LOSES.
+ *
+ * Without HP, the king is never literally captured — the self-check
+ * filter prevents any move that would let it be. With HP and no
+ * self-check filter, a king CAN be captured (its HP hits 0 via
+ * `onBeforeCapture` and the default capture path retracts it). The
+ * moment that happens, the opposite color wins; standard
+ * checkmate/stalemate logic becomes meaningless because a king that
+ * can be "in check" forever isn't a terminal condition any more.
+ *
+ * Returning `undefined` here means "no preset-level verdict yet —
+ * run the default logic". Once a king is gone we return the winner
+ * directly.
+ */
+ onCheckGameResult(engine: ChessEngine): GameResult | undefined {
+ const session = engine.session;
+ const colors = kingsPresent(session);
+ if (!colors.white && colors.black) return "black-wins";
+ if (colors.white && !colors.black) return "white-wins";
+ if (!colors.white && !colors.black) {
+ // Both kings dead in the same half-move (e.g. an AoE preset
+ // that kills through the attacker too). In practice this is
+ // unreachable through normal captures because capturing is a
+ // single action per move. If it does happen, whoever's turn it
+ // is just finished — so the previous mover delivered the
+ // double-kill. Without tracking that here we award it to the
+ // opponent of the current side to move (the side that just
+ // played).
+ const turn = engine.getCurrentTurn();
+ return turn === "white" ? "black-wins" : "white-wins";
+ }
+ // Both kings on the board → game continues, default rules take over.
+ // But we also need to SUPPRESS the default checkmate/stalemate
+ // verdict (a king "in check" with HP left isn't checkmated). We do
+ // that by explicitly returning "ongoing" so the engine short-
+ // circuits before the default isCheckmate/isStalemate check.
+ //
+ // The default draw rules (50-move, 3fold, insufficient material)
+ // are less meaningful when HP is active — insufficient material
+ // especially, since a rook with HP can still lose to a king — but
+ // leaving them on is harmless since they require a lot of moves
+ // to trigger. We let the engine run them after we return
+ // "ongoing".
+ return "ongoing";
},
});
+
+/**
+ * Check which colors still have a king on the board. A king "on the
+ * board" = there's an entity with PieceType=king AND a Position fact
+ * (the capture path retracts Position alongside everything else).
+ */
+function kingsPresent(session: Session): { white: boolean; black: boolean } {
+ const facts = session.allFacts();
+ let white = false;
+ let black = false;
+ for (const f of facts) {
+ if (f.attr !== "PieceType" || f.value !== "king") continue;
+ // Confirm the king is still ALIVE by checking for a Position fact.
+ const alive = facts.some(
+ (p) => p.id === f.id && p.attr === "Position",
+ );
+ if (!alive) continue;
+ const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
+ if (colorFact?.value === "white") white = true;
+ else if (colorFact?.value === "black") black = true;
+ }
+ return { white, black };
+}
diff --git a/packages/chess/src/presets/poisoned-squares.ts b/packages/chess/src/presets/poisoned-squares.ts
index a1be7f2..5592f44 100644
--- a/packages/chess/src/presets/poisoned-squares.ts
+++ b/packages/chess/src/presets/poisoned-squares.ts
@@ -25,23 +25,11 @@ import type { EntityId } from "@paratype/rete";
* the UI layer to render visual poison cues on the same squares. */
export const POISONED_SQUARES: ReadonlySet = new Set([27, 28, 35, 36]);
-/** Attributes to retract when a piece dies of poison. Kept in sync with
- * the engine's capture.PIECE_ATTRS list; we can't import it directly
- * because poisoned-squares is a pure-data preset and `capture.ts`
- * depends on rete/session. */
-const PIECE_ATTRS = [
- "PieceType",
- "Color",
- "Position",
- "HasMoved",
- "Hp",
-] as const;
-
PRESET_REGISTRY.register({
id: "poisoned-squares",
name: "Poisoned Centre",
description:
- "The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one loses 1 HP per move. Requires Hit Points.",
+ "The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one takes 1 poison damage. Requires Hit Points.",
incompatibleWith: [],
requires: ["piece-hp"],
@@ -50,29 +38,22 @@ PRESET_REGISTRY.register({
const facts = session.allFacts();
// Collect pieces standing on poisoned squares. We snapshot the
- // list BEFORE mutating anything so HP decrements don't interact
- // with iteration semantics.
- const toPoison: Array<{ id: EntityId; hp: number }> = [];
+ // list BEFORE dealing any damage so retractions mid-loop don't
+ // disturb iteration.
+ const victims: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "Position") continue;
if (!POISONED_SQUARES.has(f.value as number)) continue;
if ((f.id as number) <= 0) continue; // game entity
- if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
- const hp = session.get(f.id, "Hp") as number;
- toPoison.push({ id: f.id, hp });
+ victims.push(f.id);
}
- for (const { id, hp } of toPoison) {
- const next = hp - 1;
- if (next > 0) {
- session.insert(id, "Hp", next);
- } else {
- // HP hit 0 — piece dies. Retract all attributes mirroring
- // the normal capture path.
- for (const attr of PIECE_ATTRS) {
- if (session.contains(id, attr)) session.retract(id, attr);
- }
- }
+ // Route through the damage pipeline. piece-hp's onDamage absorbs
+ // 1 HP; when Hp hits 0 the piece is retracted. We tag the kind
+ // as "poison" so a future "poison-resistant" preset could short-
+ // circuit this without changing poisoned-squares' own code.
+ for (const id of victims) {
+ engine.dealDamage(id, 1, { kind: "poison" });
}
},
});
diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts
index 984a448..c229fdc 100644
--- a/packages/chess/src/presets/presets.test.ts
+++ b/packages/chess/src/presets/presets.test.ts
@@ -80,11 +80,17 @@ describe("Incompatibility declarations", () => {
expect(b.incompatibleWith).toContain("capture-to-win");
});
- it("piece-hp ↔ explosive-rook are incompatible", () => {
+ it("piece-hp ↔ explosive-rook now compose via the damage pipeline", () => {
+ // Historical: these were hard-incompatible because each preset
+ // raced to own capture resolution. With the damage pipeline
+ // (registry.DamageHookResult / engine.dealDamage), explosive-rook
+ // calls dealDamage for each blast victim and piece-hp is the sole
+ // damage-to-death arbiter, so they compose naturally: blasts deal
+ // 1 HP to every piece in range.
const a = PRESET_REGISTRY.getAll().find(p => p.id === "piece-hp")!;
const b = PRESET_REGISTRY.getAll().find(p => p.id === "explosive-rook")!;
- expect(a.incompatibleWith).toContain("explosive-rook");
- expect(b.incompatibleWith).toContain("piece-hp");
+ expect(a.incompatibleWith).not.toContain("explosive-rook");
+ expect(b.incompatibleWith).not.toContain("piece-hp");
});
});
diff --git a/packages/chess/src/presets/queen-splits.ts b/packages/chess/src/presets/queen-splits.ts
index 377035b..bbc5655 100644
--- a/packages/chess/src/presets/queen-splits.ts
+++ b/packages/chess/src/presets/queen-splits.ts
@@ -120,11 +120,25 @@ PRESET_REGISTRY.register({
const targetPos = session.get(target, "Position") as Square | undefined;
if (targetPos === undefined) return;
- // Retract target (normal capture) and queen (she's fissioning).
- retractEntity(session, target);
- retractEntity(session, attacker);
+ // Damage the target through the pipeline. Without HP, this kills
+ // instantly (engine default). With HP, it decrements — and we
+ // only fission if the target actually died. A queen that "pokes"
+ // a 2-HP piece doesn't split; she stays a queen, the target loses
+ // 1 HP, and the turn passes — consistent with HP's general
+ // non-lethal-capture rule.
+ const { died: targetDied } = engine.dealDamage(target, 1, {
+ kind: "capture",
+ attacker,
+ });
+ if (!targetDied) {
+ // Poke: queen stays, no fission. We consumed the capture so the
+ // engine shouldn't run its default retract+advance on attacker.
+ return { consume: true };
+ }
- // Spawn the rook on the capture square.
+ // Target died (standard path). Retract the queen (she fissions)
+ // and spawn a rook on the capture square + a bishop nearby.
+ retractEntity(session, attacker);
spawnPiece(session, "rook", attackerColor, targetPos);
// Find the first empty adjacent square clockwise from N and spawn
diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts
index 2f4ba0c..b7c0714 100644
--- a/packages/chess/src/presets/registry.ts
+++ b/packages/chess/src/presets/registry.ts
@@ -55,6 +55,46 @@ export interface CaptureHookResult {
readonly consume?: boolean;
}
+/**
+ * Describes WHERE a unit of damage is coming from. Presets that want
+ * to react only to certain damage sources (e.g. a hypothetical
+ * "poison-resistant" preset that ignores damage of kind "poison") read
+ * `ctx.kind`. The engine sets `attacker` on direct hits; damage with no
+ * attacking entity (poisoned-squares tick, fall damage, etc) leaves it
+ * undefined.
+ *
+ * `kind` is typed as a string union with known values plus free-form
+ * overflow so future presets can introduce new damage kinds without
+ * touching this file. Each preset is responsible for documenting the
+ * kinds it emits.
+ */
+export interface DamageContext {
+ readonly kind: "capture" | "explosion" | "poison" | (string & {});
+ readonly attacker?: EntityId;
+}
+
+/**
+ * Return shape for onDamage. Semantics:
+ * - `consume: true` means "I'm the authority on this damage event;
+ * the engine should NOT run its default (damage >= 1 kills) path."
+ * The hook must also indicate whether the target died via `died`,
+ * because follow-up actions (fission, cascading triggers) depend on
+ * knowing.
+ * - `consume: false` / undefined / no hook means "I don't want to
+ * handle this"; the engine falls through to the next preset and
+ * ultimately the default kill-on-damage path.
+ *
+ * Only ONE preset is expected to consume a given damage event. If more
+ * than one tries, the first to return `consume: true` wins (iteration
+ * order follows `activePresets.list()`). This is the conscious
+ * simplification that eliminates the zombie-state bug we had when two
+ * presets' onBeforeCapture hooks both set consume: true.
+ */
+export interface DamageHookResult {
+ readonly consume: boolean;
+ readonly died?: boolean;
+}
+
export interface PresetDef {
readonly id: string;
readonly name: string;
@@ -83,6 +123,36 @@ export interface PresetDef {
target: EntityId,
) => CaptureHookResult | void;
+ /**
+ * Intercept a damage event before the engine applies its default
+ * "damage >= 1 kills" behaviour. This is the pipeline that lets HP
+ * exist as a self-contained preset: `piece-hp` implements onDamage
+ * to decrement an Hp attribute and only falls through to death when
+ * Hp would go <= 0. Other presets (explosive-rook, poisoned-squares,
+ * etc) call `engine.dealDamage()` instead of retracting pieces
+ * directly, which means they compose with HP for free — without ever
+ * importing or referencing HP-specific code.
+ *
+ * Contract (see DamageHookResult docs):
+ * - return `{ consume: true, died }` to handle the damage event;
+ * `died` tells the engine whether the target entity was retracted
+ * and upstream callers (e.g. default capture path) whether the
+ * attacker should advance onto the square.
+ * - return `undefined` / `{ consume: false }` to let other presets
+ * or the engine default handle it.
+ *
+ * ORDERING: the engine iterates active presets in list order and
+ * stops at the first consumer. Consequently only ONE preset is
+ * expected to implement onDamage meaningfully at a time — `piece-hp`
+ * is the canonical implementer.
+ */
+ readonly onDamage?: (
+ engine: ChessEngine,
+ target: EntityId,
+ amount: number,
+ ctx: DamageContext,
+ ) => DamageHookResult | void;
+
/**
* Fires after every successful `applyMove`, after turn advancement
* and tickAfterMove but before checkGameResult. `moverColor` is the
@@ -114,6 +184,27 @@ export interface PresetDef {
* redefine "when is the game over".
*/
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
+
+ /**
+ * Opt out of the engine's default self-check filter for moves of
+ * `color`. Return `false` to allow moves that leave the king attacked
+ * (e.g. `piece-hp`, where a "hit" only costs HP rather than ending
+ * the game). Return `true` or `undefined` to leave the filter in
+ * place.
+ *
+ * The engine polls every preset whose scope covers `color` and
+ * skips the self-check filter iff at least one returns `false`. A
+ * preset that doesn't care leaves this hook undefined.
+ *
+ * Note: presets that opt out of the self-check filter almost always
+ * also want to override `onCheckGameResult` — otherwise default
+ * checkmate/stalemate detection still fires against a king that can
+ * legally survive being attacked.
+ */
+ readonly shouldFilterSelfCheck?: (
+ engine: ChessEngine,
+ color: "white" | "black",
+ ) => boolean | undefined;
}
/**
diff --git a/packages/chess/src/rules/capture.ts b/packages/chess/src/rules/capture.ts
index 5ce9fd9..9c34f30 100644
--- a/packages/chess/src/rules/capture.ts
+++ b/packages/chess/src/rules/capture.ts
@@ -5,8 +5,10 @@
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor } from "../schema.js";
-/** All known chess fact attributes for a piece entity. */
-const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
+/** All known chess fact attributes for a piece entity.
+ * Exported so presets and the damage pipeline can stay in lockstep
+ * without each duplicating the list. */
+export const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
/**
* Apply a capture: retract all known facts for the captured piece.
diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx
index 1d404c5..b6816eb 100644
--- a/packages/chess/src/ui/GameView.tsx
+++ b/packages/chess/src/ui/GameView.tsx
@@ -11,7 +11,8 @@ import { isInCheck } from '../rules/check';
import { useEffect, useState } from 'react';
import confetti from 'canvas-confetti';
import { motion, AnimatePresence } from 'motion/react';
-import { Volume2, VolumeX } from 'lucide-react';
+import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
+import { toast } from 'sonner';
import * as audio from '../audio';
/**
@@ -51,7 +52,7 @@ interface GameViewProps {
export function GameView({ engineState }: GameViewProps) {
const localChessState = useChessEngine();
const state = engineState || localChessState;
- return ;
+ return ;
}
interface MultiplayerGameViewProps {
@@ -76,11 +77,18 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
// authoritative position.
if (state.loading) {
return (
-
+
Waiting for opponent…
-
+
Room: {code}
+ {/* Prominent share affordance — this is the screen the creator
+ sees before anyone joins, so it MUST make copying the invite
+ link obvious. */}
+
+
+ Share the room code or this page's URL with your opponent.
+
)}
-
+
>
);
}
@@ -109,9 +117,13 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
function GameLayout({
state,
myColor,
+ roomCode,
}: {
state: GameEngineState;
myColor: Color | null;
+ /** Shareable room code — non-null iff we're in multiplayer mode.
+ * Drives the RoomShareBadge next to the "You are white" pill. */
+ roomCode: string | null;
}) {
const {
engine,
@@ -241,6 +253,7 @@ function GameLayout({
You are {myColor}
)}
+ {roomCode !== null && }
@@ -384,3 +397,61 @@ function GameLayout({
);
}
+
+/**
+ * Shareable room code pill. Clicking copies the full /game/:code URL to
+ * the clipboard so the creator can paste it straight into chat. The pill
+ * stays present throughout the game — a second player joining later (or
+ * a spectator, once that's a feature) can always be handed a link.
+ *
+ * `navigator.clipboard.writeText` is the modern API, available in every
+ * browser we support. If it somehow fails (permissions-policy, HTTP-only
+ * contexts on localhost, etc.) we fall back to a toast telling the user
+ * to copy manually.
+ */
+function RoomShareBadge({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const shareUrl =
+ typeof window !== 'undefined'
+ ? `${window.location.origin}/game/${code}`
+ : `/game/${code}`;
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ setCopied(true);
+ toast.success('Link copied — share it with your opponent');
+ // Revert the icon after a short beat so repeat copies register visibly.
+ window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ toast.error(`Copy failed — link is ${shareUrl}`);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx
index 875450c..f5cd02a 100644
--- a/packages/chess/src/ui/Lobby.tsx
+++ b/packages/chess/src/ui/Lobby.tsx
@@ -4,89 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
import { ChessEngine } from '../engine';
import { clearAutoSave } from '../persist/autosave';
-
-const WS_URL =
- (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ??
- 'ws://localhost:7357/ws';
-
-interface RoomPayload {
- code?: string;
- token?: string;
- color?: string;
- message?: string;
-}
-
-interface ServerMsg {
- type: string;
- payload: RoomPayload;
-}
-
-/**
- * Opens a fresh WebSocket, sends one envelope, then returns a promise that
- * resolves on the first room.created / room.joined reply or rejects on error.
- *
- * We use a raw WebSocket (not GameClient) because the Lobby's create/join
- * flow is a single request/response pair — no reconnect, no persistent state.
- * GameClient is wired up downstream by the game view once we have a token.
- */
-function oneShotRoomRequest(
- type: 'room.create' | 'room.join',
- extraPayload: Record,
-): Promise<{ code: string; token: string; color: string }> {
- return new Promise((resolve, reject) => {
- const ws = new WebSocket(WS_URL);
- const timeout = window.setTimeout(() => {
- ws.close();
- reject(new Error('Could not connect to server'));
- }, 5000);
-
- ws.onopen = () => {
- ws.send(
- JSON.stringify({
- v: 1,
- seq: 1,
- ts: Date.now(),
- type,
- payload: extraPayload,
- }),
- );
- };
-
- ws.onmessage = (event) => {
- try {
- const msg = JSON.parse(event.data as string) as ServerMsg;
- if (
- (msg.type === 'room.created' || msg.type === 'room.joined') &&
- typeof msg.payload.code === 'string' &&
- typeof msg.payload.token === 'string' &&
- typeof msg.payload.color === 'string'
- ) {
- clearTimeout(timeout);
- ws.close();
- resolve({
- code: msg.payload.code,
- token: msg.payload.token,
- color: msg.payload.color,
- });
- } else if (msg.type === 'error') {
- clearTimeout(timeout);
- ws.close();
- reject(new Error(msg.payload.message ?? 'Server error'));
- }
- } catch {
- /* ignore malformed frames */
- }
- };
-
- ws.onerror = () => {
- clearTimeout(timeout);
- reject(new Error('Could not connect to server'));
- };
- ws.onclose = () => {
- clearTimeout(timeout);
- };
- });
-}
+import { oneShotRoomRequest } from '../net/lobby-request';
interface LobbyProps {
/** Optional — when provided, create/join/solo flows reset the local
@@ -99,7 +17,6 @@ interface LobbyProps {
export function Lobby({ chessState }: LobbyProps = {}) {
const [codeInput, setCodeInput] = useState('');
- const [roomCode, setRoomCode] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
@@ -122,7 +39,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
sessionStorage.setItem('room-token', token);
sessionStorage.setItem('player-color', color);
resetToFreshGame();
- setRoomCode(code);
+ // Navigate straight to the canonical shareable URL — no
+ // intermediate "Room created" card. The GameView itself renders
+ // the room code + copy-link pill so the creator can share it.
+ navigate(`/game/${code}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not connect to server');
} finally {
@@ -144,7 +64,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
sessionStorage.setItem('room-token', result.token);
sessionStorage.setItem('player-color', result.color);
resetToFreshGame();
- navigate('/game');
+ navigate(`/game/${result.code}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid room code');
} finally {
@@ -192,41 +112,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
Host Game
- {!roomCode ? (
-
- ) : (
-
-
- Room Code
-
- {roomCode}
-
-
-
-
- )}
+
diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx
index ade0a48..2a7074b 100644
--- a/packages/chess/src/ui/RulesDrawer.tsx
+++ b/packages/chess/src/ui/RulesDrawer.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, type ChangeEvent } from 'react';
+import { useEffect, 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';
@@ -72,6 +72,37 @@ export function RulesDrawer({
return map;
}, [activations]);
+ // id → human-readable preset name, used for rendering dependency chips.
+ // Built once from the registry (presets are static); falls back to the
+ // raw id if a preset references something not in the registry (future-
+ // proofing against typos in `requires` / `incompatibleWith`).
+ const nameById = useMemo(() => {
+ const map = new Map();
+ for (const p of presets) map.set(p.id, p.name);
+ return (id: string) => map.get(id) ?? id;
+ }, [presets]);
+
+ // Lock page scroll while the drawer is open. Without this, scrolling
+ // past the drawer's top/bottom (or scrolling on the backdrop) bubbles
+ // up to and drifts the game view behind the drawer. We also
+ // compensate for the disappearing scrollbar by adding right padding
+ // equal to its width, so the board doesn't jump horizontally.
+ useEffect(() => {
+ if (!open) return;
+ const { body, documentElement: html } = document;
+ const scrollbarWidth = window.innerWidth - html.clientWidth;
+ const prevOverflow = body.style.overflow;
+ const prevPaddingRight = body.style.paddingRight;
+ body.style.overflow = 'hidden';
+ if (scrollbarWidth > 0) {
+ body.style.paddingRight = `${scrollbarWidth}px`;
+ }
+ return () => {
+ body.style.overflow = prevOverflow;
+ body.style.paddingRight = prevPaddingRight;
+ };
+ }, [open]);
+
const toggle = (id: string, name: string) => {
const currently = activeById.get(id);
const next: PresetActivation[] = currently
@@ -155,7 +186,9 @@ export function RulesDrawer({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
- className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40"
+ // touch-none blocks mobile gesture scroll on the backdrop;
+ // overscroll-contain belt-and-suspenders with the body lock.
+ className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40 overscroll-contain touch-none"
onClick={() => setOpen(false)}
/>
-
+
{presets.map((preset) => {
const active = activeById.get(preset.id);
const isOn = active !== undefined;
+
+ // Compute whether this preset CAN be toggled right now
+ // given the currently-active set. Pre-checking mirrors
+ // the engine's own validation in
+ // `ActivePresetSet.replaceAll` so we don't round-trip
+ // to the server just to get an error back (which
+ // would produce a misleading "Preset enabled" toast
+ // followed by an error banner). Scope defaults to
+ // 'both', so overlap is always true for this quick
+ // check.
+ //
+ // Enable block: a currently-active preset conflicts,
+ // OR a required preset isn't active.
+ // Disable block: another active preset REQUIRES this
+ // one. (Turning off piece-hp while
+ // king-heals is on would fail.)
+ //
+ // Returns null when the toggle action would succeed,
+ // otherwise a structured reason used to drive the
+ // disabled toggle + tooltip.
+ let blockReason:
+ | null
+ | { kind: 'conflicts'; withName: string }
+ | { kind: 'missing'; needsName: string }
+ | { kind: 'depended-on-by'; dependentName: string } = null;
+ if (!isOn) {
+ for (const otherId of preset.incompatibleWith) {
+ if (activeById.has(otherId)) {
+ blockReason = {
+ kind: 'conflicts',
+ withName: nameById(otherId),
+ };
+ break;
+ }
+ }
+ if (blockReason === null) {
+ for (const depId of preset.requires) {
+ if (!activeById.has(depId)) {
+ blockReason = {
+ kind: 'missing',
+ needsName: nameById(depId),
+ };
+ break;
+ }
+ }
+ }
+ } else {
+ // Disable path: check whether any OTHER active
+ // preset requires this one. If so, disabling would
+ // leave that preset with an unmet dependency and
+ // the server / engine would reject the whole set.
+ for (const other of presets) {
+ if (!activeById.has(other.id)) continue;
+ if (other.id === preset.id) continue;
+ if (other.requires.includes(preset.id)) {
+ blockReason = {
+ kind: 'depended-on-by',
+ dependentName: other.name,
+ };
+ break;
+ }
+ }
+ }
+ const toggleDisabled = blockReason !== null;
+ const blockMessage =
+ blockReason === null
+ ? undefined
+ : blockReason.kind === 'conflicts'
+ ? `Disable "${blockReason.withName}" first — it conflicts with this rule`
+ : blockReason.kind === 'missing'
+ ? `Enable "${blockReason.needsName}" first — this rule requires it`
+ : `Disable "${blockReason.dependentName}" first — it requires this rule`;
+
return (