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 { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom'
|
||||
import { Lobby } from '../ui/Lobby'
|
||||
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
||||
import { RulesView } from '../ui/RulesView'
|
||||
|
|
@ -8,9 +8,10 @@ import { ImportExport } from '../ui/ImportExport'
|
|||
import { useChessEngine } from '../hooks/useChessEngine'
|
||||
import { ChessEngine } from '../engine'
|
||||
import { loadAutoSave } from '../persist/autosave.js'
|
||||
import { oneShotRoomRequest } from '../net/lobby-request'
|
||||
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Toaster } from 'sonner'
|
||||
import { Toaster, toast } from 'sonner'
|
||||
|
||||
export function App() {
|
||||
const chessState = useChessEngine()
|
||||
|
|
@ -34,6 +35,7 @@ export function App() {
|
|||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageTransition><Lobby 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="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||
</Routes>
|
||||
|
|
@ -43,29 +45,102 @@ export function App() {
|
|||
}
|
||||
|
||||
/**
|
||||
* /game route dispatcher. Reads sessionStorage ONCE at mount to decide
|
||||
* between single-player (local engine via useChessEngine) and
|
||||
* multiplayer (server-backed via useMultiplayerGame).
|
||||
* /game and /game/:code route dispatcher.
|
||||
*
|
||||
* We snapshot into state so that a subsequent sessionStorage mutation
|
||||
* (e.g. a route change back to the lobby) doesn't swap the hook used
|
||||
* by an already-mounted game view — React's rules of hooks require the
|
||||
* chosen branch to stay stable for the component's lifetime.
|
||||
* Four ways to land here:
|
||||
* 1. /game with creds in sessionStorage — existing mid-game reload.
|
||||
* We redirect to the canonical /game/:code URL so the address bar
|
||||
* reflects the room, but otherwise render MultiplayerGameView.
|
||||
* 2. /game with no creds — solo local game.
|
||||
* 3. /game/:code with MATCHING creds in sessionStorage — standard
|
||||
* path after Create / Join clicked on the Lobby. Mount MP view.
|
||||
* 4. /game/:code with NO (or mismatched) creds — shared link flow.
|
||||
* We auto-fire a room.join for :code, store the returned creds,
|
||||
* and mount the MP view. On failure we redirect to / with a toast.
|
||||
*
|
||||
* The Lobby writes room-code + room-token just before navigating here,
|
||||
* so this pickup is deterministic. If either is missing we render the
|
||||
* local mode.
|
||||
* We snapshot mpCreds into state so that a subsequent sessionStorage
|
||||
* mutation (e.g. route change back to the lobby) doesn't swap the hook
|
||||
* used by an already-mounted game view — React's rules of hooks require
|
||||
* the chosen branch to stay stable for the component's lifetime.
|
||||
*/
|
||||
function GameRoute({
|
||||
chessState,
|
||||
}: {
|
||||
chessState: ReturnType<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 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) {
|
||||
return <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
getEnPassantMoves,
|
||||
setEnPassantTarget,
|
||||
clearEnPassantTarget,
|
||||
applyEnPassantCapture,
|
||||
} from "./rules/enpassant.js";
|
||||
import {
|
||||
isPromotionMove,
|
||||
|
|
@ -45,7 +44,8 @@ import {
|
|||
recordPosition,
|
||||
isThreefoldRepetition,
|
||||
} from "./rules/draws.js";
|
||||
import { applyCapture } from "./rules/capture.js";
|
||||
import { PIECE_ATTRS } from "./rules/capture.js";
|
||||
import type { DamageContext } from "./presets/registry.js";
|
||||
import type { LegalMove } from "./rules/types.js";
|
||||
import {
|
||||
ActivePresetSet,
|
||||
|
|
@ -166,6 +166,61 @@ export class ChessEngine {
|
|||
return consumed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deal `amount` damage to `target`, running it through the preset
|
||||
* damage pipeline before applying the default kill-on-damage rule.
|
||||
*
|
||||
* This is the primitive that lets presets compose cleanly:
|
||||
* - Without any damage-interceptor preset (standard chess,
|
||||
* explosive-rook alone, etc.), damage ≥ 1 retracts the target.
|
||||
* - With `piece-hp` active, the hook decrements the target's Hp
|
||||
* fact and only retracts when Hp would go ≤ 0.
|
||||
*
|
||||
* Callers should use this instead of retracting piece facts directly
|
||||
* whenever they want to compose with HP/armor/shield-like presets.
|
||||
* See `explosive-rook` (AoE calls dealDamage on every victim) and
|
||||
* `poisoned-squares` (tick calls dealDamage on every occupant of a
|
||||
* poisoned square) for the canonical call sites.
|
||||
*
|
||||
* The first preset whose `onDamage` returns `consume: true` wins —
|
||||
* further presets are not consulted for that event. This is
|
||||
* deliberate: HP is the ONE authority on damage→death; two damage
|
||||
* interceptors would race and produce zombie state (the bug this
|
||||
* pipeline was designed to eliminate).
|
||||
*
|
||||
* Returns `{ died: true }` if the target was retracted (either by a
|
||||
* hook that killed or by the default path), `{ died: false }` if it
|
||||
* survived (HP absorbed it, or amount was 0).
|
||||
*/
|
||||
dealDamage(
|
||||
target: EntityId,
|
||||
amount: number,
|
||||
ctx: DamageContext,
|
||||
): { died: boolean } {
|
||||
if (amount <= 0) return { died: false };
|
||||
|
||||
// Consult damage interceptors in active-preset list order. First
|
||||
// consumer wins. Scope is not considered here because damage is a
|
||||
// board-level event that can strike any piece regardless of whose
|
||||
// turn it is (e.g. explosive rook hitting friendly pieces).
|
||||
for (const entry of this.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(entry.id);
|
||||
const result = def?.onDamage?.(this, target, amount, ctx);
|
||||
if (result?.consume === true) {
|
||||
return { died: result.died === true };
|
||||
}
|
||||
}
|
||||
|
||||
// Default: any damage is lethal. Retract all piece attributes so
|
||||
// downstream queries see the piece as truly gone.
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (this.session.contains(target, attr)) {
|
||||
this.session.retract(target, attr);
|
||||
}
|
||||
}
|
||||
return { died: true };
|
||||
}
|
||||
|
||||
getCurrentTurn(): PieceColor {
|
||||
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
|
||||
}
|
||||
|
|
@ -244,8 +299,18 @@ export class ChessEngine {
|
|||
moves.push(...pieceMoves);
|
||||
}
|
||||
|
||||
// Filter self-check moves
|
||||
return filterSelfCheckMoves(this.session, moves, color);
|
||||
// Self-check filter: skip it iff any active preset (for this color)
|
||||
// explicitly opts out via shouldFilterSelfCheck returning false.
|
||||
// This is how `piece-hp` allows the king to stay on an attacked
|
||||
// square — a "hit" costs HP rather than losing the game.
|
||||
let applyFilter = true;
|
||||
for (const preset of this.activePresets.getForColor(color)) {
|
||||
if (preset.shouldFilterSelfCheck?.(this, color) === false) {
|
||||
applyFilter = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return applyFilter ? filterSelfCheckMoves(this.session, moves, color) : moves;
|
||||
}
|
||||
|
||||
applyMove(move: LegalMove, promoteTo: PieceType = "queen"): GameResult {
|
||||
|
|
@ -266,41 +331,73 @@ export class ChessEngine {
|
|||
|
||||
if (isEnPassant) {
|
||||
// En passant captures the pawn on the SKIPPED square, not on
|
||||
// `move.to`. Dispatch the preset hook against that off-square
|
||||
// target so e.g. piece-hp can decrement HP on the captured pawn.
|
||||
// `move.to`. We still fire `onBeforeCapture` first so presets
|
||||
// like queen-splits / explosive-rook get a chance to transform
|
||||
// the capture wholesale. If none consume, we route the target
|
||||
// through `dealDamage` so the HP pipeline gets to absorb it.
|
||||
const capturedSquare =
|
||||
color === "white" ? ((move.to - 8) as number) : ((move.to + 8) as number);
|
||||
const capturedId = this.getPieceAt(capturedSquare);
|
||||
const consumed =
|
||||
capturedId !== null &&
|
||||
this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||
if (consumed) {
|
||||
// Preset handled the capture (e.g. damaged the pawn). The
|
||||
// attacker does NOT move — consuming the move as a "poke"
|
||||
// ends the turn without a positional change.
|
||||
} else {
|
||||
applyEnPassantCapture(this.session, move, color);
|
||||
let consumed = false;
|
||||
let targetDied = true; // default: no captured pawn found (degenerate), treat as "died"
|
||||
if (capturedId !== null) {
|
||||
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||
if (!consumed) {
|
||||
const result = this.dealDamage(capturedId, 1, {
|
||||
kind: "capture",
|
||||
attacker: move.pieceId,
|
||||
});
|
||||
targetDied = result.died;
|
||||
}
|
||||
}
|
||||
// Attacker advances diagonally unless a preset consumed the
|
||||
// capture (it owns positioning) or the captured pawn survived
|
||||
// HP damage (poke — attacker stays).
|
||||
if (!consumed && targetDied) {
|
||||
this.session.insert(move.pieceId, "Position", move.to);
|
||||
this.session.insert(move.pieceId, "HasMoved", true);
|
||||
}
|
||||
} else if (isCastling) {
|
||||
applyCastlingMove(this.session, move as CastlingMove);
|
||||
} else {
|
||||
// Normal move: handle capture, then update position. The preset
|
||||
// capture hook is our chance to short-circuit the default
|
||||
// retract-and-move behaviour (used by piece-hp for non-lethal
|
||||
// damage). If any preset consumes the capture we skip BOTH the
|
||||
// retraction AND the attacker's move: the preset turned the
|
||||
// capture into a "poke" that just ends the turn.
|
||||
// Normal move. Two intercept layers:
|
||||
// 1. onBeforeCapture — lets a preset replace the capture
|
||||
// mechanic entirely (queen-splits fission, explosive-rook
|
||||
// AoE, capture-to-win winner-recording). If consumed the
|
||||
// engine does nothing else for this capture.
|
||||
// 2. dealDamage — runs after, only if nothing consumed. This
|
||||
// is the pipeline HP hooks, and is what makes "rook
|
||||
// captures pawn" decrement the pawn's Hp by 1 rather than
|
||||
// instantly killing. On default (no interceptor) the target
|
||||
// is retracted and `died=true` is returned.
|
||||
//
|
||||
// The attacker advances onto the target square iff the target
|
||||
// ACTUALLY DIED. Non-lethal captures leave the attacker in
|
||||
// place; turn still advances so the move counts as a "poke".
|
||||
let consumed = false;
|
||||
let captureResolved = true; // default: no capture happened, treat as "died"
|
||||
if (move.isCapture) {
|
||||
const capturedId = this.getPieceAt(move.to);
|
||||
if (capturedId !== null) {
|
||||
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||
if (!consumed) {
|
||||
applyCapture(this.session, capturedId);
|
||||
const { died } = this.dealDamage(capturedId, 1, {
|
||||
kind: "capture",
|
||||
attacker: move.pieceId,
|
||||
});
|
||||
captureResolved = died;
|
||||
} else {
|
||||
// A consuming preset owns ALL post-capture behaviour,
|
||||
// including whether the attacker moved. We don't run the
|
||||
// standard advance below.
|
||||
captureResolved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!consumed) {
|
||||
// Advance attacker unless:
|
||||
// - a preset consumed the capture (it handled positioning), OR
|
||||
// - the capture was non-lethal (attacker stays, target stands)
|
||||
if (!consumed && captureResolved) {
|
||||
this.session.insert(move.pieceId, "Position", move.to);
|
||||
this.session.insert(move.pieceId, "HasMoved", true);
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
*
|
||||
* When a rook captures, it detonates on the target square, removing
|
||||
* every piece (friendly or enemy, excluding the capturing rook itself)
|
||||
* within Chebyshev distance <= 2 on the same rank or file. Diagonal
|
||||
* neighbours are NOT affected — detonation propagates along orthogonal
|
||||
* rays only, matching the rook's own movement.
|
||||
* When a rook captures, it detonates on the target square, dealing 1
|
||||
* damage to every piece (friendly or enemy, excluding the capturing
|
||||
* rook itself) within Chebyshev distance <= 2 on the same rank or
|
||||
* file. Diagonal neighbours are NOT affected — detonation propagates
|
||||
* along orthogonal rays only, matching the rook's own movement.
|
||||
*
|
||||
* Implementation via `onBeforeCapture`:
|
||||
* - If the attacker isn't a rook, do nothing (default capture runs).
|
||||
* - Otherwise consume the hook, then manually:
|
||||
* 1. Remove the target piece.
|
||||
* 2. Remove every piece within distance 2 on the target's rank or
|
||||
* file (friendly or enemy; rook itself excluded).
|
||||
* 3. Move the rook onto the target square (so the explosion
|
||||
* visually "lands" there).
|
||||
* Composes with HP
|
||||
* ────────────────
|
||||
* Damage is applied via `engine.dealDamage()` rather than direct
|
||||
* retraction. Without HP, 1 damage kills instantly (engine default).
|
||||
* With HP active, every victim takes 1 HP and survives if it had >1
|
||||
* HP — so combining Hit Points with Detonating Rook produces a
|
||||
* natural "chip damage blast" rather than being impossible. This is
|
||||
* the whole point of the damage pipeline: presets don't know about HP,
|
||||
* and HP doesn't know about them.
|
||||
*
|
||||
* Explosion does NOT chain — if a captured piece happened to be a
|
||||
* second rook, its detonation does not re-trigger. Keeping the
|
||||
* mechanic finite.
|
||||
*
|
||||
* Incompatible with `piece-hp` — HP's "capture deals 1 damage" and
|
||||
* explosive-rook's "capture wipes AoE" are contradictory capture
|
||||
* resolutions.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
|
|
@ -32,20 +29,6 @@ import type { Square } from "../schema.js";
|
|||
|
||||
const DETONATION_RADIUS = 2;
|
||||
|
||||
const PIECE_ATTRS = [
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Hp",
|
||||
] as const;
|
||||
|
||||
function retractEntity(session: Session, id: EntityId): void {
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(id, attr)) session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
|
||||
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
||||
const facts = session.allFacts();
|
||||
for (const f of facts) {
|
||||
|
|
@ -60,8 +43,8 @@ PRESET_REGISTRY.register({
|
|||
id: "explosive-rook",
|
||||
name: "Detonating Rook",
|
||||
description:
|
||||
"When a Rook captures, it detonates: every piece within 2 squares on the same rank or file is removed (friend and foe alike). Diagonals are spared.",
|
||||
incompatibleWith: ["piece-hp"],
|
||||
"When a Rook captures, it detonates: every piece within 2 squares on the same rank or file takes 1 damage (friend and foe alike). Diagonals are spared. Composes with Hit Points — pieces with HP>1 survive the blast.",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
|
||||
|
|
@ -77,11 +60,13 @@ PRESET_REGISTRY.register({
|
|||
const targetFile = fileOf(targetPos);
|
||||
const targetRank = rankOf(targetPos);
|
||||
|
||||
// Collect detonation victims (orthogonal neighbours within radius).
|
||||
// We include the target itself — it gets removed first. We skip
|
||||
// the attacker so the rook survives.
|
||||
const victims = new Set<EntityId>();
|
||||
victims.add(target);
|
||||
// Collect detonation victims (orthogonal neighbours within radius)
|
||||
// BEFORE dealing any damage — we snapshot the Position-facts
|
||||
// because dealDamage may retract pieces mid-loop, which would
|
||||
// otherwise mess up iteration order. The attacker is always
|
||||
// excluded. The target (centre of blast) is included.
|
||||
const victims: EntityId[] = [];
|
||||
victims.push(target);
|
||||
|
||||
for (let d = 1; d <= DETONATION_RADIUS; d++) {
|
||||
for (const [df, dr] of [
|
||||
|
|
@ -94,18 +79,33 @@ PRESET_REGISTRY.register({
|
|||
const id = pieceAtSquare(session, sq);
|
||||
if (id === null) continue;
|
||||
if (id === attacker) continue;
|
||||
victims.add(id);
|
||||
if (id === target) continue; // already added
|
||||
victims.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Retract every victim's facts — their pieces are gone.
|
||||
for (const v of victims) retractEntity(session, v);
|
||||
// Damage the target first — we care specifically about whether
|
||||
// IT died, to decide if the rook advances onto its square. The
|
||||
// other victims just get sprayed with the blast; we don't track
|
||||
// their survival here.
|
||||
const { died: targetDied } = engine.dealDamage(target, 1, {
|
||||
kind: "explosion",
|
||||
attacker,
|
||||
});
|
||||
for (const v of victims) {
|
||||
if (v === target) continue; // already handled
|
||||
engine.dealDamage(v, 1, { kind: "explosion", attacker });
|
||||
}
|
||||
|
||||
// The rook still "captures" by moving onto the target square and
|
||||
// has HasMoved set. Default engine path is consumed, so we apply
|
||||
// these mutations ourselves.
|
||||
session.insert(attacker, "Position", targetPos);
|
||||
session.insert(attacker, "HasMoved", true);
|
||||
// Advance the rook only if the target died — matches HP's general
|
||||
// "non-lethal capture = attacker stays" semantics. Without HP,
|
||||
// targetDied is always true so the rook always advances (standard
|
||||
// detonating-rook behaviour). With HP, a target that absorbed the
|
||||
// hit keeps the rook off the square.
|
||||
if (targetDied) {
|
||||
session.insert(attacker, "Position", targetPos);
|
||||
session.insert(attacker, "HasMoved", true);
|
||||
}
|
||||
|
||||
return { consume: true };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -396,3 +396,100 @@ describe("queen-splits", () => {
|
|||
expect(typeOf(engine, onD5)).toBe("pawn");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// piece-hp + self-check override
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("piece-hp: self-check filter override", () => {
|
||||
it("with HP active, moves that leave the king in check are legal", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Build a check scenario: 1. e4 e5 2. Nf3 Nc6 3. Bb5 — now Bb5 pins
|
||||
// Nc6 against black's king. Standard chess would forbid Nc6xBb5's
|
||||
// move-partners (not applicable here — we need to show moves NOT
|
||||
// addressing the check are allowed). Easier: set up an explicit
|
||||
// check with an unrelated legal black move available.
|
||||
//
|
||||
// 1. e4 ... 2. Qh5 ... 3. Bc4 ... 4. Qxf7+ puts black in check.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("d1"), algebraicToSquare("h5"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("a6"), algebraicToSquare("a5"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("f1"), algebraicToSquare("c4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("a5"), algebraicToSquare("a4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("h5"), algebraicToSquare("f7"))!);
|
||||
// Black is now "in check" from Qf7 but the queen has only dealt 1 HP
|
||||
// damage (king still has 1 HP left). Standard chess would force
|
||||
// black to address check — with HP, black should be free to make
|
||||
// any legal-by-geometry move, including one that ignores the check
|
||||
// and doesn't block/escape/capture the attacker.
|
||||
|
||||
const a4Square = algebraicToSquare("a4");
|
||||
const a3Square = algebraicToSquare("a3");
|
||||
const blackMoves = engine.getAllLegalMoves();
|
||||
// Find the a4-pawn's moves — normally blocked by self-check filter
|
||||
// because pushing the pawn doesn't address the check on e8.
|
||||
const a4Push = blackMoves.find(
|
||||
(m) => m.from === a4Square && m.to === a3Square,
|
||||
);
|
||||
expect(a4Push).toBeDefined();
|
||||
});
|
||||
|
||||
it("the game is not declared checkmate just because the king is attacked", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Standard back-rank mate setup doesn't apply since defenders can
|
||||
// just tank the hit. Fool's Mate almost: 1. f3 e5 2. g4 Qh4#.
|
||||
// Normally this is checkmate; with HP it should NOT be.
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("f2"), algebraicToSquare("f3"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("g2"), algebraicToSquare("g4"))!);
|
||||
engine.applyMove(engine.findMove(algebraicToSquare("d8"), algebraicToSquare("h4"))!);
|
||||
expect(engine.checkGameResult()).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("capturing the enemy king ends the game (white-wins / black-wins)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Shortcut: directly reduce black's king to 1 HP and capture it.
|
||||
const blackKing = pieceAt(engine, "e8")!;
|
||||
engine.session.insert(blackKing, "Hp", 1);
|
||||
// Put a white queen next to black's king on d8 so it can capture.
|
||||
// Clear d8 first.
|
||||
for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
|
||||
const occupant = engine.session
|
||||
.allFacts()
|
||||
.find((f) => f.attr === "Position" && f.value === algebraicToSquare("d8"));
|
||||
if (!occupant) break;
|
||||
if (engine.session.contains(occupant.id, attr)) {
|
||||
engine.session.retract(occupant.id, attr);
|
||||
}
|
||||
}
|
||||
// White queen on d1 → move to d8 via teleport by setting Position.
|
||||
const whiteQueen = pieceAt(engine, "d1")!;
|
||||
engine.session.insert(whiteQueen, "Position", algebraicToSquare("d8"));
|
||||
// Now it's still white's turn (we didn't call applyMove). Find the
|
||||
// Qxe8 capture move.
|
||||
const capture = engine
|
||||
.getAllLegalMoves()
|
||||
.find(
|
||||
(m) =>
|
||||
m.pieceId === whiteQueen &&
|
||||
m.to === algebraicToSquare("e8") &&
|
||||
m.isCapture,
|
||||
);
|
||||
expect(capture).toBeDefined();
|
||||
const result = engine.applyMove(capture!);
|
||||
expect(result).toBe("white-wins");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,57 @@
|
|||
/**
|
||||
* Preset: `piece-hp` (Hit Points, RULES.md rule #13)
|
||||
*
|
||||
* Every piece starts with 2 HP. A capture deals 1 HP damage; the
|
||||
* target only dies (is removed from the board) when its HP reaches 0.
|
||||
* While the target still has HP, the capturing piece does NOT move —
|
||||
* the capture attempt becomes a "poke": turn consumed, target damaged,
|
||||
* attacker stays put. This is the canonical variant semantics from
|
||||
* the v0 design notes.
|
||||
* Every piece starts with 2 HP. A damage event deals 1 HP; the target
|
||||
* only dies (is retracted from the board) when its HP hits 0. The
|
||||
* engine's capture-path already routes captures through `dealDamage`,
|
||||
* so "capturing a piece" and "taking poison/explosion damage" all
|
||||
* flow through the same hook — piece-hp is the sole arbiter of
|
||||
* damage-to-death for the whole game.
|
||||
*
|
||||
* How it integrates
|
||||
* ─────────────────
|
||||
* - onActivate: assert `Hp = 2` on every existing entity on the board.
|
||||
* Idempotent: only assigns to entities that don't already have an Hp
|
||||
* fact, so replaying activate on an already-HP-loaded session (e.g.
|
||||
* after loading server state that included Hp) doesn't reset everyone.
|
||||
* - onActivate: assert `Hp = 2` on every existing entity on the
|
||||
* board. Idempotent: only assigns to entities that don't already
|
||||
* have an Hp fact, so replaying activate on an already-HP-loaded
|
||||
* session (e.g. after loading server state that included Hp)
|
||||
* doesn't reset everyone.
|
||||
*
|
||||
* - onDeactivate: retract Hp from every entity. Symmetric cleanup so
|
||||
* toggling the preset off mid-game returns the board to the standard
|
||||
* "captures are lethal" behaviour without leaving stale attributes.
|
||||
* toggling the preset off mid-game returns the board to standard
|
||||
* "damage kills" behaviour without leaving stale attributes.
|
||||
*
|
||||
* - onBeforeCapture: decrement the target's Hp. If the new Hp is still
|
||||
* positive, consume the capture (engine skips the default retract +
|
||||
* attacker-move path). If Hp reaches 0, return without consuming so
|
||||
* the engine falls through to `applyCapture` and the piece is removed
|
||||
* normally.
|
||||
* - onDamage: decrement the target's Hp by `amount`. If the new Hp
|
||||
* is still positive, return `{ consume: true, died: false }` — the
|
||||
* damage pipeline reports "target survived" so the engine knows
|
||||
* not to advance the attacker (capture = poke). If Hp would go to
|
||||
* 0 or below, retract the target ourselves and return `{ consume:
|
||||
* true, died: true }` so the engine advances the attacker as
|
||||
* normal.
|
||||
*
|
||||
* Incompatibilities: explosive-rook (different capture resolution model
|
||||
* — AoE instant removal vs. single-target damage).
|
||||
* We DO consume the lethal case rather than falling through to the
|
||||
* engine's default retract, because if we didn't, the default path
|
||||
* would also retract and we'd end up with two retract passes (safe
|
||||
* but wasteful) AND `died` semantics would be fragile — other
|
||||
* presets that read died to cascade triggers (fission, etc) would
|
||||
* see `died=true` coming from the engine's fallback rather than
|
||||
* from HP's explicit decision.
|
||||
*
|
||||
* - shouldFilterSelfCheck: opt out of the engine's "you can't leave
|
||||
* your king in check" move filter. With HP active, being in check
|
||||
* just means your next hit costs HP — moves that ignore the check
|
||||
* are legal (see README for the full variant ruleset).
|
||||
*
|
||||
* - onCheckGameResult: declare terminal game result based on whether
|
||||
* a king has been retracted (won → by the color whose king remains).
|
||||
* Suppresses default checkmate/stalemate because those aren't
|
||||
* meaningful when kings can stand in check.
|
||||
*
|
||||
* Compatibility: now plays nicely with every other damage-dealing
|
||||
* preset (explosive-rook, queen-splits, poisoned-squares, ...) because
|
||||
* they all use the damage pipeline. No more hardcoded incompatibilities.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { ChessEngine, GameResult } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
|
||||
/** Starting HP for every piece. Future work: make this per-type so
|
||||
|
|
@ -50,12 +72,16 @@ function iteratePieceIds(session: Session): EntityId[] {
|
|||
return [...ids];
|
||||
}
|
||||
|
||||
/** Piece attributes cleared when HP hits 0 (piece dies). Kept in
|
||||
* lockstep with the canonical list in rules/capture.ts. */
|
||||
const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "piece-hp",
|
||||
name: "Hit Points",
|
||||
description:
|
||||
"Every piece has 2 HP. Captures deal 1 damage instead of removing the target. A piece only dies when its HP hits 0; otherwise the capturing piece stays put and turn passes.",
|
||||
incompatibleWith: ["explosive-rook"],
|
||||
"Every piece has 2 HP. Damage (capture, explosion, poison…) deals 1 HP instead of immediately killing. A piece only dies when its HP hits 0; until then the attacker doesn't advance — the hit becomes a 'poke'.",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
|
||||
onActivate(engine: ChessEngine) {
|
||||
|
|
@ -78,26 +104,121 @@ PRESET_REGISTRY.register({
|
|||
}
|
||||
},
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, _attacker: EntityId, target: EntityId) {
|
||||
/**
|
||||
* The damage pipeline's single consumer. Every damage event — whether
|
||||
* from a capture, an AoE explosion, a poisoned square, or something
|
||||
* not yet invented — comes through here when HP is active.
|
||||
*
|
||||
* `amount` is respected: a hit for 2 from a hypothetical "heavy
|
||||
* attack" preset would kill a 2-HP piece in one blow. This makes
|
||||
* piece-hp composable with damage sources that vary in strength
|
||||
* without needing per-source special cases.
|
||||
*/
|
||||
onDamage(engine: ChessEngine, target: EntityId, amount: number, _ctx) {
|
||||
// The engine short-circuits amount<=0 before we ever get here,
|
||||
// but be defensive: if somehow invoked with 0, treat it as a
|
||||
// no-op survival.
|
||||
if (amount <= 0) return { consume: true, died: false };
|
||||
|
||||
const session = engine.session;
|
||||
// If for any reason the target lacks an Hp fact (shouldn't happen
|
||||
// once onActivate ran, but defensive), install the default so we
|
||||
// still behave predictably.
|
||||
const current = session.contains(target, "Hp")
|
||||
? (session.get(target, "Hp") as number)
|
||||
: DEFAULT_HP;
|
||||
const next = current - 1;
|
||||
const next = current - amount;
|
||||
|
||||
if (next > 0) {
|
||||
// Non-lethal: update HP, consume the capture so the engine
|
||||
// skips its default retract-and-move path.
|
||||
session.insert(target, "Hp", next);
|
||||
return { consume: true };
|
||||
return { consume: true, died: false };
|
||||
}
|
||||
// Lethal: let the engine fall through to its default capture.
|
||||
// The attacker moves onto the target's square and the target is
|
||||
// retracted (including its Hp fact, because PIECE_ATTRS includes
|
||||
// "Hp"). No return value needed; undefined === don't consume.
|
||||
return;
|
||||
|
||||
// Lethal. Retract all piece attrs ourselves so the engine doesn't
|
||||
// need a second retract pass. Returning died:true tells upstream
|
||||
// callers (capture path) that the attacker should advance.
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(target, attr)) session.retract(target, attr);
|
||||
}
|
||||
return { consume: true, died: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Opt out of the engine's default self-check filter. With HP active,
|
||||
* being "in check" just means your next hit costs you 1 HP — it
|
||||
* doesn't end the game, and it shouldn't force you to respond. This
|
||||
* lets players move freely even while their king is under attack,
|
||||
* and lets them deliberately move their king into an attacked
|
||||
* square (paying HP when the attacker follows through).
|
||||
*/
|
||||
shouldFilterSelfCheck(): boolean {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Terminal-state override: whoever lost their king LOSES.
|
||||
*
|
||||
* Without HP, the king is never literally captured — the self-check
|
||||
* filter prevents any move that would let it be. With HP and no
|
||||
* self-check filter, a king CAN be captured (its HP hits 0 via
|
||||
* `onBeforeCapture` and the default capture path retracts it). The
|
||||
* moment that happens, the opposite color wins; standard
|
||||
* checkmate/stalemate logic becomes meaningless because a king that
|
||||
* can be "in check" forever isn't a terminal condition any more.
|
||||
*
|
||||
* Returning `undefined` here means "no preset-level verdict yet —
|
||||
* run the default logic". Once a king is gone we return the winner
|
||||
* directly.
|
||||
*/
|
||||
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
|
||||
const session = engine.session;
|
||||
const colors = kingsPresent(session);
|
||||
if (!colors.white && colors.black) return "black-wins";
|
||||
if (colors.white && !colors.black) return "white-wins";
|
||||
if (!colors.white && !colors.black) {
|
||||
// Both kings dead in the same half-move (e.g. an AoE preset
|
||||
// that kills through the attacker too). In practice this is
|
||||
// unreachable through normal captures because capturing is a
|
||||
// single action per move. If it does happen, whoever's turn it
|
||||
// is just finished — so the previous mover delivered the
|
||||
// double-kill. Without tracking that here we award it to the
|
||||
// opponent of the current side to move (the side that just
|
||||
// played).
|
||||
const turn = engine.getCurrentTurn();
|
||||
return turn === "white" ? "black-wins" : "white-wins";
|
||||
}
|
||||
// Both kings on the board → game continues, default rules take over.
|
||||
// But we also need to SUPPRESS the default checkmate/stalemate
|
||||
// verdict (a king "in check" with HP left isn't checkmated). We do
|
||||
// that by explicitly returning "ongoing" so the engine short-
|
||||
// circuits before the default isCheckmate/isStalemate check.
|
||||
//
|
||||
// The default draw rules (50-move, 3fold, insufficient material)
|
||||
// are less meaningful when HP is active — insufficient material
|
||||
// especially, since a rook with HP can still lose to a king — but
|
||||
// leaving them on is harmless since they require a lot of moves
|
||||
// to trigger. We let the engine run them after we return
|
||||
// "ongoing".
|
||||
return "ongoing";
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Check which colors still have a king on the board. A king "on the
|
||||
* board" = there's an entity with PieceType=king AND a Position fact
|
||||
* (the capture path retracts Position alongside everything else).
|
||||
*/
|
||||
function kingsPresent(session: Session): { white: boolean; black: boolean } {
|
||||
const facts = session.allFacts();
|
||||
let white = false;
|
||||
let black = false;
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== "king") continue;
|
||||
// Confirm the king is still ALIVE by checking for a Position fact.
|
||||
const alive = facts.some(
|
||||
(p) => p.id === f.id && p.attr === "Position",
|
||||
);
|
||||
if (!alive) continue;
|
||||
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||
if (colorFact?.value === "white") white = true;
|
||||
else if (colorFact?.value === "black") black = true;
|
||||
}
|
||||
return { white, black };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,23 +25,11 @@ import type { EntityId } from "@paratype/rete";
|
|||
* the UI layer to render visual poison cues on the same squares. */
|
||||
export const POISONED_SQUARES: ReadonlySet<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({
|
||||
id: "poisoned-squares",
|
||||
name: "Poisoned Centre",
|
||||
description:
|
||||
"The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one loses 1 HP per move. Requires Hit Points.",
|
||||
"The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one takes 1 poison damage. Requires Hit Points.",
|
||||
incompatibleWith: [],
|
||||
requires: ["piece-hp"],
|
||||
|
||||
|
|
@ -50,29 +38,22 @@ PRESET_REGISTRY.register({
|
|||
const facts = session.allFacts();
|
||||
|
||||
// Collect pieces standing on poisoned squares. We snapshot the
|
||||
// list BEFORE mutating anything so HP decrements don't interact
|
||||
// with iteration semantics.
|
||||
const toPoison: Array<{ id: EntityId; hp: number }> = [];
|
||||
// list BEFORE dealing any damage so retractions mid-loop don't
|
||||
// disturb iteration.
|
||||
const victims: EntityId[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if (!POISONED_SQUARES.has(f.value as number)) continue;
|
||||
if ((f.id as number) <= 0) continue; // game entity
|
||||
if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
|
||||
const hp = session.get(f.id, "Hp") as number;
|
||||
toPoison.push({ id: f.id, hp });
|
||||
victims.push(f.id);
|
||||
}
|
||||
|
||||
for (const { id, hp } of toPoison) {
|
||||
const next = hp - 1;
|
||||
if (next > 0) {
|
||||
session.insert(id, "Hp", next);
|
||||
} else {
|
||||
// HP hit 0 — piece dies. Retract all attributes mirroring
|
||||
// the normal capture path.
|
||||
for (const attr of PIECE_ATTRS) {
|
||||
if (session.contains(id, attr)) session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
// Route through the damage pipeline. piece-hp's onDamage absorbs
|
||||
// 1 HP; when Hp hits 0 the piece is retracted. We tag the kind
|
||||
// as "poison" so a future "poison-resistant" preset could short-
|
||||
// circuit this without changing poisoned-squares' own code.
|
||||
for (const id of victims) {
|
||||
engine.dealDamage(id, 1, { kind: "poison" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,11 +80,17 @@ describe("Incompatibility declarations", () => {
|
|||
expect(b.incompatibleWith).toContain("capture-to-win");
|
||||
});
|
||||
|
||||
it("piece-hp ↔ explosive-rook are incompatible", () => {
|
||||
it("piece-hp ↔ explosive-rook now compose via the damage pipeline", () => {
|
||||
// Historical: these were hard-incompatible because each preset
|
||||
// raced to own capture resolution. With the damage pipeline
|
||||
// (registry.DamageHookResult / engine.dealDamage), explosive-rook
|
||||
// calls dealDamage for each blast victim and piece-hp is the sole
|
||||
// damage-to-death arbiter, so they compose naturally: blasts deal
|
||||
// 1 HP to every piece in range.
|
||||
const a = PRESET_REGISTRY.getAll().find(p => p.id === "piece-hp")!;
|
||||
const b = PRESET_REGISTRY.getAll().find(p => p.id === "explosive-rook")!;
|
||||
expect(a.incompatibleWith).toContain("explosive-rook");
|
||||
expect(b.incompatibleWith).toContain("piece-hp");
|
||||
expect(a.incompatibleWith).not.toContain("explosive-rook");
|
||||
expect(b.incompatibleWith).not.toContain("piece-hp");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -120,11 +120,25 @@ PRESET_REGISTRY.register({
|
|||
const targetPos = session.get(target, "Position") as Square | undefined;
|
||||
if (targetPos === undefined) return;
|
||||
|
||||
// Retract target (normal capture) and queen (she's fissioning).
|
||||
retractEntity(session, target);
|
||||
retractEntity(session, attacker);
|
||||
// Damage the target through the pipeline. Without HP, this kills
|
||||
// instantly (engine default). With HP, it decrements — and we
|
||||
// only fission if the target actually died. A queen that "pokes"
|
||||
// a 2-HP piece doesn't split; she stays a queen, the target loses
|
||||
// 1 HP, and the turn passes — consistent with HP's general
|
||||
// non-lethal-capture rule.
|
||||
const { died: targetDied } = engine.dealDamage(target, 1, {
|
||||
kind: "capture",
|
||||
attacker,
|
||||
});
|
||||
if (!targetDied) {
|
||||
// Poke: queen stays, no fission. We consumed the capture so the
|
||||
// engine shouldn't run its default retract+advance on attacker.
|
||||
return { consume: true };
|
||||
}
|
||||
|
||||
// Spawn the rook on the capture square.
|
||||
// Target died (standard path). Retract the queen (she fissions)
|
||||
// and spawn a rook on the capture square + a bishop nearby.
|
||||
retractEntity(session, attacker);
|
||||
spawnPiece(session, "rook", attackerColor, targetPos);
|
||||
|
||||
// Find the first empty adjacent square clockwise from N and spawn
|
||||
|
|
|
|||
|
|
@ -55,6 +55,46 @@ export interface CaptureHookResult {
|
|||
readonly consume?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes WHERE a unit of damage is coming from. Presets that want
|
||||
* to react only to certain damage sources (e.g. a hypothetical
|
||||
* "poison-resistant" preset that ignores damage of kind "poison") read
|
||||
* `ctx.kind`. The engine sets `attacker` on direct hits; damage with no
|
||||
* attacking entity (poisoned-squares tick, fall damage, etc) leaves it
|
||||
* undefined.
|
||||
*
|
||||
* `kind` is typed as a string union with known values plus free-form
|
||||
* overflow so future presets can introduce new damage kinds without
|
||||
* touching this file. Each preset is responsible for documenting the
|
||||
* kinds it emits.
|
||||
*/
|
||||
export interface DamageContext {
|
||||
readonly kind: "capture" | "explosion" | "poison" | (string & {});
|
||||
readonly attacker?: EntityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return shape for onDamage. Semantics:
|
||||
* - `consume: true` means "I'm the authority on this damage event;
|
||||
* the engine should NOT run its default (damage >= 1 kills) path."
|
||||
* The hook must also indicate whether the target died via `died`,
|
||||
* because follow-up actions (fission, cascading triggers) depend on
|
||||
* knowing.
|
||||
* - `consume: false` / undefined / no hook means "I don't want to
|
||||
* handle this"; the engine falls through to the next preset and
|
||||
* ultimately the default kill-on-damage path.
|
||||
*
|
||||
* Only ONE preset is expected to consume a given damage event. If more
|
||||
* than one tries, the first to return `consume: true` wins (iteration
|
||||
* order follows `activePresets.list()`). This is the conscious
|
||||
* simplification that eliminates the zombie-state bug we had when two
|
||||
* presets' onBeforeCapture hooks both set consume: true.
|
||||
*/
|
||||
export interface DamageHookResult {
|
||||
readonly consume: boolean;
|
||||
readonly died?: boolean;
|
||||
}
|
||||
|
||||
export interface PresetDef {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
|
@ -83,6 +123,36 @@ export interface PresetDef {
|
|||
target: EntityId,
|
||||
) => CaptureHookResult | void;
|
||||
|
||||
/**
|
||||
* Intercept a damage event before the engine applies its default
|
||||
* "damage >= 1 kills" behaviour. This is the pipeline that lets HP
|
||||
* exist as a self-contained preset: `piece-hp` implements onDamage
|
||||
* to decrement an Hp attribute and only falls through to death when
|
||||
* Hp would go <= 0. Other presets (explosive-rook, poisoned-squares,
|
||||
* etc) call `engine.dealDamage()` instead of retracting pieces
|
||||
* directly, which means they compose with HP for free — without ever
|
||||
* importing or referencing HP-specific code.
|
||||
*
|
||||
* Contract (see DamageHookResult docs):
|
||||
* - return `{ consume: true, died }` to handle the damage event;
|
||||
* `died` tells the engine whether the target entity was retracted
|
||||
* and upstream callers (e.g. default capture path) whether the
|
||||
* attacker should advance onto the square.
|
||||
* - return `undefined` / `{ consume: false }` to let other presets
|
||||
* or the engine default handle it.
|
||||
*
|
||||
* ORDERING: the engine iterates active presets in list order and
|
||||
* stops at the first consumer. Consequently only ONE preset is
|
||||
* expected to implement onDamage meaningfully at a time — `piece-hp`
|
||||
* is the canonical implementer.
|
||||
*/
|
||||
readonly onDamage?: (
|
||||
engine: ChessEngine,
|
||||
target: EntityId,
|
||||
amount: number,
|
||||
ctx: DamageContext,
|
||||
) => DamageHookResult | void;
|
||||
|
||||
/**
|
||||
* Fires after every successful `applyMove`, after turn advancement
|
||||
* and tickAfterMove but before checkGameResult. `moverColor` is the
|
||||
|
|
@ -114,6 +184,27 @@ export interface PresetDef {
|
|||
* redefine "when is the game over".
|
||||
*/
|
||||
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
|
||||
|
||||
/**
|
||||
* Opt out of the engine's default self-check filter for moves of
|
||||
* `color`. Return `false` to allow moves that leave the king attacked
|
||||
* (e.g. `piece-hp`, where a "hit" only costs HP rather than ending
|
||||
* the game). Return `true` or `undefined` to leave the filter in
|
||||
* place.
|
||||
*
|
||||
* The engine polls every preset whose scope covers `color` and
|
||||
* skips the self-check filter iff at least one returns `false`. A
|
||||
* preset that doesn't care leaves this hook undefined.
|
||||
*
|
||||
* Note: presets that opt out of the self-check filter almost always
|
||||
* also want to override `onCheckGameResult` — otherwise default
|
||||
* checkmate/stalemate detection still fires against a king that can
|
||||
* legally survive being attacked.
|
||||
*/
|
||||
readonly shouldFilterSelfCheck?: (
|
||||
engine: ChessEngine,
|
||||
color: "white" | "black",
|
||||
) => boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceColor } from "../schema.js";
|
||||
|
||||
/** All known chess fact attributes for a piece entity. */
|
||||
const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
|
||||
/** All known chess fact attributes for a piece entity.
|
||||
* Exported so presets and the damage pipeline can stay in lockstep
|
||||
* without each duplicating the list. */
|
||||
export const PIECE_ATTRS = ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const;
|
||||
|
||||
/**
|
||||
* Apply a capture: retract all known facts for the captured piece.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import { isInCheck } from '../rules/check';
|
|||
import { useEffect, useState } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import * as audio from '../audio';
|
||||
|
||||
/**
|
||||
|
|
@ -51,7 +52,7 @@ interface GameViewProps {
|
|||
export function GameView({ engineState }: GameViewProps) {
|
||||
const localChessState = useChessEngine();
|
||||
const state = engineState || localChessState;
|
||||
return <GameLayout state={state} myColor={null} />;
|
||||
return <GameLayout state={state} myColor={null} roomCode={null} />;
|
||||
}
|
||||
|
||||
interface MultiplayerGameViewProps {
|
||||
|
|
@ -76,11 +77,18 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
|||
// authoritative position.
|
||||
if (state.loading) {
|
||||
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="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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -95,7 +103,7 @@ export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
|||
{state.error}
|
||||
</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({
|
||||
state,
|
||||
myColor,
|
||||
roomCode,
|
||||
}: {
|
||||
state: GameEngineState;
|
||||
myColor: Color | null;
|
||||
/** Shareable room code — non-null iff we're in multiplayer mode.
|
||||
* Drives the RoomShareBadge next to the "You are white" pill. */
|
||||
roomCode: string | null;
|
||||
}) {
|
||||
const {
|
||||
engine,
|
||||
|
|
@ -241,6 +253,7 @@ function GameLayout({
|
|||
You are {myColor}
|
||||
</span>
|
||||
)}
|
||||
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -384,3 +397,61 @@ function GameLayout({
|
|||
</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 { ChessEngine } from '../engine';
|
||||
import { clearAutoSave } from '../persist/autosave';
|
||||
|
||||
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);
|
||||
};
|
||||
});
|
||||
}
|
||||
import { oneShotRoomRequest } from '../net/lobby-request';
|
||||
|
||||
interface LobbyProps {
|
||||
/** Optional — when provided, create/join/solo flows reset the local
|
||||
|
|
@ -99,7 +17,6 @@ interface LobbyProps {
|
|||
|
||||
export function Lobby({ chessState }: LobbyProps = {}) {
|
||||
const [codeInput, setCodeInput] = useState('');
|
||||
const [roomCode, setRoomCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -122,7 +39,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
sessionStorage.setItem('room-token', token);
|
||||
sessionStorage.setItem('player-color', color);
|
||||
resetToFreshGame();
|
||||
setRoomCode(code);
|
||||
// Navigate straight to the canonical shareable URL — no
|
||||
// intermediate "Room created" card. The GameView itself renders
|
||||
// the room code + copy-link pill so the creator can share it.
|
||||
navigate(`/game/${code}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not connect to server');
|
||||
} finally {
|
||||
|
|
@ -144,7 +64,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
sessionStorage.setItem('room-token', result.token);
|
||||
sessionStorage.setItem('player-color', result.color);
|
||||
resetToFreshGame();
|
||||
navigate('/game');
|
||||
navigate(`/game/${result.code}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid room code');
|
||||
} finally {
|
||||
|
|
@ -192,41 +112,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
Host Game
|
||||
</h2>
|
||||
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
|
||||
{!roomCode ? (
|
||||
<button
|
||||
data-action="create-room"
|
||||
onClick={handleCreate}
|
||||
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]"
|
||||
>
|
||||
{loading && !codeInput ? 'Creating...' : 'Create Room'}
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
data-action="create-room"
|
||||
onClick={handleCreate}
|
||||
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]"
|
||||
>
|
||||
{loading && !codeInput ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</div>
|
||||
</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 type { PresetScope } from '../presets/active-set.js';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
|
|
@ -72,6 +72,37 @@ export function RulesDrawer({
|
|||
return map;
|
||||
}, [activations]);
|
||||
|
||||
// id → human-readable preset name, used for rendering dependency chips.
|
||||
// Built once from the registry (presets are static); falls back to the
|
||||
// raw id if a preset references something not in the registry (future-
|
||||
// proofing against typos in `requires` / `incompatibleWith`).
|
||||
const nameById = useMemo(() => {
|
||||
const map = new Map<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 currently = activeById.get(id);
|
||||
const next: PresetActivation[] = currently
|
||||
|
|
@ -155,7 +186,9 @@ export function RulesDrawer({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40"
|
||||
// touch-none blocks mobile gesture scroll on the backdrop;
|
||||
// overscroll-contain belt-and-suspenders with the body lock.
|
||||
className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40 overscroll-contain touch-none"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<motion.aside
|
||||
|
|
@ -186,10 +219,83 @@ export function RulesDrawer({
|
|||
</button>
|
||||
</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) => {
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
|
||||
// Compute whether this preset CAN be toggled right now
|
||||
// given the currently-active set. Pre-checking mirrors
|
||||
// the engine's own validation in
|
||||
// `ActivePresetSet.replaceAll` so we don't round-trip
|
||||
// to the server just to get an error back (which
|
||||
// would produce a misleading "Preset enabled" toast
|
||||
// followed by an error banner). Scope defaults to
|
||||
// 'both', so overlap is always true for this quick
|
||||
// check.
|
||||
//
|
||||
// Enable block: a currently-active preset conflicts,
|
||||
// OR a required preset isn't active.
|
||||
// Disable block: another active preset REQUIRES this
|
||||
// one. (Turning off piece-hp while
|
||||
// king-heals is on would fail.)
|
||||
//
|
||||
// Returns null when the toggle action would succeed,
|
||||
// otherwise a structured reason used to drive the
|
||||
// disabled toggle + tooltip.
|
||||
let blockReason:
|
||||
| null
|
||||
| { kind: 'conflicts'; withName: string }
|
||||
| { kind: 'missing'; needsName: string }
|
||||
| { kind: 'depended-on-by'; dependentName: string } = null;
|
||||
if (!isOn) {
|
||||
for (const otherId of preset.incompatibleWith) {
|
||||
if (activeById.has(otherId)) {
|
||||
blockReason = {
|
||||
kind: 'conflicts',
|
||||
withName: nameById(otherId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blockReason === null) {
|
||||
for (const depId of preset.requires) {
|
||||
if (!activeById.has(depId)) {
|
||||
blockReason = {
|
||||
kind: 'missing',
|
||||
needsName: nameById(depId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable path: check whether any OTHER active
|
||||
// preset requires this one. If so, disabling would
|
||||
// leave that preset with an unmet dependency and
|
||||
// the server / engine would reject the whole set.
|
||||
for (const other of presets) {
|
||||
if (!activeById.has(other.id)) continue;
|
||||
if (other.id === preset.id) continue;
|
||||
if (other.requires.includes(preset.id)) {
|
||||
blockReason = {
|
||||
kind: 'depended-on-by',
|
||||
dependentName: other.name,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggleDisabled = blockReason !== null;
|
||||
const blockMessage =
|
||||
blockReason === null
|
||||
? undefined
|
||||
: blockReason.kind === 'conflicts'
|
||||
? `Disable "${blockReason.withName}" first — it conflicts with this rule`
|
||||
: blockReason.kind === 'missing'
|
||||
? `Enable "${blockReason.needsName}" first — this rule requires it`
|
||||
: `Disable "${blockReason.dependentName}" first — it requires this rule`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
|
|
@ -208,16 +314,89 @@ export function RulesDrawer({
|
|||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
data-disabled-reason={
|
||||
blockReason === null ? undefined : blockReason.kind
|
||||
}
|
||||
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 ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
disabled={toggleDisabled}
|
||||
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"
|
||||
aria-checked={isOn}
|
||||
aria-disabled={toggleDisabled}
|
||||
>
|
||||
<motion.span
|
||||
layout
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue