Preset refactors for HP

This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 18:59:11 -06:00
commit 7c4c942938
No known key found for this signature in database
19 changed files with 1497 additions and 288 deletions

View file

@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=full-flow.spec.d.ts.map

View file

@ -0,0 +1 @@
{"version":3,"file":"full-flow.spec.d.ts","sourceRoot":"","sources":["full-flow.spec.ts"],"names":[],"mappings":""}

View 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

View file

@ -0,0 +1 @@
{"version":3,"file":"full-flow.spec.js","sourceRoot":"","sources":["full-flow.spec.ts"],"names":[],"mappings":"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"}

View file

@ -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} />
}

View file

@ -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);
}

View 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);
};
});
}

View 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);
});
});

View file

@ -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 };
},

View file

@ -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");
});
});

View file

@ -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 };
}

View file

@ -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" });
}
},
});

View file

@ -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");
});
});

View file

@ -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

View file

@ -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;
}
/**

View file

@ -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.

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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