feat(chess): piece-hp mechanic + extensible preset-hook infrastructure
Implements the piece-hp (Hit Points) preset end-to-end and, more
importantly, sets up the infrastructure for rules that need state,
capture interception, or custom UI. Adding a new "guns" rule, a
"poison-cloud" visual, or similar now requires zero changes to
engine.ts, Board.tsx, or protocol.ts.
Engine / preset registry
~~~~~~~~~~~~~~~~~~~~~~~~
PresetDef gains three new optional hooks:
- onActivate(engine) \u2014 fires once on active-set transition
(inactive \u2192 active). Idempotent by
convention so sync paths that re-apply
from server state don\`t stomp values.
- onDeactivate(engine) \u2014 symmetric cleanup. Also fires when a
turn-limited preset expires via
tickAfterMove.
- onBeforeCapture(engine, attacker, target)
Fires immediately before the engine\`s default capture path.
Returns `{ consume: true }` to short-circuit: engine skips
target retraction AND attacker move. Used by piece-hp for
non-lethal damage; future rules can override however they like.
New ChessEngine.setActivePresets(requests) is the single entry point
that diffs old vs new and fires lifecycle hooks in deterministic
order (deactivate-then-activate). replaceAll stays public for tests
that want to bypass hooks.
ActivePresetSet.tickAfterMove now returns the list of expired ids
so the engine can fire onDeactivate on them.
piece-hp preset
~~~~~~~~~~~~~~~
- onActivate seeds Hp=2 on every piece.
- onDeactivate retracts Hp from all pieces.
- onBeforeCapture decrements Hp; if > 0 consumes the capture (attacker
stays, target survives, turn advances). At 0, returns without
consuming so the engine\`s default retract-and-move fires normally.
All capture sites intercepted: regular captures (engine.ts:200) AND
en-passant captures (engine.ts:194). The check-simulation path in
check.ts does NOT fire the hook \u2014 it uses an isolated snapshot
session, so lifecycle side effects don\`t leak into legal-move
filtering.
UI overlay registry
~~~~~~~~~~~~~~~~~~~
New packages/chess/src/ui/preset-overlays.tsx: a module-level registry
mapping preset id \u2192 React component that renders above each piece.
Board.tsx loads the registry via side-effect import and renders any
registered overlays for every active preset.
New packages/chess/src/presets/piece-hp.ui.tsx registers HealthBarPips:
2 pip dots above each piece, filled = remaining HP, empty = lost HP.
Pip color contrasts piece color for readability on either square.
Adding a new visual rule now costs 3 files and zero engine changes:
presets/foo.ts (mechanic + lifecycle hooks)
presets/foo.ui.tsx (overlay component + registry call)
presets/ui-overlays-index.ts (single-line import)
Testing
~~~~~~~
New piece-hp.test.ts: 8 integration tests covering lifecycle (seed,
retract, idempotent re-apply, no-fire on scope-only change) and
capture resolution (non-lethal decrement, lethal retract at HP=0,
HP drain over multiple captures, deactivate mid-game leaves damage
but retracts Hp). Total 861 tests pass (+8), 3/3 E2E green.
This commit is contained in:
parent
af9973adfa
commit
f4e030b8e5
13 changed files with 757 additions and 22 deletions
|
|
@ -47,7 +47,11 @@ import {
|
|||
} from "./rules/draws.js";
|
||||
import { applyCapture } from "./rules/capture.js";
|
||||
import type { LegalMove } from "./rules/types.js";
|
||||
import { ActivePresetSet } from "./presets/active-set.js";
|
||||
import {
|
||||
ActivePresetSet,
|
||||
type ActivationRequest,
|
||||
} from "./presets/active-set.js";
|
||||
import { PRESET_REGISTRY } from "./presets/registry.js";
|
||||
// Importing from the barrel guarantees every preset module's
|
||||
// side-effect registration has run before the first engine is created.
|
||||
import "./presets/index.js";
|
||||
|
|
@ -93,6 +97,69 @@ export class ChessEngine {
|
|||
this.activePresets = activePresets ?? new ActivePresetSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active preset set and fire lifecycle hooks on transitions.
|
||||
*
|
||||
* This is the ONLY public entry point that routes through preset
|
||||
* `onActivate` / `onDeactivate` hooks — direct mutation of
|
||||
* `activePresets` (via `.replaceAll`) is still allowed for tests but
|
||||
* bypasses the hooks.
|
||||
*
|
||||
* Transition ordering, by design:
|
||||
* 1. Snapshot the old id set.
|
||||
* 2. Validate + apply the new set via `ActivePresetSet.replaceAll`.
|
||||
* If validation throws, no hooks fire and the old set is intact.
|
||||
* 3. Fire `onDeactivate` for ids in old-but-not-new.
|
||||
* 4. Fire `onActivate` for ids in new-but-not-old.
|
||||
*
|
||||
* Scope or turns-remaining changes on an id present in both sets do
|
||||
* NOT re-fire any hook — the preset is considered "continuously
|
||||
* active" across the transition.
|
||||
*
|
||||
* Deactivate-before-activate is deliberate: it lets a preset tear
|
||||
* down state cleanly before the incoming preset reads the board.
|
||||
* Ordering within each phase follows the input list's natural order.
|
||||
*/
|
||||
setActivePresets(requests: readonly ActivationRequest[]): void {
|
||||
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
||||
this.activePresets.replaceAll(requests);
|
||||
const newIds = new Set(requests.map((r) => r.id));
|
||||
|
||||
for (const id of oldIds) {
|
||||
if (newIds.has(id)) continue;
|
||||
const def = PRESET_REGISTRY.get(id);
|
||||
def?.onDeactivate?.(this);
|
||||
}
|
||||
for (const req of requests) {
|
||||
if (oldIds.has(req.id)) continue;
|
||||
const def = PRESET_REGISTRY.get(req.id);
|
||||
def?.onActivate?.(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a preset-intercepted capture. Dispatches `onBeforeCapture`
|
||||
* on every currently-active preset for the mover's color; if any
|
||||
* preset returns `{ consume: true }` the default capture is skipped
|
||||
* and we return `true`. Otherwise the caller should proceed with the
|
||||
* standard capture path.
|
||||
*
|
||||
* `color` is the color of the capturing piece (i.e. whose turn it is).
|
||||
*/
|
||||
private tryInterceptCapture(
|
||||
attacker: EntityId,
|
||||
target: EntityId,
|
||||
color: PieceColor,
|
||||
): boolean {
|
||||
let consumed = false;
|
||||
for (const preset of this.activePresets.getForColor(color)) {
|
||||
if (!preset.onBeforeCapture) continue;
|
||||
const result = preset.onBeforeCapture(this, attacker, target);
|
||||
if (result && result.consume === true) consumed = true;
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
getCurrentTurn(): PieceColor {
|
||||
return (this.session.get(GAME_ENTITY, "Turn") as PieceColor) ?? "white";
|
||||
}
|
||||
|
|
@ -192,19 +259,45 @@ export class ChessEngine {
|
|||
const isCastling = (move as CastlingMove).isCastling === true;
|
||||
|
||||
if (isEnPassant) {
|
||||
applyEnPassantCapture(this.session, move, color);
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
} else if (isCastling) {
|
||||
applyCastlingMove(this.session, move as CastlingMove);
|
||||
} else {
|
||||
// Normal move: handle capture, then update position
|
||||
// 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.
|
||||
let consumed = false;
|
||||
if (move.isCapture) {
|
||||
const capturedId = this.getPieceAt(move.to);
|
||||
if (capturedId !== null) {
|
||||
applyCapture(this.session, capturedId);
|
||||
consumed = this.tryInterceptCapture(move.pieceId, capturedId, color);
|
||||
if (!consumed) {
|
||||
applyCapture(this.session, capturedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.session.insert(move.pieceId, "Position", move.to);
|
||||
this.session.insert(move.pieceId, "HasMoved", true);
|
||||
if (!consumed) {
|
||||
this.session.insert(move.pieceId, "Position", move.to);
|
||||
this.session.insert(move.pieceId, "HasMoved", true);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle promotion (pawn reaching last rank)
|
||||
|
|
@ -242,8 +335,13 @@ export class ChessEngine {
|
|||
// Tick preset durations with the color that JUST moved. Player-local
|
||||
// turn counting: a `scope=white` preset with 3 turns remaining
|
||||
// ticks only when white plays; a `scope=both` ticks on every
|
||||
// half-move. Entries reaching 0 are removed.
|
||||
this.activePresets.tickAfterMove(color);
|
||||
// half-move. Entries reaching 0 are removed AND fire onDeactivate
|
||||
// so they can tear down any board state they installed.
|
||||
const expired = this.activePresets.tickAfterMove(color);
|
||||
for (const id of expired) {
|
||||
const def = PRESET_REGISTRY.get(id);
|
||||
def?.onDeactivate?.(this);
|
||||
}
|
||||
|
||||
return this.checkGameResult();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,10 @@ export function useChessEngine() {
|
|||
* the local UI updates immediately (no server round-trip).
|
||||
*/
|
||||
const setPresets = useCallback((activations: PresetActivation[]) => {
|
||||
engine.activePresets.replaceAll(activations);
|
||||
// Route through setActivePresets so presets' onActivate /
|
||||
// onDeactivate lifecycle hooks fire (piece-hp needs this to seed
|
||||
// and clean up Hp facts). Fall through to autosave + re-render.
|
||||
engine.setActivePresets(activations);
|
||||
saveAutoSave(engine.session.allFacts());
|
||||
setTick(t => t + 1);
|
||||
}, [engine]);
|
||||
|
|
|
|||
|
|
@ -191,8 +191,13 @@ export class ActivePresetSet {
|
|||
* This implements the "player-local turns" policy: a `scope=white`
|
||||
* preset ticks only after white moves; `scope=both` ticks on every
|
||||
* half-move.
|
||||
*
|
||||
* Returns the list of ids that expired during this tick so the engine
|
||||
* can fire the preset `onDeactivate` lifecycle hook against them. The
|
||||
* ActivePresetSet itself stays engine-unaware — the hook dispatch is
|
||||
* strictly a ChessEngine concern.
|
||||
*/
|
||||
tickAfterMove(moverColor: "white" | "black"): void {
|
||||
tickAfterMove(moverColor: "white" | "black"): string[] {
|
||||
const toRemove: string[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.scope !== "both" && entry.scope !== moverColor) continue;
|
||||
|
|
@ -205,6 +210,7 @@ export class ActivePresetSet {
|
|||
}
|
||||
}
|
||||
for (const id of toRemove) this.entries.delete(id);
|
||||
return toRemove;
|
||||
}
|
||||
|
||||
/** All active entries, in registration order. Used by UI + wire sync. */
|
||||
|
|
|
|||
251
packages/chess/src/presets/piece-hp.test.ts
Normal file
251
packages/chess/src/presets/piece-hp.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Integration tests for the `piece-hp` preset.
|
||||
*
|
||||
* Covers the lifecycle hooks (onActivate / onDeactivate) AND the
|
||||
* capture-interception hook (onBeforeCapture → consume). Tests use
|
||||
* `engine.setActivePresets(...)` so lifecycle hooks fire; using
|
||||
* `.activePresets.replaceAll` directly would bypass them.
|
||||
*/
|
||||
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";
|
||||
|
||||
/** Find the piece currently on a given algebraic square; null if empty. */
|
||||
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;
|
||||
}
|
||||
|
||||
describe("piece-hp preset — lifecycle hooks", () => {
|
||||
it("onActivate seeds Hp=2 on every piece", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// 32 pieces on the starting board; every one should have Hp=2.
|
||||
const pieces = engine.session.allFacts().filter(
|
||||
(f) => f.attr === "PieceType",
|
||||
);
|
||||
expect(pieces.length).toBe(32);
|
||||
for (const p of pieces) {
|
||||
expect(engine.session.contains(p.id, "Hp")).toBe(true);
|
||||
expect(engine.session.get(p.id, "Hp")).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("onDeactivate retracts Hp from every piece", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
engine.setActivePresets([]);
|
||||
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr === "PieceType") {
|
||||
expect(engine.session.contains(f.id, "Hp")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("onActivate is idempotent — doesn't stomp existing HP values", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Manually damage a piece to Hp=1.
|
||||
const e2Pawn = pieceAt(engine, "e2")!;
|
||||
engine.session.insert(e2Pawn, "Hp", 1);
|
||||
|
||||
// Re-running setActivePresets with the same set should be a no-op
|
||||
// for Hp (our transition logic says "id present in both → no hook").
|
||||
// But even if someone calls onActivate directly via a future code
|
||||
// path, the idempotence guard ensures Hp=1 stays.
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
expect(engine.session.get(e2Pawn, "Hp")).toBe(1);
|
||||
});
|
||||
|
||||
it("scope or duration change on an already-active preset does NOT re-fire onActivate", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
const e2Pawn = pieceAt(engine, "e2")!;
|
||||
engine.session.insert(e2Pawn, "Hp", 1);
|
||||
|
||||
// Change scope only — lifecycle should not fire; Hp=1 preserved.
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "white", turnsRemaining: null },
|
||||
]);
|
||||
expect(engine.session.get(e2Pawn, "Hp")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("piece-hp preset — capture interception", () => {
|
||||
it("non-lethal capture: target loses 1 HP, attacker stays, turn passes", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Scholars-Mate-style: 1. e4 e5 2. Bc4 Nc6 3. Qh5 … but we want a
|
||||
// capture in a couple moves. Easiest: 1. e4 d5 2. exd5 — white
|
||||
// pawn on e4 captures black pawn on d5.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
|
||||
const e4Pawn = pieceAt(engine, "e4")!;
|
||||
const d5Pawn = pieceAt(engine, "d5")!;
|
||||
expect(hpOf(engine, d5Pawn)).toBe(2);
|
||||
|
||||
// Attempt exd5. With piece-hp active, target starts at 2 HP → goes
|
||||
// to 1; non-lethal, attacker stays on e4, d5 pawn still there.
|
||||
const capture = engine.findMove(
|
||||
algebraicToSquare("e4"),
|
||||
algebraicToSquare("d5"),
|
||||
);
|
||||
expect(capture).not.toBeNull();
|
||||
engine.applyMove(capture!);
|
||||
|
||||
// Attacker did NOT move: e4 still occupied.
|
||||
expect(pieceAt(engine, "e4")).toBe(e4Pawn);
|
||||
// Target still there.
|
||||
expect(pieceAt(engine, "d5")).toBe(d5Pawn);
|
||||
// Target lost 1 HP.
|
||||
expect(hpOf(engine, d5Pawn)).toBe(1);
|
||||
// Turn advanced to black.
|
||||
expect(engine.getCurrentTurn()).toBe("black");
|
||||
});
|
||||
|
||||
it("lethal capture: target at 1 HP is fully removed, attacker moves in", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
|
||||
// Hand-damage the d5 pawn down to 1 HP so the next capture is lethal.
|
||||
const d5Pawn = pieceAt(engine, "d5")!;
|
||||
engine.session.insert(d5Pawn, "Hp", 1);
|
||||
|
||||
const capture = engine.findMove(
|
||||
algebraicToSquare("e4"),
|
||||
algebraicToSquare("d5"),
|
||||
);
|
||||
engine.applyMove(capture!);
|
||||
|
||||
// e4 now empty, d5 now holds the white pawn (standard capture
|
||||
// semantics apply when HP reaches 0).
|
||||
expect(pieceAt(engine, "e4")).toBeNull();
|
||||
expect(pieceAt(engine, "d5")).not.toBeNull();
|
||||
expect(pieceAt(engine, "d5")).not.toBe(d5Pawn); // d5 pawn retracted
|
||||
});
|
||||
|
||||
it("repeated non-lethal captures drain HP to 0, third capture kills", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
// Build a position where white and black pieces can repeatedly
|
||||
// poke each other. Easiest setup: clear the board, put a white
|
||||
// pawn on e4 and black pawn on d5, then alternate captures.
|
||||
// Actually we'll use natural play:
|
||||
// 1. e4 d5 2. exd5 (d5 → HP 1) 3. ... ... tricky without
|
||||
// alternation. Simpler: directly test with a contrived board.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
const d5Pawn = pieceAt(engine, "d5")!;
|
||||
|
||||
// First poke: HP 2 → 1.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
expect(hpOf(engine, d5Pawn)).toBe(1);
|
||||
expect(pieceAt(engine, "d5")).toBe(d5Pawn);
|
||||
|
||||
// Black's turn. Black needs to play any move so white can poke again.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||
);
|
||||
|
||||
// Second poke: HP 1 → 0, lethal. d5 pawn dies, white pawn moves in.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
expect(pieceAt(engine, "e4")).toBeNull();
|
||||
const nowOnD5 = pieceAt(engine, "d5");
|
||||
expect(nowOnD5).not.toBeNull();
|
||||
expect(nowOnD5).not.toBe(d5Pawn);
|
||||
});
|
||||
|
||||
it("deactivating after damage leaves pieces un-healed but without Hp attribute", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setActivePresets([
|
||||
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
|
||||
);
|
||||
|
||||
const d5Pawn = pieceAt(engine, "d5")!;
|
||||
expect(hpOf(engine, d5Pawn)).toBe(1);
|
||||
|
||||
// Toggle off.
|
||||
engine.setActivePresets([]);
|
||||
// Hp fact should be gone from all pieces including the damaged one.
|
||||
expect(engine.session.contains(d5Pawn, "Hp")).toBe(false);
|
||||
|
||||
// Next capture attempt should be lethal via standard rules.
|
||||
engine.applyMove(
|
||||
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||
);
|
||||
// White to play; need a white move. Actually turn is white here
|
||||
// (black just played a6). Move a white pawn back into the fray:
|
||||
const whiteMove = engine.findMove(
|
||||
algebraicToSquare("a2"),
|
||||
algebraicToSquare("a3"),
|
||||
);
|
||||
engine.applyMove(whiteMove!);
|
||||
// Without piece-hp, normal chess semantics resume.
|
||||
expect(
|
||||
engine.session.allFacts().some((f) => f.attr === "Hp"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,103 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* - 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.
|
||||
*
|
||||
* - 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.
|
||||
*
|
||||
* Incompatibilities: explosive-rook (different capture resolution model
|
||||
* — AoE instant removal vs. single-target damage).
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
|
||||
/** Starting HP for every piece. Future work: make this per-type so
|
||||
* pawns have 1 HP and queens have 3, etc. */
|
||||
const DEFAULT_HP = 2;
|
||||
|
||||
/** Find every entity on the board that could reasonably be a piece
|
||||
* (has PieceType + Color + Position). Game-level entity (id 0) is
|
||||
* excluded. */
|
||||
function iteratePieceIds(session: Session): EntityId[] {
|
||||
const facts = session.allFacts();
|
||||
const ids = new Set<EntityId>();
|
||||
for (const f of facts) {
|
||||
if (f.attr === "PieceType" && (f.id as number) > 0) {
|
||||
ids.add(f.id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "piece-hp",
|
||||
name: "Hit Points",
|
||||
description: "All pieces start with 2 HP. Captures deal 1 HP damage; piece only dies at 0 HP. Attacker stays on target if HP > 0.",
|
||||
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"],
|
||||
requires: [],
|
||||
// Full integration in ChessEngine (P3.11)
|
||||
|
||||
onActivate(engine: ChessEngine) {
|
||||
const session = engine.session;
|
||||
for (const id of iteratePieceIds(session)) {
|
||||
// Idempotent: skip entities that already have an Hp fact, so
|
||||
// syncing from server state that already includes Hp doesn't
|
||||
// stomp on the authoritative values.
|
||||
if (session.contains(id, "Hp")) continue;
|
||||
session.insert(id, "Hp", DEFAULT_HP);
|
||||
}
|
||||
},
|
||||
|
||||
onDeactivate(engine: ChessEngine) {
|
||||
const session = engine.session;
|
||||
for (const id of iteratePieceIds(session)) {
|
||||
if (session.contains(id, "Hp")) {
|
||||
session.retract(id, "Hp");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBeforeCapture(engine: ChessEngine, _attacker: EntityId, target: EntityId) {
|
||||
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;
|
||||
|
||||
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 };
|
||||
}
|
||||
// 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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
82
packages/chess/src/presets/piece-hp.ui.tsx
Normal file
82
packages/chess/src/presets/piece-hp.ui.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* UI overlay for the `piece-hp` preset: a row of pip dots above each
|
||||
* piece showing its current HP.
|
||||
*
|
||||
* Filled (solid) dot = remaining HP.
|
||||
* Empty (hollow) dot = lost HP.
|
||||
*
|
||||
* Design choices:
|
||||
* - Pip dots instead of a bar: stays readable at every zoom and
|
||||
* scales naturally if we later increase max HP beyond 2. The
|
||||
* render is resolution-independent SVG-like CSS (no image asset).
|
||||
* - Color matches the piece color (white pips for white pieces,
|
||||
* dark pips for black) so the affordance sits on the piece
|
||||
* visually rather than competing with it.
|
||||
* - Positioned at the TOP of the cell, slightly clipping above the
|
||||
* piece image. The piece image uses 85% of the cell area so
|
||||
* there's room; the overlay sits at roughly 5% from the top edge.
|
||||
*/
|
||||
|
||||
import { registerPieceOverlay } from "../ui/preset-overlays.js";
|
||||
import type { PieceOverlayProps } from "../ui/preset-overlays.js";
|
||||
|
||||
/** Max HP we expect to display. If the mechanic ever bumps starting
|
||||
* HP past this we'll render a row of `maxHp` pips, not truncate. */
|
||||
const DEFAULT_MAX_HP = 2;
|
||||
|
||||
function HealthBarPips({ pieceFacts }: PieceOverlayProps) {
|
||||
// Pull HP + Color from the pre-filtered piece facts. If Hp is
|
||||
// absent the overlay renders nothing — means the preset isn't
|
||||
// actually wired on this piece (yet).
|
||||
const hp = pieceFacts.find((f) => f.attr === "Hp")?.value as
|
||||
| number
|
||||
| undefined;
|
||||
if (hp === undefined) return null;
|
||||
const color = pieceFacts.find((f) => f.attr === "Color")?.value as
|
||||
| "white"
|
||||
| "black"
|
||||
| undefined;
|
||||
|
||||
// Max HP is implicit: we show the greater of DEFAULT_MAX_HP and the
|
||||
// piece's current Hp (in case some future preset heals above the cap).
|
||||
const maxHp = Math.max(DEFAULT_MAX_HP, hp);
|
||||
|
||||
// Pip colors: white pieces get dark pips (sits on the light piece),
|
||||
// black pieces get light pips. Both outlined with the contrasting
|
||||
// color for legibility on either square color.
|
||||
const filledClass =
|
||||
color === "black"
|
||||
? "bg-white border border-neutral-900"
|
||||
: "bg-neutral-900 border border-white";
|
||||
const emptyClass =
|
||||
color === "black"
|
||||
? "bg-transparent border border-white/60"
|
||||
: "bg-transparent border border-neutral-900/60";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-role="hp-overlay"
|
||||
// Absolute so it sits at the top of the cell without affecting
|
||||
// the piece's flex centering. pointer-events-none because HP
|
||||
// pips shouldn't intercept drags or hovers.
|
||||
className="absolute top-1 left-1/2 -translate-x-1/2 flex gap-[3px] pointer-events-none z-30"
|
||||
>
|
||||
{Array.from({ length: maxHp }, (_, i) => {
|
||||
const filled = i < hp;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-role={filled ? "hp-pip-full" : "hp-pip-empty"}
|
||||
className={`w-[7px] h-[7px] rounded-full shadow-sm ${
|
||||
filled ? filledClass : emptyClass
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Register at module init. Consumers side-effect-import this file to
|
||||
// populate the overlay registry.
|
||||
registerPieceOverlay("piece-hp", HealthBarPips);
|
||||
|
|
@ -1,11 +1,46 @@
|
|||
/**
|
||||
* Preset rule registry (P3.4).
|
||||
*
|
||||
* A preset is a modifier to chess rules. Each preset exposes one or more
|
||||
* hooks that the ChessEngine (P3.11) will call during move generation:
|
||||
* A preset is a modifier to chess rules. Presets expose optional hooks
|
||||
* that the ChessEngine invokes at well-defined points. The full menu:
|
||||
*
|
||||
* - getExtraMoves: returns ADDITIONAL legal moves for a piece.
|
||||
* - filterMoves: removes/modifies entries in an already-computed move list.
|
||||
* Move-generation hooks (per-piece, on every getAllLegalMoves call):
|
||||
* - getExtraMoves(engine, pieceId) -> LegalMove[]
|
||||
* Contribute extra legal moves (e.g. wrap-board, knights-leap-twice).
|
||||
* - filterMoves(moves, engine, pieceId) -> LegalMove[]
|
||||
* Remove/modify moves from the aggregated list (e.g. knight-immunity).
|
||||
*
|
||||
* Lifecycle hooks (fire once per state transition):
|
||||
* - onActivate(engine)
|
||||
* Called when the preset transitions from inactive -> active. Use this
|
||||
* to seed per-piece state, e.g. `piece-hp` inserts `Hp = 2` on every
|
||||
* existing entity here.
|
||||
* - onDeactivate(engine)
|
||||
* Called when the preset transitions from active -> inactive, either
|
||||
* because the user toggled it off or because its turn-timer expired.
|
||||
* Symmetric cleanup point (retract custom attributes, etc.).
|
||||
*
|
||||
* Capture-interception hook (per-capture, main-session only):
|
||||
* - onBeforeCapture(engine, attacker, target) -> { consume?: boolean } | void
|
||||
* Fires immediately before the engine would retract the target's
|
||||
* piece facts. Returning `{ consume: true }` tells the engine
|
||||
* "I've handled this capture, skip your default retract-and-move
|
||||
* behaviour"; the attacker will NOT move and the target will NOT
|
||||
* be removed. The preset itself decides what to do (decrement an
|
||||
* HP attribute, explode adjacent squares, etc.). Anything else
|
||||
* (undefined, `{}`, `{ consume: false }`) lets the engine continue
|
||||
* with the normal capture path.
|
||||
*
|
||||
* IMPORTANT: this hook only fires from `ChessEngine.applyMove` on
|
||||
* the authoritative session. The self-check filter uses an isolated
|
||||
* snapshot session and deliberately bypasses the hook — otherwise
|
||||
* every move-legality check would fire preset side-effects.
|
||||
*
|
||||
* Overall design intent: these hooks let a preset react to state changes
|
||||
* without coupling the engine to any specific rule. Adding a new rule with
|
||||
* custom state + custom captures + custom UI should be possible without
|
||||
* touching `engine.ts` at all — see `./piece-hp.ts` + `./piece-hp.ui.tsx`
|
||||
* for the canonical example.
|
||||
*
|
||||
* Presets register themselves via side-effect imports (see `./index.ts`).
|
||||
*/
|
||||
|
|
@ -13,6 +48,13 @@ import type { EntityId } from "@paratype/rete";
|
|||
import type { ChessEngine } from "../engine.js";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
|
||||
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
|
||||
* default capture path (no target retraction, no attacker move).
|
||||
* `consume: false` / undefined continues normally. */
|
||||
export interface CaptureHookResult {
|
||||
readonly consume?: boolean;
|
||||
}
|
||||
|
||||
export interface PresetDef {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
|
@ -21,14 +63,25 @@ export interface PresetDef {
|
|||
readonly incompatibleWith: readonly string[];
|
||||
/** Preset IDs that must also be active for this one to be valid. */
|
||||
readonly requires: readonly string[];
|
||||
/** Returns additional legal moves for a piece (called per-piece). */
|
||||
|
||||
// ── Move-generation hooks ────────────────────────────────────────────
|
||||
readonly getExtraMoves?: (engine: ChessEngine, pieceId: EntityId) => LegalMove[];
|
||||
/** Filters/modifies the aggregated move list for a piece. */
|
||||
readonly filterMoves?: (
|
||||
moves: LegalMove[],
|
||||
engine: ChessEngine,
|
||||
pieceId: EntityId,
|
||||
) => LegalMove[];
|
||||
|
||||
// ── Lifecycle hooks ──────────────────────────────────────────────────
|
||||
readonly onActivate?: (engine: ChessEngine) => void;
|
||||
readonly onDeactivate?: (engine: ChessEngine) => void;
|
||||
|
||||
// ── Capture-interception hook ────────────────────────────────────────
|
||||
readonly onBeforeCapture?: (
|
||||
engine: ChessEngine,
|
||||
attacker: EntityId,
|
||||
target: EntityId,
|
||||
) => CaptureHookResult | void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
21
packages/chess/src/presets/ui-overlays-index.ts
Normal file
21
packages/chess/src/presets/ui-overlays-index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* UI-only barrel for preset visual overlays.
|
||||
*
|
||||
* This file is imported EXACTLY ONCE by the app entry point (Board.tsx
|
||||
* or App.tsx). It side-effect imports every `.ui.tsx` file so they can
|
||||
* register their overlay components in the UI registry. Split from
|
||||
* `./index.ts` so non-UI consumers (engine tests, server) don't drag
|
||||
* React into their bundle.
|
||||
*
|
||||
* To add a new visually-rich preset:
|
||||
* 1. Implement the mechanic in `packages/chess/src/presets/foo.ts`.
|
||||
* 2. Implement the overlay in `packages/chess/src/presets/foo.ui.tsx`
|
||||
* and call `registerPieceOverlay('foo', FooOverlay)` at module
|
||||
* scope.
|
||||
* 3. Add a side-effect import here.
|
||||
*
|
||||
* No changes to engine.ts or Board.tsx required.
|
||||
*/
|
||||
|
||||
import "./piece-hp.ui.js";
|
||||
// Future rules with per-piece overlays add their .ui import here.
|
||||
|
|
@ -5,6 +5,11 @@ import type { LegalMove } from '../rules/types';
|
|||
import { Piece } from './Piece';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
import {
|
||||
getActivePieceOverlays,
|
||||
type PieceOverlayComponent,
|
||||
} from './preset-overlays';
|
||||
import '../presets/ui-overlays-index';
|
||||
|
||||
interface BoardProps {
|
||||
facts: ChessFact[];
|
||||
|
|
@ -19,6 +24,9 @@ interface BoardProps {
|
|||
* callers pass the hook's return value directly without stripping keys. */
|
||||
lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||
checkedKingSquare?: number | null | undefined;
|
||||
/** Currently-active preset ids. Used to look up registered per-piece
|
||||
* overlay components (e.g. HP pips for piece-hp). Order preserved. */
|
||||
activePresetIds?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface PieceState {
|
||||
|
|
@ -27,7 +35,29 @@ interface PieceState {
|
|||
color: PieceColor;
|
||||
}
|
||||
|
||||
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) {
|
||||
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds }: BoardProps) {
|
||||
// Pre-compute overlay components once per render — lookup is cheap
|
||||
// but doing it once in a useMemo keeps the Piece render path clean.
|
||||
const overlays: PieceOverlayComponent[] = useMemo(
|
||||
() => getActivePieceOverlays(activePresetIds ?? []),
|
||||
[activePresetIds],
|
||||
);
|
||||
|
||||
// Group facts by entity id ONCE per render. Overlays want per-piece
|
||||
// fact arrays and rebuilding the index inline per square would be
|
||||
// O(squares × facts). The map is reused for the pieces-by-square
|
||||
// construction below too.
|
||||
const factsById = useMemo(() => {
|
||||
const map = new Map<number, ChessFact[]>();
|
||||
for (const f of facts) {
|
||||
const id = f.id as number;
|
||||
if (id <= 0) continue; // skip game entity
|
||||
const arr = map.get(id);
|
||||
if (arr) arr.push(f);
|
||||
else map.set(id, [f]);
|
||||
}
|
||||
return map;
|
||||
}, [facts]);
|
||||
// Build pieces map: square -> { id, type, color }
|
||||
const pieces = useMemo(() => {
|
||||
const map = new Map<number, PieceState>();
|
||||
|
|
@ -282,6 +312,16 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
|||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
{/* Per-piece preset overlays (HP pips, etc.). Each is a
|
||||
pure function of the piece's facts; Board stays
|
||||
agnostic to which presets exist. */}
|
||||
{overlays.map((Overlay, i) => (
|
||||
<Overlay
|
||||
key={i}
|
||||
pieceId={piece.id}
|
||||
pieceFacts={factsById.get(piece.id) ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ function GameLayout({
|
|||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
activePresetIds={activations.map((a) => a.id)}
|
||||
/>
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
|
|
|
|||
|
|
@ -83,9 +83,11 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
|||
|
||||
const handleApply = () => {
|
||||
// Starting a new game preserving the currently configured rule set.
|
||||
// Route through setActivePresets so onActivate fires on the fresh
|
||||
// engine (piece-hp needs it to seed Hp=2 on every starting piece).
|
||||
clearAutoSave();
|
||||
const newEngine = new ChessEngine();
|
||||
newEngine.activePresets.replaceAll(activations);
|
||||
newEngine.setActivePresets(activations);
|
||||
chessState.loadEngine(newEngine);
|
||||
navigate('/game');
|
||||
};
|
||||
|
|
|
|||
85
packages/chess/src/ui/preset-overlays.tsx
Normal file
85
packages/chess/src/ui/preset-overlays.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Per-piece UI overlay registry for presets.
|
||||
*
|
||||
* Engine and UI are separated by design: `ChessEngine` doesn't know
|
||||
* about React. But many presets want custom visual affordances on top
|
||||
* of the piece — HP pips for `piece-hp`, a poison cloud for
|
||||
* `poisoned-squares`, an ammo counter for a future `guns` rule, etc.
|
||||
*
|
||||
* This registry is the bridge. A preset that needs a per-piece overlay
|
||||
* registers a React component here (from a `.ui.tsx` sibling file so
|
||||
* server-side imports stay React-free). `Board.tsx` looks up the
|
||||
* overlays for every active preset and renders them above the piece.
|
||||
*
|
||||
* Add a new visually-rich rule in three small files:
|
||||
*
|
||||
* packages/chess/src/presets/guns.ts // engine mechanic
|
||||
* packages/chess/src/presets/guns.ui.tsx // overlay component + register
|
||||
* packages/chess/src/presets/ui-index.ts // side-effect import of guns.ui
|
||||
*
|
||||
* Zero changes to engine.ts or Board.tsx.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ChessAttrMap, ChessFact } from "../schema";
|
||||
|
||||
/**
|
||||
* Data the Board already has per piece. Overlays are pure functions
|
||||
* of this — no engine reference, no session, just facts for the piece
|
||||
* in question. Keeps the overlay contract trivially mockable.
|
||||
*/
|
||||
export interface PieceOverlayProps {
|
||||
/** Entity id of the piece this overlay decorates. */
|
||||
readonly pieceId: number;
|
||||
/** All facts currently known about THIS piece. Pre-filtered by the
|
||||
* Board so the overlay doesn't re-scan the full board. */
|
||||
readonly pieceFacts: ReadonlyArray<ChessFact<keyof ChessAttrMap>>;
|
||||
}
|
||||
|
||||
/** The actual React component type for an overlay. */
|
||||
export type PieceOverlayComponent = (props: PieceOverlayProps) => ReactNode;
|
||||
|
||||
const registry = new Map<string, PieceOverlayComponent>();
|
||||
|
||||
/**
|
||||
* Register a per-piece overlay for a preset. Called at module init
|
||||
* time from each preset's `.ui.tsx` file; the order of registration
|
||||
* is irrelevant because overlays are looked up by preset id at render
|
||||
* time.
|
||||
*
|
||||
* Registering the same preset id twice overwrites the previous
|
||||
* registration — intentional so hot-module-reload works cleanly.
|
||||
*/
|
||||
export function registerPieceOverlay(
|
||||
presetId: string,
|
||||
component: PieceOverlayComponent,
|
||||
): void {
|
||||
registry.set(presetId, component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the overlay component for a preset id, or undefined if none
|
||||
* registered. Used by Board.tsx to decide what to render.
|
||||
*/
|
||||
export function getPieceOverlay(
|
||||
presetId: string,
|
||||
): PieceOverlayComponent | undefined {
|
||||
return registry.get(presetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the list of currently-active preset ids (from the hook's
|
||||
* `activations`), return the overlay components that should render.
|
||||
* The return order matches the input order, so presets can be layered
|
||||
* deterministically.
|
||||
*/
|
||||
export function getActivePieceOverlays(
|
||||
activePresetIds: ReadonlyArray<string>,
|
||||
): PieceOverlayComponent[] {
|
||||
const out: PieceOverlayComponent[] = [];
|
||||
for (const id of activePresetIds) {
|
||||
const c = registry.get(id);
|
||||
if (c !== undefined) out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ export class GameSession {
|
|||
this.engine = new ChessEngine();
|
||||
if (rulesetIds.length > 0) {
|
||||
try {
|
||||
this.engine.activePresets.replaceAll(
|
||||
this.engine.setActivePresets(
|
||||
rulesetIds.map((id) => ({
|
||||
id,
|
||||
scope: "both" as const,
|
||||
|
|
@ -129,7 +129,7 @@ export class GameSession {
|
|||
activations: readonly ActivationRequest[],
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
try {
|
||||
this.engine.activePresets.replaceAll(activations);
|
||||
this.engine.setActivePresets(activations);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue