From 7c4c9429388c4e839c63361813b0119d9f397560 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 18:59:11 -0600 Subject: [PATCH] Preset refactors for HP --- packages/chess/e2e/full-flow.spec.d.ts | 2 + packages/chess/e2e/full-flow.spec.d.ts.map | 1 + packages/chess/e2e/full-flow.spec.js | 105 +++++ packages/chess/e2e/full-flow.spec.js.map | 1 + packages/chess/src/app/App.tsx | 103 ++++- packages/chess/src/engine.ts | 143 +++++-- packages/chess/src/net/lobby-request.ts | 91 +++++ .../chess/src/presets/damage-pipeline.test.ts | 382 ++++++++++++++++++ packages/chess/src/presets/explosive-rook.ts | 92 ++--- .../chess/src/presets/fleshed-presets.test.ts | 97 +++++ packages/chess/src/presets/piece-hp.ts | 191 +++++++-- .../chess/src/presets/poisoned-squares.ts | 41 +- packages/chess/src/presets/presets.test.ts | 12 +- packages/chess/src/presets/queen-splits.ts | 22 +- packages/chess/src/presets/registry.ts | 91 +++++ packages/chess/src/rules/capture.ts | 6 +- packages/chess/src/ui/GameView.tsx | 81 +++- packages/chess/src/ui/Lobby.tsx | 135 +------ packages/chess/src/ui/RulesDrawer.tsx | 189 ++++++++- 19 files changed, 1497 insertions(+), 288 deletions(-) create mode 100644 packages/chess/e2e/full-flow.spec.d.ts create mode 100644 packages/chess/e2e/full-flow.spec.d.ts.map create mode 100644 packages/chess/e2e/full-flow.spec.js create mode 100644 packages/chess/e2e/full-flow.spec.js.map create mode 100644 packages/chess/src/net/lobby-request.ts create mode 100644 packages/chess/src/presets/damage-pipeline.test.ts 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":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAEhD;;;;;;;;GAQG;AACH,IAAI,CAAC,4FAA4F,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;IAE7H,8EAA8E;IAC9E,MAAM,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1B,oEAAoE;IACpE,MAAM,IAAI,CAAC,OAAO,CAAC,0DAA0D,CAAC,CAAC,KAAK,EAAE,CAAC;IACvF,0CAA0C;IAC1C,MAAM,IAAI,CAAC,OAAO,CAAC,yDAAyD,CAAC,CAAC,KAAK,EAAE,CAAC;IACtF,uDAAuD;IACvD,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;IAE/E,MAAM,IAAI,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC,KAAK,EAAE,CAAC;IAC7D,MAAM,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IAEjC,6EAA6E;IAC7E,EAAE;IACF,6EAA6E;IAC7E,4EAA4E;IAC5E,8EAA8E;IAC9E,iFAAiF;IACjF,EAAE;IACF,6EAA6E;IAC7E,+EAA+E;IAC/E,yCAAyC;IACzC,MAAM,IAAI,GAAG,KAAK,EAAE,IAAY,EAAE,EAAU,EAAE,EAAE;QAC9C,MAAM,IAAI;aACP,OAAO,CAAC,iBAAiB,IAAI,iBAAiB,CAAC;aAC/C,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC,CAAC;IACnD,CAAC,CAAC;IAEF,mCAAmC;IACnC,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,QAAQ;IAChC,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;IACjC,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;IACjC,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,UAAU;IAClC,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;IAEjC,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAEpF,2BAA2B;IAC3B,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,mDAAmD;IAC3E,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,mBAAmB;IAC3C,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,6BAA6B;IAErD,6EAA6E;IAC7E,4EAA4E;IAC5E,4EAA4E;IAC5E,wDAAwD;IACxD,MAAM,IAAI,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,KAAK,EAAE,CAAC;IACnD,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,gDAAgD,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAE3F,gEAAgE;IAChE,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,kBAAkB;IAE1C,8EAA8E;IAC9E,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzB,MAAM,IAAI,CAAC,IAAI,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAC;IACvD,MAAM,IAAI,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,KAAK,EAAE,CAAC;IACnD,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,yCAAyC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAEpF,6EAA6E;IAC7E,6EAA6E;IAC7E,wDAAwD;IACxD,wEAAwE;IACxE,qBAAqB;IACrB,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;IACpB,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzB,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,+CAA+C,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAC1F,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,gDAAgD,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAE3F,6EAA6E;IAC7E,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzB,MAAM,eAAe,GAAG,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;IACtD,MAAM,IAAI,CAAC,OAAO,CAAC,wBAAwB,CAAC,CAAC,KAAK,EAAE,CAAC;IACrD,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC;IACvC,MAAM,UAAU,GAAG,qBAAqB,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC;IAC1D,MAAM,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAElC,8EAA8E;IAC9E,MAAM,YAAY,GAAG,MAAM,OAAO,CAAC,UAAU,EAAE,CAAC;IAChD,MAAM,SAAS,GAAG,MAAM,YAAY,CAAC,OAAO,EAAE,CAAC;IAC/C,MAAM,SAAS,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;IAEnD,8EAA8E;IAC9E,8EAA8E;IAC9E,MAAM,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1D,MAAM,SAAS,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC;IAC1C,MAAM,SAAS,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IAEtC,+DAA+D;IAC/D,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,+CAA+C,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAC/F,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,gDAAgD,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAEhG,4EAA4E;IAC5E,uEAAuE;IACvE,uEAAuE;IACvE,yDAAyD;IACzD,kCAAkC;IAClC,mEAAmE;IACnE,4EAA4E;IAC5E,kCAAkC;IAClC,wDAAwD;IACxD,MAAM,SAAS;SACZ,OAAO,CAAC,iCAAiC,CAAC;SAC1C,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAC;IAEnD,6EAA6E;IAC7E,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,2BAA2B,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IAE3E,MAAM,YAAY,CAAC,KAAK,EAAE,CAAC;AAC7B,CAAC,CAAC,CAAC"} \ 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. +

); } @@ -95,7 +103,7 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) { {state.error}
)} - + ); } @@ -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 (
{preset.description}

+ {(preset.requires.length > 0 || + preset.incompatibleWith.length > 0) && ( +
+ {preset.requires.map((depId) => { + const depActive = activeById.has(depId); + return ( + + + Requires {nameById(depId)} + + ); + })} + {preset.incompatibleWith.map((otherId) => { + const otherActive = activeById.has(otherId); + return ( + + + Conflicts with {nameById(otherId)} + + ); + })} +
+ )}