Preset refactors for HP
This commit is contained in:
parent
6883539426
commit
7c4c942938
19 changed files with 1497 additions and 288 deletions
2
packages/chess/e2e/full-flow.spec.d.ts
vendored
Normal file
2
packages/chess/e2e/full-flow.spec.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=full-flow.spec.d.ts.map
|
||||||
1
packages/chess/e2e/full-flow.spec.d.ts.map
Normal file
1
packages/chess/e2e/full-flow.spec.d.ts.map
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"full-flow.spec.d.ts","sourceRoot":"","sources":["full-flow.spec.ts"],"names":[],"mappings":""}
|
||||||
105
packages/chess/e2e/full-flow.spec.js
Normal file
105
packages/chess/e2e/full-flow.spec.js
Normal file
|
|
@ -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
|
||||||
1
packages/chess/e2e/full-flow.spec.js.map
Normal file
1
packages/chess/e2e/full-flow.spec.js.map
Normal file
|
|
@ -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"}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 { Lobby } from '../ui/Lobby'
|
||||||
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
||||||
import { RulesView } from '../ui/RulesView'
|
import { RulesView } from '../ui/RulesView'
|
||||||
|
|
@ -8,9 +8,10 @@ import { ImportExport } from '../ui/ImportExport'
|
||||||
import { useChessEngine } from '../hooks/useChessEngine'
|
import { useChessEngine } from '../hooks/useChessEngine'
|
||||||
import { ChessEngine } from '../engine'
|
import { ChessEngine } from '../engine'
|
||||||
import { loadAutoSave } from '../persist/autosave.js'
|
import { loadAutoSave } from '../persist/autosave.js'
|
||||||
|
import { oneShotRoomRequest } from '../net/lobby-request'
|
||||||
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
|
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster, toast } from 'sonner'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const chessState = useChessEngine()
|
const chessState = useChessEngine()
|
||||||
|
|
@ -34,6 +35,7 @@ export function App() {
|
||||||
<Routes location={location} key={location.pathname}>
|
<Routes location={location} key={location.pathname}>
|
||||||
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
|
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
|
||||||
<Route path="/game" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
<Route path="/game" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
||||||
|
<Route path="/game/:code" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
||||||
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
||||||
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -43,29 +45,102 @@ export function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /game route dispatcher. Reads sessionStorage ONCE at mount to decide
|
* /game and /game/:code route dispatcher.
|
||||||
* between single-player (local engine via useChessEngine) and
|
|
||||||
* multiplayer (server-backed via useMultiplayerGame).
|
|
||||||
*
|
*
|
||||||
* We snapshot into state so that a subsequent sessionStorage mutation
|
* Four ways to land here:
|
||||||
* (e.g. a route change back to the lobby) doesn't swap the hook used
|
* 1. /game with creds in sessionStorage — existing mid-game reload.
|
||||||
* by an already-mounted game view — React's rules of hooks require the
|
* We redirect to the canonical /game/:code URL so the address bar
|
||||||
* chosen branch to stay stable for the component's lifetime.
|
* 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,
|
* We snapshot mpCreds into state so that a subsequent sessionStorage
|
||||||
* so this pickup is deterministic. If either is missing we render the
|
* mutation (e.g. route change back to the lobby) doesn't swap the hook
|
||||||
* local mode.
|
* 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({
|
function GameRoute({
|
||||||
chessState,
|
chessState,
|
||||||
}: {
|
}: {
|
||||||
chessState: ReturnType<typeof useChessEngine>
|
chessState: ReturnType<typeof useChessEngine>
|
||||||
}) {
|
}) {
|
||||||
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<string | null>(null)
|
||||||
|
|
||||||
|
const [mpCreds, setMpCreds] = useState(() => {
|
||||||
const code = sessionStorage.getItem('room-code')
|
const code = sessionStorage.getItem('room-code')
|
||||||
const token = sessionStorage.getItem('room-token')
|
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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-24"
|
||||||
|
data-testid="mp-joining"
|
||||||
|
>
|
||||||
|
<div className="text-neutral-500 font-medium">
|
||||||
|
Joining room {urlCode.toUpperCase()}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (mpCreds !== null) {
|
if (mpCreds !== null) {
|
||||||
return <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
return <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
getEnPassantMoves,
|
getEnPassantMoves,
|
||||||
setEnPassantTarget,
|
setEnPassantTarget,
|
||||||
clearEnPassantTarget,
|
clearEnPassantTarget,
|
||||||
applyEnPassantCapture,
|
|
||||||
} from "./rules/enpassant.js";
|
} from "./rules/enpassant.js";
|
||||||
import {
|
import {
|
||||||
isPromotionMove,
|
isPromotionMove,
|
||||||
|
|
@ -45,7 +44,8 @@ import {
|
||||||
recordPosition,
|
recordPosition,
|
||||||
isThreefoldRepetition,
|
isThreefoldRepetition,
|
||||||
} from "./rules/draws.js";
|
} 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 type { LegalMove } from "./rules/types.js";
|
||||||
import {
|
import {
|
||||||
ActivePresetSet,
|
ActivePresetSet,
|
||||||
|
|
@ -166,6 +166,61 @@ export class ChessEngine {
|
||||||
return consumed;
|
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 {
|
getCurrentTurn(): PieceColor {
|
||||||
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
|
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
|
||||||
}
|
}
|
||||||
|
|
@ -244,8 +299,18 @@ export class ChessEngine {
|
||||||
moves.push(...pieceMoves);
|
moves.push(...pieceMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter self-check moves
|
// Self-check filter: skip it iff any active preset (for this color)
|
||||||
return filterSelfCheckMoves(this.session, moves, 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 {
|
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
||||||
|
|
@ -266,41 +331,73 @@ export class ChessEngine {
|
||||||
|
|
||||||
if (isEnPassant) {
|
if (isEnPassant) {
|
||||||
// En passant captures the pawn on the SKIPPED square, not on
|
// En passant captures the pawn on the SKIPPED square, not on
|
||||||
// `move.to`. Dispatch the preset hook against that off-square
|
// `move.to`. We still fire `onBeforeCapture` first so presets
|
||||||
// target so e.g. piece-hp can decrement HP on the captured pawn.
|
// 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 =
|
const capturedSquare =
|
||||||
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
|
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
|
||||||
const capturedId = this.getPieceAt(capturedSquare);
|
const capturedId = this.getPieceAt(capturedSquare);
|
||||||
const consumed =
|
let consumed = false;
|
||||||
capturedId !== null &&
|
let targetDied = true; // default: no captured pawn found (degenerate), treat as "died"
|
||||||
this.tryInterceptCapture(move.pieceId, capturedId, color);
|
if (capturedId !== null) {
|
||||||
if (consumed) {
|
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||||
// Preset handled the capture (e.g. damaged the pawn). The
|
if (!consumed) {
|
||||||
// attacker does NOT move — consuming the move as a "poke"
|
const result = this.dealDamage(capturedId, 1, {
|
||||||
// ends the turn without a positional change.
|
kind: "capture",
|
||||||
} else {
|
attacker: move.pieceId,
|
||||||
applyEnPassantCapture(this.session, move, color);
|
});
|
||||||
|
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) {
|
} else if (isCastling) {
|
||||||
applyCastlingMove(this.session, move as CastlingMove);
|
applyCastlingMove(this.session, move as CastlingMove);
|
||||||
} else {
|
} else {
|
||||||
// Normal move: handle capture, then update position. The preset
|
// Normal move. Two intercept layers:
|
||||||
// capture hook is our chance to short-circuit the default
|
// 1. onBeforeCapture — lets a preset replace the capture
|
||||||
// retract-and-move behaviour (used by piece-hp for non-lethal
|
// mechanic entirely (queen-splits fission, explosive-rook
|
||||||
// damage). If any preset consumes the capture we skip BOTH the
|
// AoE, capture-to-win winner-recording). If consumed the
|
||||||
// retraction AND the attacker's move: the preset turned the
|
// engine does nothing else for this capture.
|
||||||
// capture into a "poke" that just ends the turn.
|
// 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 consumed = false;
|
||||||
|
let captureResolved = true; // default: no capture happened, treat as "died"
|
||||||
if (move.isCapture) {
|
if (move.isCapture) {
|
||||||
const capturedId = this.getPieceAt(move.to);
|
const capturedId = this.getPieceAt(move.to);
|
||||||
if (capturedId !== null) {
|
if (capturedId !== null) {
|
||||||
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||||
if (!consumed) {
|
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, "Position", move.to);
|
||||||
this.session.insert(move.pieceId, "HasMoved", true);
|
this.session.insert(move.pieceId, "HasMoved", true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
packages/chess/src/net/lobby-request.ts
Normal file
91
packages/chess/src/net/lobby-request.ts
Normal file
|
|
@ -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<string, string> }).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<string, unknown>,
|
||||||
|
): 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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
382
packages/chess/src/presets/damage-pipeline.test.ts
Normal file
382
packages/chess/src/presets/damage-pipeline.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
/**
|
/**
|
||||||
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
|
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
|
||||||
*
|
*
|
||||||
* When a rook captures, it detonates on the target square, removing
|
* When a rook captures, it detonates on the target square, dealing 1
|
||||||
* every piece (friendly or enemy, excluding the capturing rook itself)
|
* damage to every piece (friendly or enemy, excluding the capturing
|
||||||
* within Chebyshev distance <= 2 on the same rank or file. Diagonal
|
* rook itself) within Chebyshev distance <= 2 on the same rank or
|
||||||
* neighbours are NOT affected — detonation propagates along orthogonal
|
* file. Diagonal neighbours are NOT affected — detonation propagates
|
||||||
* rays only, matching the rook's own movement.
|
* along orthogonal rays only, matching the rook's own movement.
|
||||||
*
|
*
|
||||||
* Implementation via `onBeforeCapture`:
|
* Composes with HP
|
||||||
* - If the attacker isn't a rook, do nothing (default capture runs).
|
* ────────────────
|
||||||
* - Otherwise consume the hook, then manually:
|
* Damage is applied via `engine.dealDamage()` rather than direct
|
||||||
* 1. Remove the target piece.
|
* retraction. Without HP, 1 damage kills instantly (engine default).
|
||||||
* 2. Remove every piece within distance 2 on the target's rank or
|
* With HP active, every victim takes 1 HP and survives if it had >1
|
||||||
* file (friendly or enemy; rook itself excluded).
|
* HP — so combining Hit Points with Detonating Rook produces a
|
||||||
* 3. Move the rook onto the target square (so the explosion
|
* natural "chip damage blast" rather than being impossible. This is
|
||||||
* visually "lands" there).
|
* 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
|
* Explosion does NOT chain — if a captured piece happened to be a
|
||||||
* second rook, its detonation does not re-trigger. Keeping the
|
* second rook, its detonation does not re-trigger. Keeping the
|
||||||
* mechanic finite.
|
* 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 { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { ChessEngine } from "../engine.js";
|
import type { ChessEngine } from "../engine.js";
|
||||||
|
|
@ -32,20 +29,6 @@ import type { Square } from "../schema.js";
|
||||||
|
|
||||||
const DETONATION_RADIUS = 2;
|
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 {
|
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
||||||
const facts = session.allFacts();
|
const facts = session.allFacts();
|
||||||
for (const f of facts) {
|
for (const f of facts) {
|
||||||
|
|
@ -60,8 +43,8 @@ PRESET_REGISTRY.register({
|
||||||
id: "explosive-rook",
|
id: "explosive-rook",
|
||||||
name: "Detonating Rook",
|
name: "Detonating Rook",
|
||||||
description:
|
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.",
|
"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: ["piece-hp"],
|
incompatibleWith: [],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
||||||
|
|
@ -77,11 +60,13 @@ PRESET_REGISTRY.register({
|
||||||
const targetFile = fileOf(targetPos);
|
const targetFile = fileOf(targetPos);
|
||||||
const targetRank = rankOf(targetPos);
|
const targetRank = rankOf(targetPos);
|
||||||
|
|
||||||
// Collect detonation victims (orthogonal neighbours within radius).
|
// Collect detonation victims (orthogonal neighbours within radius)
|
||||||
// We include the target itself — it gets removed first. We skip
|
// BEFORE dealing any damage — we snapshot the Position-facts
|
||||||
// the attacker so the rook survives.
|
// because dealDamage may retract pieces mid-loop, which would
|
||||||
const victims = new Set<EntityId>();
|
// otherwise mess up iteration order. The attacker is always
|
||||||
victims.add(target);
|
// excluded. The target (centre of blast) is included.
|
||||||
|
const victims: EntityId[] = [];
|
||||||
|
victims.push(target);
|
||||||
|
|
||||||
for (let d = 1; d <= DETONATION_RADIUS; d++) {
|
for (let d = 1; d <= DETONATION_RADIUS; d++) {
|
||||||
for (const [df, dr] of [
|
for (const [df, dr] of [
|
||||||
|
|
@ -94,18 +79,33 @@ PRESET_REGISTRY.register({
|
||||||
const id = pieceAtSquare(session, sq);
|
const id = pieceAtSquare(session, sq);
|
||||||
if (id === null) continue;
|
if (id === null) continue;
|
||||||
if (id === attacker) 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.
|
// Damage the target first — we care specifically about whether
|
||||||
for (const v of victims) retractEntity(session, v);
|
// 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
|
// Advance the rook only if the target died — matches HP's general
|
||||||
// has HasMoved set. Default engine path is consumed, so we apply
|
// "non-lethal capture = attacker stays" semantics. Without HP,
|
||||||
// these mutations ourselves.
|
// targetDied is always true so the rook always advances (standard
|
||||||
session.insert(attacker, "Position", targetPos);
|
// detonating-rook behaviour). With HP, a target that absorbed the
|
||||||
session.insert(attacker, "HasMoved", true);
|
// hit keeps the rook off the square.
|
||||||
|
if (targetDied) {
|
||||||
|
session.insert(attacker, "Position", targetPos);
|
||||||
|
session.insert(attacker, "HasMoved", true);
|
||||||
|
}
|
||||||
|
|
||||||
return { consume: true };
|
return { consume: true };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -396,3 +396,100 @@ describe("queen-splits", () => {
|
||||||
expect(typeOf(engine, onD5)).toBe("pawn");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,57 @@
|
||||||
/**
|
/**
|
||||||
* Preset: `piece-hp` (Hit Points, RULES.md rule #13)
|
* Preset: `piece-hp` (Hit Points, RULES.md rule #13)
|
||||||
*
|
*
|
||||||
* Every piece starts with 2 HP. A capture deals 1 HP damage; the
|
* Every piece starts with 2 HP. A damage event deals 1 HP; the target
|
||||||
* target only dies (is removed from the board) when its HP reaches 0.
|
* only dies (is retracted from the board) when its HP hits 0. The
|
||||||
* While the target still has HP, the capturing piece does NOT move —
|
* engine's capture-path already routes captures through `dealDamage`,
|
||||||
* the capture attempt becomes a "poke": turn consumed, target damaged,
|
* so "capturing a piece" and "taking poison/explosion damage" all
|
||||||
* attacker stays put. This is the canonical variant semantics from
|
* flow through the same hook — piece-hp is the sole arbiter of
|
||||||
* the v0 design notes.
|
* damage-to-death for the whole game.
|
||||||
*
|
*
|
||||||
* How it integrates
|
* How it integrates
|
||||||
* ─────────────────
|
* ─────────────────
|
||||||
* - onActivate: assert `Hp = 2` on every existing entity on the board.
|
* - onActivate: assert `Hp = 2` on every existing entity on the
|
||||||
* Idempotent: only assigns to entities that don't already have an Hp
|
* board. Idempotent: only assigns to entities that don't already
|
||||||
* fact, so replaying activate on an already-HP-loaded session (e.g.
|
* have an Hp fact, so replaying activate on an already-HP-loaded
|
||||||
* after loading server state that included Hp) doesn't reset everyone.
|
* session (e.g. after loading server state that included Hp)
|
||||||
|
* doesn't reset everyone.
|
||||||
*
|
*
|
||||||
* - onDeactivate: retract Hp from every entity. Symmetric cleanup so
|
* - onDeactivate: retract Hp from every entity. Symmetric cleanup so
|
||||||
* toggling the preset off mid-game returns the board to the standard
|
* toggling the preset off mid-game returns the board to standard
|
||||||
* "captures are lethal" behaviour without leaving stale attributes.
|
* "damage kills" behaviour without leaving stale attributes.
|
||||||
*
|
*
|
||||||
* - onBeforeCapture: decrement the target's Hp. If the new Hp is still
|
* - onDamage: decrement the target's Hp by `amount`. If the new Hp
|
||||||
* positive, consume the capture (engine skips the default retract +
|
* is still positive, return `{ consume: true, died: false }` — the
|
||||||
* attacker-move path). If Hp reaches 0, return without consuming so
|
* damage pipeline reports "target survived" so the engine knows
|
||||||
* the engine falls through to `applyCapture` and the piece is removed
|
* not to advance the attacker (capture = poke). If Hp would go to
|
||||||
* normally.
|
* 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
|
* We DO consume the lethal case rather than falling through to the
|
||||||
* — AoE instant removal vs. single-target damage).
|
* 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 { 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";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
|
|
||||||
/** Starting HP for every piece. Future work: make this per-type so
|
/** Starting HP for every piece. Future work: make this per-type so
|
||||||
|
|
@ -50,12 +72,16 @@ function iteratePieceIds(session: Session): EntityId[] {
|
||||||
return [...ids];
|
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({
|
PRESET_REGISTRY.register({
|
||||||
id: "piece-hp",
|
id: "piece-hp",
|
||||||
name: "Hit Points",
|
name: "Hit Points",
|
||||||
description:
|
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.",
|
"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: ["explosive-rook"],
|
incompatibleWith: [],
|
||||||
requires: [],
|
requires: [],
|
||||||
|
|
||||||
onActivate(engine: ChessEngine) {
|
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;
|
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")
|
const current = session.contains(target, "Hp")
|
||||||
? (session.get(target, "Hp") as number)
|
? (session.get(target, "Hp") as number)
|
||||||
: DEFAULT_HP;
|
: DEFAULT_HP;
|
||||||
const next = current - 1;
|
const next = current - amount;
|
||||||
|
|
||||||
if (next > 0) {
|
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);
|
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
|
// Lethal. Retract all piece attrs ourselves so the engine doesn't
|
||||||
// retracted (including its Hp fact, because PIECE_ATTRS includes
|
// need a second retract pass. Returning died:true tells upstream
|
||||||
// "Hp"). No return value needed; undefined === don't consume.
|
// callers (capture path) that the attacker should advance.
|
||||||
return;
|
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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,11 @@ import type { EntityId } from "@paratype/rete";
|
||||||
* the UI layer to render visual poison cues on the same squares. */
|
* the UI layer to render visual poison cues on the same squares. */
|
||||||
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
|
export const POISONED_SQUARES: ReadonlySet<number> = 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({
|
PRESET_REGISTRY.register({
|
||||||
id: "poisoned-squares",
|
id: "poisoned-squares",
|
||||||
name: "Poisoned Centre",
|
name: "Poisoned Centre",
|
||||||
description:
|
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: [],
|
incompatibleWith: [],
|
||||||
requires: ["piece-hp"],
|
requires: ["piece-hp"],
|
||||||
|
|
||||||
|
|
@ -50,29 +38,22 @@ PRESET_REGISTRY.register({
|
||||||
const facts = session.allFacts();
|
const facts = session.allFacts();
|
||||||
|
|
||||||
// Collect pieces standing on poisoned squares. We snapshot the
|
// Collect pieces standing on poisoned squares. We snapshot the
|
||||||
// list BEFORE mutating anything so HP decrements don't interact
|
// list BEFORE dealing any damage so retractions mid-loop don't
|
||||||
// with iteration semantics.
|
// disturb iteration.
|
||||||
const toPoison: Array<{ id: EntityId; hp: number }> = [];
|
const victims: EntityId[] = [];
|
||||||
for (const f of facts) {
|
for (const f of facts) {
|
||||||
if (f.attr !== "Position") continue;
|
if (f.attr !== "Position") continue;
|
||||||
if (!POISONED_SQUARES.has(f.value as number)) continue;
|
if (!POISONED_SQUARES.has(f.value as number)) continue;
|
||||||
if ((f.id as number) <= 0) continue; // game entity
|
if ((f.id as number) <= 0) continue; // game entity
|
||||||
if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
|
victims.push(f.id);
|
||||||
const hp = session.get(f.id, "Hp") as number;
|
|
||||||
toPoison.push({ id: f.id, hp });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { id, hp } of toPoison) {
|
// Route through the damage pipeline. piece-hp's onDamage absorbs
|
||||||
const next = hp - 1;
|
// 1 HP; when Hp hits 0 the piece is retracted. We tag the kind
|
||||||
if (next > 0) {
|
// as "poison" so a future "poison-resistant" preset could short-
|
||||||
session.insert(id, "Hp", next);
|
// circuit this without changing poisoned-squares' own code.
|
||||||
} else {
|
for (const id of victims) {
|
||||||
// HP hit 0 — piece dies. Retract all attributes mirroring
|
engine.dealDamage(id, 1, { kind: "poison" });
|
||||||
// the normal capture path.
|
|
||||||
for (const attr of PIECE_ATTRS) {
|
|
||||||
if (session.contains(id, attr)) session.retract(id, attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,17 @@ describe("Incompatibility declarations", () => {
|
||||||
expect(b.incompatibleWith).toContain("capture-to-win");
|
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 a = PRESET_REGISTRY.getAll().find(p => p.id === "piece-hp")!;
|
||||||
const b = PRESET_REGISTRY.getAll().find(p => p.id === "explosive-rook")!;
|
const b = PRESET_REGISTRY.getAll().find(p => p.id === "explosive-rook")!;
|
||||||
expect(a.incompatibleWith).toContain("explosive-rook");
|
expect(a.incompatibleWith).not.toContain("explosive-rook");
|
||||||
expect(b.incompatibleWith).toContain("piece-hp");
|
expect(b.incompatibleWith).not.toContain("piece-hp");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,25 @@ PRESET_REGISTRY.register({
|
||||||
const targetPos = session.get(target, "Position") as Square | undefined;
|
const targetPos = session.get(target, "Position") as Square | undefined;
|
||||||
if (targetPos === undefined) return;
|
if (targetPos === undefined) return;
|
||||||
|
|
||||||
// Retract target (normal capture) and queen (she's fissioning).
|
// Damage the target through the pipeline. Without HP, this kills
|
||||||
retractEntity(session, target);
|
// instantly (engine default). With HP, it decrements — and we
|
||||||
retractEntity(session, attacker);
|
// 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);
|
spawnPiece(session, "rook", attackerColor, targetPos);
|
||||||
|
|
||||||
// Find the first empty adjacent square clockwise from N and spawn
|
// Find the first empty adjacent square clockwise from N and spawn
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,46 @@ export interface CaptureHookResult {
|
||||||
readonly consume?: boolean;
|
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 {
|
export interface PresetDef {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
|
@ -83,6 +123,36 @@ export interface PresetDef {
|
||||||
target: EntityId,
|
target: EntityId,
|
||||||
) => CaptureHookResult | void;
|
) => 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
|
* Fires after every successful `applyMove`, after turn advancement
|
||||||
* and tickAfterMove but before checkGameResult. `moverColor` is the
|
* and tickAfterMove but before checkGameResult. `moverColor` is the
|
||||||
|
|
@ -114,6 +184,27 @@ export interface PresetDef {
|
||||||
* redefine "when is the game over".
|
* redefine "when is the game over".
|
||||||
*/
|
*/
|
||||||
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import type { PieceColor } from "../schema.js";
|
import type { PieceColor } from "../schema.js";
|
||||||
|
|
||||||
/** All known chess fact attributes for a piece entity. */
|
/** All known chess fact attributes for a piece entity.
|
||||||
const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
|
* 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.
|
* Apply a capture: retract all known facts for the captured piece.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { isInCheck } from '../rules/check';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
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';
|
import * as audio from '../audio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -51,7 +52,7 @@ interface GameViewProps {
|
||||||
export function GameView({ engineState }: GameViewProps) {
|
export function GameView({ engineState }: GameViewProps) {
|
||||||
const localChessState = useChessEngine();
|
const localChessState = useChessEngine();
|
||||||
const state = engineState || localChessState;
|
const state = engineState || localChessState;
|
||||||
return <GameLayout state={state} myColor={null} />;
|
return <GameLayout state={state} myColor={null} roomCode={null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiplayerGameViewProps {
|
interface MultiplayerGameViewProps {
|
||||||
|
|
@ -76,11 +77,18 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
||||||
// authoritative position.
|
// authoritative position.
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<div className="text-neutral-500 font-medium">Waiting for opponent…</div>
|
<div className="text-neutral-500 font-medium">Waiting for opponent…</div>
|
||||||
<div className="mt-2 text-sm text-neutral-400" data-testid="mp-room-code">
|
<div className="text-sm text-neutral-400" data-testid="mp-room-code">
|
||||||
Room: {code}
|
Room: {code}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Prominent share affordance — this is the screen the creator
|
||||||
|
sees before anyone joins, so it MUST make copying the invite
|
||||||
|
link obvious. */}
|
||||||
|
<RoomShareBadge code={code} />
|
||||||
|
<p className="text-xs text-neutral-400 max-w-xs text-center">
|
||||||
|
Share the room code or this page's URL with your opponent.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +103,7 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
||||||
{state.error}
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GameLayout state={state} myColor={state.myColor} />
|
<GameLayout state={state} myColor={state.myColor} roomCode={code} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -109,9 +117,13 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
||||||
function GameLayout({
|
function GameLayout({
|
||||||
state,
|
state,
|
||||||
myColor,
|
myColor,
|
||||||
|
roomCode,
|
||||||
}: {
|
}: {
|
||||||
state: GameEngineState;
|
state: GameEngineState;
|
||||||
myColor: Color | null;
|
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 {
|
const {
|
||||||
engine,
|
engine,
|
||||||
|
|
@ -241,6 +253,7 @@ function GameLayout({
|
||||||
You are {myColor}
|
You are {myColor}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -384,3 +397,61 @@ function GameLayout({
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="room-share-badge"
|
||||||
|
data-room-code={code}
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={`Copy link to room ${code}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 px-2 py-1 bg-white border border-neutral-200 rounded hover:bg-neutral-50 hover:border-neutral-300 transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-300"
|
||||||
|
>
|
||||||
|
<span className="text-neutral-400 text-xs uppercase tracking-wide">
|
||||||
|
Room
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono font-semibold tracking-wider text-neutral-900"
|
||||||
|
style={{ fontFeatureSettings: '"ss01", "cv11"' }}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
{copied ? (
|
||||||
|
<Check size={14} className="text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} className="text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,89 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { pieceAssets } from '../assets/pieces';
|
import { pieceAssets } from '../assets/pieces';
|
||||||
import { ChessEngine } from '../engine';
|
import { ChessEngine } from '../engine';
|
||||||
import { clearAutoSave } from '../persist/autosave';
|
import { clearAutoSave } from '../persist/autosave';
|
||||||
|
import { oneShotRoomRequest } from '../net/lobby-request';
|
||||||
const WS_URL =
|
|
||||||
(import.meta as { env?: Record<string, string> }).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<string, unknown>,
|
|
||||||
): 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);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
/** Optional — when provided, create/join/solo flows reset the local
|
/** Optional — when provided, create/join/solo flows reset the local
|
||||||
|
|
@ -99,7 +17,6 @@ interface LobbyProps {
|
||||||
|
|
||||||
export function Lobby({ chessState }: LobbyProps = {}) {
|
export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
const [codeInput, setCodeInput] = useState('');
|
const [codeInput, setCodeInput] = useState('');
|
||||||
const [roomCode, setRoomCode] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -122,7 +39,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
sessionStorage.setItem('room-token', token);
|
sessionStorage.setItem('room-token', token);
|
||||||
sessionStorage.setItem('player-color', color);
|
sessionStorage.setItem('player-color', color);
|
||||||
resetToFreshGame();
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Could not connect to server');
|
setError(err instanceof Error ? err.message : 'Could not connect to server');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -144,7 +64,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
sessionStorage.setItem('room-token', result.token);
|
sessionStorage.setItem('room-token', result.token);
|
||||||
sessionStorage.setItem('player-color', result.color);
|
sessionStorage.setItem('player-color', result.color);
|
||||||
resetToFreshGame();
|
resetToFreshGame();
|
||||||
navigate('/game');
|
navigate(`/game/${result.code}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Invalid room code');
|
setError(err instanceof Error ? err.message : 'Invalid room code');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -192,41 +112,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
Host Game
|
Host Game
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
|
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
|
||||||
{!roomCode ? (
|
<button
|
||||||
<button
|
data-action="create-room"
|
||||||
data-action="create-room"
|
onClick={handleCreate}
|
||||||
onClick={handleCreate}
|
disabled={loading}
|
||||||
disabled={loading}
|
className="w-full bg-neutral-900 text-white font-bold py-3 px-4 rounded-lg hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md active:scale-[0.98]"
|
||||||
className="w-full bg-neutral-900 text-white font-bold py-3 px-4 rounded-lg hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md active:scale-[0.98]"
|
>
|
||||||
>
|
{loading && !codeInput ? 'Creating...' : 'Create Room'}
|
||||||
{loading && !codeInput ? 'Creating...' : 'Create Room'}
|
</button>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-sm font-medium text-neutral-500 block mb-2">Room Code</span>
|
|
||||||
<span
|
|
||||||
data-testid="room-code"
|
|
||||||
className="text-3xl font-mono font-black tracking-[0.2em] text-neutral-900 bg-white border border-neutral-200 shadow-sm rounded-lg px-6 py-3 inline-block"
|
|
||||||
style={{ fontFeatureSettings: '"ss01", "cv11"' }}
|
|
||||||
>
|
|
||||||
{roomCode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Re-reset just before navigation in case the user
|
|
||||||
// spent a long time on this page and autosave was
|
|
||||||
// somehow re-written in the meantime.
|
|
||||||
resetToFreshGame();
|
|
||||||
navigate('/game');
|
|
||||||
}}
|
|
||||||
className="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-all shadow-md active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
Enter Room
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { PRESET_REGISTRY } from '../presets/index.js';
|
||||||
import type { PresetScope } from '../presets/active-set.js';
|
import type { PresetScope } from '../presets/active-set.js';
|
||||||
import type { PresetActivation } from '../net/types.js';
|
import type { PresetActivation } from '../net/types.js';
|
||||||
|
|
@ -72,6 +72,37 @@ export function RulesDrawer({
|
||||||
return map;
|
return map;
|
||||||
}, [activations]);
|
}, [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<string, string>();
|
||||||
|
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 <body> 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 toggle = (id: string, name: string) => {
|
||||||
const currently = activeById.get(id);
|
const currently = activeById.get(id);
|
||||||
const next: PresetActivation[] = currently
|
const next: PresetActivation[] = currently
|
||||||
|
|
@ -155,7 +186,9 @@ export function RulesDrawer({
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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)}
|
onClick={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
<motion.aside
|
<motion.aside
|
||||||
|
|
@ -186,10 +219,83 @@ export function RulesDrawer({
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
<div className="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4">
|
||||||
{presets.map((preset) => {
|
{presets.map((preset) => {
|
||||||
const active = activeById.get(preset.id);
|
const active = activeById.get(preset.id);
|
||||||
const isOn = active !== undefined;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
|
|
@ -208,16 +314,89 @@ export function RulesDrawer({
|
||||||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||||
{preset.description}
|
{preset.description}
|
||||||
</p>
|
</p>
|
||||||
|
{(preset.requires.length > 0 ||
|
||||||
|
preset.incompatibleWith.length > 0) && (
|
||||||
|
<div
|
||||||
|
className="mt-2 flex flex-wrap gap-1.5"
|
||||||
|
data-role="deps"
|
||||||
|
>
|
||||||
|
{preset.requires.map((depId) => {
|
||||||
|
const depActive = activeById.has(depId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`req-${depId}`}
|
||||||
|
data-role="dep-requires"
|
||||||
|
data-dep-id={depId}
|
||||||
|
data-dep-satisfied={depActive}
|
||||||
|
title={
|
||||||
|
depActive
|
||||||
|
? `Requires ${nameById(depId)} (active)`
|
||||||
|
: `Requires ${nameById(depId)} — enable this first`
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||||
|
depActive
|
||||||
|
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||||
|
: 'bg-amber-50 border-amber-200 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{depActive ? '✓' : '!'}
|
||||||
|
</span>
|
||||||
|
Requires {nameById(depId)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{preset.incompatibleWith.map((otherId) => {
|
||||||
|
const otherActive = activeById.has(otherId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`inc-${otherId}`}
|
||||||
|
data-role="dep-conflicts"
|
||||||
|
data-dep-id={otherId}
|
||||||
|
data-dep-conflicting={otherActive}
|
||||||
|
title={
|
||||||
|
otherActive
|
||||||
|
? `Conflicts with ${nameById(otherId)} (active) — enabling this will clash`
|
||||||
|
: `Conflicts with ${nameById(otherId)}`
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||||
|
otherActive
|
||||||
|
? 'bg-red-50 border-red-200 text-red-700'
|
||||||
|
: 'bg-neutral-100 border-neutral-200 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⊗</span>
|
||||||
|
Conflicts with {nameById(otherId)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-role="toggle"
|
data-role="toggle"
|
||||||
|
data-disabled-reason={
|
||||||
|
blockReason === null ? undefined : blockReason.kind
|
||||||
|
}
|
||||||
onClick={() => toggle(preset.id, preset.name)}
|
onClick={() => toggle(preset.id, preset.name)}
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
disabled={toggleDisabled}
|
||||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
title={blockMessage}
|
||||||
|
aria-label={
|
||||||
|
blockMessage !== undefined
|
||||||
|
? `${preset.name} — ${blockMessage}`
|
||||||
|
: `${preset.name} toggle`
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||||
|
toggleDisabled
|
||||||
|
? 'cursor-not-allowed opacity-40 bg-neutral-200'
|
||||||
|
: `cursor-pointer ${
|
||||||
|
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||||
|
}`
|
||||||
}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isOn}
|
aria-checked={isOn}
|
||||||
|
aria-disabled={toggleDisabled}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
layout
|
layout
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue