From a442e54d7930522de1792ff565679354a31726e0 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:03:06 -0600 Subject: [PATCH 01/88] docs(adr): modifier-profiles architecture decisions --- docs/adr/modifier-profiles.md | 346 ++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/adr/modifier-profiles.md diff --git a/docs/adr/modifier-profiles.md b/docs/adr/modifier-profiles.md new file mode 100644 index 0000000..06167d3 --- /dev/null +++ b/docs/adr/modifier-profiles.md @@ -0,0 +1,346 @@ +# ADR: Piece Modifier Profiles Architecture + +This document captures the eight architectural decisions that shape the T1 +piece-modifier-profiles feature. Each section is self-contained and records +the decision, the reasoning behind it, and the alternatives that were +considered and rejected. + +--- + +## ADR-1: Move Generator Hook Contract — WRAP (not REPLACE) + +### Decision + +Introduce a new hook signature on modifier/preset descriptors: + +``` +transformMoveGenerator?(engine, pieceId, prevGenerator) => MoveGenerator +``` + +Each hook receives the *previous* generator in the chain and returns a new +one. Multiple presets plus the active profile can all contribute, composing +in precedence order. The generator returned by the chain emits +**pseudo-legal** moves only. + +### Rationale + +- **Composition over replacement.** Several modifier sources (presets, + per-type profile entries, per-instance profile entries) must be able to + stack without one clobbering another. +- **Clear layering.** The engine's legality layer (self-check filter, turn + validation, repetition detection) always runs downstream of the generator + chain. Hooks never need to reason about whole-board legality — only about + the piece's own movement facts. +- **Deterministic ordering.** Precedence (ADR-5) uniquely defines the order + in which generators are wrapped, so the final generator is reproducible + from the profile alone. + +### Rejected Alternatives + +- **REPLACE semantics** (only the first/highest-priority hook wins): kills + composition. Two independent modifiers that both want to alter movement + could not coexist, forcing users to choose one or hand-merge them. +- **Mid-chain interception** (hooks can peek at or mutate moves emitted by + other hooks): dramatically more complex to reason about, breaks locality, + and makes determinism hard to audit. + +--- + +## ADR-2: Per-Instance Identity Keying — Layout-Slot Bound (Option B) + +### Decision + +Per-instance modifier entries in a profile are keyed by the piece's +**starting square in algebraic notation** (e.g. `"b1"`). The profile +document carries an optional `layoutId` field. At game start, +`applyProfileToSession` resolves each `square → EntityId` by consulting the +layout's piece placement array. + +Cross-layout reuse is permitted for the *per-type* portion of a profile. +The *per-instance* portion is dropped with a warning when the active layout +does not contain the referenced square (orphan handling — see ADR-6 check +#3). + +### Rationale + +- **Human-readable.** Profile JSON (and any URL-encoded form) stays legible: + a designer can see `"b1": { ... }` and immediately know which piece it + targets. +- **Portable within a layout.** Two sessions using the same layout can + share the profile verbatim. +- **Graceful degradation.** When a profile is applied against a different + layout, per-type rules still apply; only the now-meaningless per-instance + keys are dropped, with a surfaced warning. + +### Rejected Alternatives + +- **Option A — key by `EntityId`.** EntityIds are session-scoped runtime + handles; they are meaningless outside the session that produced them. + This would make profiles non-portable and impossible to author by hand. +- **Option C — defer per-instance entirely to T2.** Per-instance keying is + the single feature that makes "this specific rook on b1" different from + "all rooks" possible in T1; deferring it would gut the feature's value. + +--- + +## ADR-3: Hot-Swap Reconciliation Rules + +### Decision + +When a profile is swapped on a live game, each modifier kind reconciles +according to the following table: + +| Modifier | On swap-apply | On swap-remove | +| ------------------- | ------------------------------------------------------------- | --------------------------------------- | +| HpBonus | `maxHp` recomputed; `currentHp = min(currentHp, newMax)` | `currentHp` never grows (same rule) | +| RangeBonus | Overwrite fact; next legal-move query reflects new range | Revert to base; same query semantics | +| DirectionAdditions | Overwrite fact; next legal-move query includes/excludes | Revert to base direction set | +| CaptureFlags | Overwrite; applies to the **next** capture event | Revert; applies to next capture | +| PromotionOverride | Overwrite; applies to **future** promotions only | Revert; future promotions use base | +| DamageResistance | Overwrite; applies to the **next** damage event | Revert; next damage uses base | + +**Timing.** Hot-swaps are applied at the **turn boundary only** — after +`turnEnd` fires and before the next `turnStart`. Any update received +mid-turn is queued and flushed at that boundary. + +**Failure / rollback.** The server is authoritative and clients hold no +optimistic state for profile changes. If a swap is rejected (legality +validator fails, permission denied, etc.) the server sends a NACK to the +originating client and performs no broadcast. There is no per-event +rollback to unwind partially-applied effects, because effects do not apply +until the boundary. + +### Rationale + +- **Determinism.** Applying changes strictly at turn boundaries means every + observer (players, spectators, replays) sees an identical sequence of + (state, swap, state, swap) transitions. +- **HP invariant.** The "never grows" rule for `currentHp` preserves the + intuition that removing a buff should not heal; applying a buff raises + the ceiling but never the floor. +- **Server authority.** Keeping clients dumb about swap outcomes avoids a + whole class of desync bugs; the NACK pattern keeps the protocol simple. + +### Rejected Alternatives + +- **Mid-turn application.** Introduces ordering ambiguity relative to + queued events, move-generation caches, and partially-resolved attacks; + determinism becomes painful to reason about. +- **Per-event rollback.** Would require journaling every effect so it can + be unwound on failure. T1 does not need this level of sophistication, + and the boundary-only rule makes it unnecessary. + +--- + +## ADR-4: Stacking Rules per Modifier Kind + +### Decision + +Each T1 modifier kind declares a fixed stacking rule, applied whenever more +than one source contributes a value (per ADR-5 precedence): + +| Modifier | Stacking rule | Formula | +| ------------------- | -------------------------------- | --------------------------------------------- | +| HpBonus | Additive | sum of all sources | +| RangeBonus | Additive, clamped `[0, 7]` | sum, then clamp | +| DirectionAdditions | Union | set union of arrays | +| CaptureFlags | Union (bitwise OR) | flags OR'd together | +| PromotionOverride | Precedence wins | perInstance > perType > preset | +| DamageResistance | Multiplicative | `1 - ∏(1 - r_i)`, clamped `≥ 0` | + +### Rationale + +- **Additive/union kinds** have natural commutative/associative semantics, + so order of application does not matter. This is safe to compute in any + order. +- **Clamping `RangeBonus`** to the 0..7 board diagonal keeps generators + sane; an 8-square-wide board can never need more than 7 steps of range. +- **Multiplicative `DamageResistance`** matches player intuition: two + sources of 50% resistance yield 75% total, not 100%. This prevents + accidental invulnerability from additive stacking. +- **PromotionOverride as "precedence wins"** reflects that overrides are + replacement-style facts — two simultaneous overrides cannot meaningfully + merge, so the highest-priority one is chosen. + +### Rejected Alternatives + +- **All-additive** (including resistance): trivially produces 100% + resistance with two modest sources, breaking balance. +- **All-overwrite**: kills the expressive power of stacking entirely and + forces designers to pre-merge any combination of modifiers they want. + +--- + +## ADR-5: Precedence Chain + +### Decision + +When multiple sources contribute to the same piece and modifier kind, the +priority order is: + +``` +per-instance (profile) > per-type (profile) > preset > engine base +``` + +- For **additive** and **union** stacking rules (see ADR-4), *all* sources + contribute; precedence only controls the order in which hooks wrap the + move generator (ADR-1). +- For **override** kinds (e.g. `PromotionOverride`), only the + highest-priority source wins. + +### Rationale + +- **Specificity first.** Per-instance data is the most specific statement + ("this piece on b1"), per-type is broader ("all bishops"), preset is + broader still ("this game mode"), and engine base is the default. Higher + specificity winning matches designer expectations and mirrors how CSS, + config layering, and similar systems behave. +- **Composable by default.** Treating additive/union kinds as contributors + rather than gated by precedence means a per-type buff and a per-instance + buff can *both* apply, which is the whole point of having two layers. + +### Rejected Alternatives + +- **Preset-first** (preset beats profile): undermines user-authored + profiles and makes game modes unmodifiable. +- **Flat merge with no precedence**: leaves override-style modifiers + ambiguous. + +--- + +## ADR-6: Legality Validator Checklist + +### Decision + +On every profile apply and every hot-swap, the engine runs a legality +validator with five checks. Each check has a stable error code for +client-side reporting. + +1. **Both sides have ≥ 1 king.** Code: `E_PROFILE_NO_KING`. Error. +2. **No king carries `CaptureFlags = CANNOT_BE_CAPTURED`.** Code: + `E_PROFILE_INVULN_KING`. Error. +3. **No orphan per-instance entries** (per-instance key references a + square not present in the active layout). Code: + `E_PROFILE_ORPHAN_INSTANCE`. **Warning only**, not a rejection — the + orphan entry is dropped per ADR-2. +4. **Each side has ≥ 1 legal move** from the current position. Code: + `E_PROFILE_DEADLOCK`. Error. +5. **Total resolved facts per piece ≤ 16 attributes.** Code: + `E_PROFILE_ATTR_LIMIT`. Error. + +Checks that emit an error cause the apply/swap to be rejected atomically; +no partial state is observable. Warnings are surfaced but do not block +application. + +### Rationale + +- **Win-condition integrity.** Checks 1 and 2 guarantee the game can still + be won — you cannot accidentally author a profile that removes all kings + or makes a king uncapturable. +- **Playability.** Check 4 prevents instant deadlocks at swap time. +- **Performance & sanity ceiling.** Check 5 caps the fact-set per piece so + that the attribute system cannot be overwhelmed by pathological + profiles. +- **User experience.** Check 3 is a warning rather than an error because + dropping irrelevant per-instance keys (see ADR-2) is the documented + behaviour when applying across layouts. + +### Rejected Alternatives + +- **No validator — trust the author.** Produces unrecoverable games and + makes server state hard to reason about. +- **Validator as errors only (no warnings).** Would force cross-layout + reuse to be a hard failure, defeating ADR-2's portability goal. +- **Validator at game-start only.** Misses hot-swap-introduced + corruptions. + +--- + +## ADR-7: Single Active Profile Per Game (T1) + +### Decision + +In T1, exactly one profile is active on a given game at a time. Changing +the active profile is a **full swap** performed by sending a +`modifier-profile.update` WebSocket message. Profile stacking or layered +composition of multiple profiles is explicitly deferred to T2+. + +### Rationale + +- **Scope control.** T1 already introduces a new hook, registry, profile + document shape, and validator. Adding multi-profile composition on top + would multiply the edge cases (ordering between profiles, overlap + conflicts, partial updates) and delay the feature. +- **Simple mental model.** "One profile, swap to change it" is trivially + understandable by end users and matches how presets are selected today. +- **Forward-compatible.** The swap message already carries a full profile + payload; a future multi-profile world can extend the same message shape + (e.g. `profiles: Profile[]`) without breaking T1 clients. + +### Rejected Alternatives + +- **Multi-profile composition in T1.** Punts on too many unresolved + design questions (inter-profile precedence, addition vs replacement, + diffing) to fit in the T1 milestone. +- **Additive deltas only (no full swap).** Harder to reason about when + recovering from a corrupted state; the full-swap primitive is simpler + and can always simulate a delta by applying a re-derived profile. + +--- + +## ADR-8: Registry Pattern for Modifier Catalog + +### Decision + +Each T1 modifier kind lives in its own file under: + +``` +packages/chess/src/modifiers/descriptors/{kind}.ts +``` + +At module load time the file self-registers into `MODIFIER_REGISTRY`, +mirroring the existing `PRESET_REGISTRY` and `LAYOUT_REGISTRY` patterns +(with `register(def)`, `get(id)`, `list()`, `has(id)` surface). + +The descriptor shape is: + +``` +{ + id, + attrName, + label, + valueSchema, + stackingRule, + apply, + describe, + uiForm, +} +``` + +T3 custom (user-defined) modifiers will plug into this same registry, +requiring no changes to the registry contract itself. + +### Rationale + +- **One modifier = one file.** Adding a new modifier kind is a single-file + addition, not a multi-file edit across schema, engine, UI, and + validator. This is the same ergonomic property that makes the preset + and layout registries pleasant to extend. +- **Consistency with existing patterns.** The codebase already has two + registries following this shape; a third avoids introducing a new + idiom to learn. +- **T3-ready.** Framing the registry as the single integration point now + means T3 custom modifiers can register through the same API without + special-casing. +- **Discoverability.** `MODIFIER_REGISTRY.list()` produces the full + catalog for UI/UX (picker widgets, docs generators, validation hints). + +### Rejected Alternatives + +- **Hardcoded switch/if-else.** Adding a new modifier kind would require + editing six or more files (schema, engine apply path, hooks, UI, docs, + validator). Each edit is an opportunity for drift between layers. +- **Plugin manifest with lazy loading.** Overkill for a fixed T1 catalog + and incompatible with deterministic module load ordering. + +--- From 0f3b28ba5556641a8c1273156a0a601bd02f27b7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:04:21 -0600 Subject: [PATCH 02/88] feat(engine): add 6 modifier attrs to ChessAttrMap --- packages/chess/src/schema.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index ff287bd..11f0769 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -49,10 +49,25 @@ export interface ChessAttrMap { // Custom rule attributes (from RULES.md presets) Hp: number; PoisonedSquare: boolean; + // Modifier profile attributes + HpBonus: number; + RangeBonus: number; + DirectionAdditions: readonly string[]; + CaptureFlags: number; + PromotionOverride: PieceType | "disabled"; + DamageResistance: number; } export type ChessAttrKey = keyof ChessAttrMap; +/** Capture behavior bitflags for the CaptureFlags modifier attribute. */ +export const CaptureFlag = { + CAN_CAPTURE_OWN: 1, + CANNOT_BE_CAPTURED: 2, + EN_PASSANT: 4, +} as const; +export type CaptureFlag = typeof CaptureFlag[keyof typeof CaptureFlag]; + /** A typed chess fact triple. */ export interface ChessFact { readonly id: EntityId; From fea511790b3406f572f3ab01c4a5ad8efd001945 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:05:58 -0600 Subject: [PATCH 03/88] feat(engine): modifier profile types --- packages/chess/package.json | 3 +- packages/chess/src/modifiers/types.ts | 148 ++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/chess/src/modifiers/types.ts diff --git a/packages/chess/package.json b/packages/chess/package.json index 362fcae..561568d 100644 --- a/packages/chess/package.json +++ b/packages/chess/package.json @@ -21,7 +21,8 @@ "motion": "^12.38.0", "react-router-dom": "^7.14.1", "sonner": "^2.0.7", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/canvas-confetti": "^1.9.0", diff --git a/packages/chess/src/modifiers/types.ts b/packages/chess/src/modifiers/types.ts new file mode 100644 index 0000000..ffdeb0c --- /dev/null +++ b/packages/chess/src/modifiers/types.ts @@ -0,0 +1,148 @@ +/** + * Type definitions for the piece modifier profile system. + * + * A ModifierProfile is a first-class entity (orthogonal to layouts and + * presets) that attaches rule modifiers to pieces by type ("all white + * knights have +1 HP") or by layout slot ("the piece on b1 has +2 range"). + * + * Modifier profiles combine with layouts at game start. They are saved + * independently to a library (houserules:modifier-profiles:v1) and can be + * hot-swapped mid-game at turn boundaries. + * + * ADR-2: per-instance modifiers keyed by algebraic-notation square string + * (e.g. "b1"). Profile may optionally specify a layoutId for validation. + */ +import type { EntityId, Session } from "@paratype/rete"; +import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js"; +import type { ZodType } from "zod"; + +/** The six T1 modifier category identifiers. */ +export type ModifierKindId = + | "hp-bonus" + | "range-bonus" + | "direction-additions" + | "capture-flags" + | "promotion-override" + | "damage-resistance"; + +/** + * Named movement directions (from the perspective of the moving piece, + * color-independent — engine interprets "forward" as toward the opponent's + * back rank based on piece color). + * + * Used by DirectionAdditions modifier to specify additional movement directions. + */ +export type Direction = + | "forward" // toward opponent's back rank (1 square) + | "backward" // toward own back rank (1 square) + | "left" // queenside (from white's perspective) + | "right" // kingside (from white's perspective) + | "diagonal-fl" // forward-left + | "diagonal-fr" // forward-right + | "diagonal-bl" // backward-left + | "diagonal-br"; // backward-right + +/** + * A modifier that applies to ALL pieces of a given type+color combo. + * `value` is the modifier-kind-specific value (number, string[], etc.) + */ +export interface TypeModifier { + readonly kind: ModifierKindId; + readonly pieceType: PieceType; + readonly color: PieceColor | "both"; + readonly value: unknown; +} + +/** + * A modifier that applies to the piece at a specific layout square. + * `square` is algebraic notation: "a1" through "h8". + * ADR-2: keyed by layout-slot (square string), not EntityId. + */ +export interface InstanceModifier { + readonly kind: ModifierKindId; + readonly square: string; // algebraic notation, e.g. "b1" + readonly value: unknown; +} + +/** + * A complete modifier profile. + * + * Combines with a layout at game start: perType modifiers apply to all + * matching pieces; perInstance modifiers apply to pieces at specific squares. + * + * `layoutId` is optional — only required when perInstance entries are + * present, as per-instance modifiers are layout-slot-bound (ADR-2). + * When layoutId is absent and perInstance is non-empty, the validator + * emits a warning. + */ +export interface ModifierProfile { + readonly id: string; + readonly name: string; + readonly description: string; + /** Layout this profile's per-instance modifiers are bound to. Optional. */ + readonly layoutId?: string; + readonly perType: readonly TypeModifier[]; + readonly perInstance: readonly InstanceModifier[]; + readonly version: 1; + readonly source: "premade" | "custom"; +} + +/** + * Descriptor for a modifier category. Each T1 modifier registers one + * descriptor into MODIFIER_REGISTRY at module load time (ADR-8). + * + * `V` is the value type (number for HpBonus, string[] for DirectionAdditions, etc.) + */ +export interface ModifierDescriptor { + /** Matches ModifierKindId — used as the registry key. */ + readonly id: ModifierKindId; + /** The ChessAttrMap key this modifier seeds on piece entities. */ + readonly attrName: ChessAttrKey; + /** Human-readable display label for UI. */ + readonly label: string; + /** Zod schema for the value field — used for validation and UI form generation. */ + readonly valueSchema: ZodType; + /** How multiple values from different sources stack (ADR-4). */ + readonly stackingRule: "additive" | "union" | "multiplicative" | "priority-wins"; + /** + * Apply the modifier's effective value to a piece entity in the session. + * Called once per (pieceId, kind) pair after all sources have been + * collected and the effective value computed via stacking rules. + * + * @param session - The game session to mutate + * @param pieceId - Target piece entity + * @param effectiveValue - The already-stacked value to apply + */ + readonly apply: (session: Session, pieceId: EntityId, effectiveValue: V) => void; + /** Return a human-readable description of this modifier value for UI. */ + readonly describe: (value: V) => string; + /** + * Hint for the UI editor about which input widget to render for this modifier. + * Each uiForm maps to a specific React component in the PerTypePanel/PerInstancePanel. + */ + readonly uiForm: + | "number" + | "direction-set" + | "capture-flags" + | "promotion-target" + | "percentage"; +} + +/** Helper: create a TypeModifier with proper typing. */ +export function typeModifier( + kind: ModifierKindId, + pieceType: PieceType, + color: PieceColor | "both", + value: unknown, +): TypeModifier { + return { kind, pieceType, color, value }; +} + +/** Helper: create an InstanceModifier with proper typing. */ +export function instanceModifier( + kind: ModifierKindId, + square: string, + value: unknown, +): InstanceModifier { + return { kind, square, value }; +} From fab8a8115b93303773ab501fcf24b42a884385d5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:08:11 -0600 Subject: [PATCH 04/88] feat(engine): add transformMoveGenerator + modifyMoveAttrs preset hooks Adds two optional hooks to PresetDef: - transformMoveGenerator wraps a piece's move generator. Presets compose in list order; each receives the previous wrapper's output. Returned moves are pseudo-legal and still pass through the engine's self-check filter. - modifyMoveAttrs lets presets contribute additive range/direction deltas for generators that support them. ChessEngine.getAllLegalMoves folds the transform chain before getExtraMoves/filterMoves, preserving existing hook ordering and the downstream self-check filter. Tests cover: no-op wrap preserves moves, wrap adds pawn backward, two wraps compose, and self-check filter still prunes transform-added illegal moves. --- packages/chess/src/engine.ts | 26 +- packages/chess/src/presets/registry.ts | 33 +- .../chess/src/presets/transform-hook.test.ts | 359 ++++++++++++++++++ 3 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 packages/chess/src/presets/transform-hook.test.ts diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 8d4b8c1..147ad09 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -718,8 +718,28 @@ export class ChessEngine { .filter(p => p.type !== undefined); for (const piece of pieces) { - const getter = lookupMoveGenerator(piece.type); - if (!getter) continue; + const baseGetter = lookupMoveGenerator(piece.type); + // Fold the transformMoveGenerator chain over all active presets + // (scope-filtered for `color`). Each preset's wrapper receives the + // OUTPUT of the previous — composing cleanly. We seed with the + // type-registry generator, or a degenerate empty-move generator if + // the piece type is unknown (keeps later wrappers well-defined). + const scopedPresets = this.activePresets.getForColor(color); + const seedGetter: MoveGetter = + baseGetter ?? ((_s: Session, _id: EntityId) => [] as LegalMove[]); + const getter: MoveGetter = scopedPresets + .filter(p => p.transformMoveGenerator) + .reduce( + (gen, preset) => preset.transformMoveGenerator!(this, piece.id, gen), + seedGetter, + ); + // Skip pieces with no known type AND no transform — same semantics + // as the original "unknown type ⇒ immobile" guard, but now a + // transform preset can still produce moves for an otherwise unknown + // type. + if (!baseGetter && scopedPresets.every(p => !p.transformMoveGenerator)) { + continue; + } let pieceMoves = getter(this.session, piece.id); @@ -759,7 +779,7 @@ export class ChessEngine { // Order matters — `getExtraMoves` contributes to the set that // `filterMoves` operates on, so every active preset sees the full // aggregated set (including prior presets' additions). - const activePresets = this.activePresets.getForColor(color); + const activePresets = scopedPresets; for (const preset of activePresets) { if (preset.getExtraMoves) { pieceMoves = [ diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 69f564e..cca8012 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -44,7 +44,7 @@ * * Presets register themselves via side-effect imports (see `./index.ts`). */ -import type { EntityId } from "@paratype/rete"; +import type { EntityId, Session } from "@paratype/rete"; import type { ChessEngine, GameResult } from "../engine.js"; import type { LegalMove } from "../rules/types.js"; import type { ChessAttrKey } from "../schema.js"; @@ -301,6 +301,37 @@ export interface PresetDef { pieceId: EntityId, ) => LegalMove[]; + /** + * Wraps the move generator for a specific piece. Receives the current + * generator (initially the type-registry's generator, or the output of + * a prior preset's transformation) and returns a new generator. + * + * Semantics: WRAP, not REPLACE. The returned function MUST call `prev` + * at some point to preserve base moves (unless intentionally discarding). + * Returned moves are PSEUDO-LEGAL — the engine's self-check filter runs + * downstream regardless. + * + * Evaluation order: presets iterate in list order. Each transform receives + * the output of the previous, composing cleanly. + */ + readonly transformMoveGenerator?: ( + engine: ChessEngine, + pieceId: EntityId, + prev: (session: Session, pieceId: EntityId) => LegalMove[], + ) => (session: Session, pieceId: EntityId) => LegalMove[]; + + /** + * Contribute move attribute deltas (range extension, direction additions) + * for a piece. Read by move generators that support them (rook, bishop, queen). + * Multiple presets returning values are ADDITIVE per the stacking rules. + * + * Returns undefined / empty object for no-op. + */ + readonly modifyMoveAttrs?: ( + engine: ChessEngine, + pieceId: EntityId, + ) => { rangeBonus?: number; directionAdditions?: readonly string[] } | undefined; + // ── Lifecycle hooks ────────────────────────────────────────────────── readonly onActivate?: (ctx: LifecycleContext) => void; readonly onDeactivate?: (ctx: LifecycleContext) => void; diff --git a/packages/chess/src/presets/transform-hook.test.ts b/packages/chess/src/presets/transform-hook.test.ts new file mode 100644 index 0000000..033c190 --- /dev/null +++ b/packages/chess/src/presets/transform-hook.test.ts @@ -0,0 +1,359 @@ +/** + * Tests for the `transformMoveGenerator` preset hook. + * + * `transformMoveGenerator` WRAPS the type-registry move generator for a + * given piece. Each preset that implements it receives the output of the + * previous preset's wrapper, composing cleanly. Self-check filtering + * runs downstream and cannot be bypassed. + * + * These tests exercise: + * 1. A no-op wrapper (calls prev unchanged) produces identical moves. + * 2. A wrapper can ADD moves (pawn backward-1) without discarding + * the base forward moves. + * 3. Two wrapping presets compose — each sees the other's output and + * both of their contributions land in the final move set. + * 4. The engine's self-check filter still runs AFTER the transform — + * a transform can't legalize a move that leaves the king in check. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import "./index.js"; +import type { EntityId, Session } from "@paratype/rete"; +import { ChessEngine } from "../engine.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import type { LegalMove } from "../rules/types.js"; +import type { PieceColor, Square } from "../schema.js"; +import { rankOf } from "../coord.js"; +import { EMPTY_LAYOUT } from "../layouts/empty.js"; + +/** Find the piece currently on a given raw square; null if empty. */ +function pieceAt(engine: ChessEngine, sq: number): EntityId | null { + for (const f of engine.session.allFacts()) { + if (f.attr === "Position" && f.value === sq) return f.id; + } + return null; +} + +// ─── Register test-only presets (idempotent across test runs) ──────── + +const NOOP_ID = "__test__/transform-noop"; +const PAWN_BACKWARD_ID = "__test__/transform-pawn-backward"; +const SECOND_WRAP_ID = "__test__/transform-pawn-sideways"; +const EXTRA_MOVES_ID = "__test__/transform-extra-moves"; + +beforeAll(() => { + // A preset that wraps the generator but calls prev(session, pieceId) + // and returns its result unmodified. The whole point is to prove that + // wrapping doesn't itself change behaviour — composition is safe. + if (!PRESET_REGISTRY.get(NOOP_ID)) { + PRESET_REGISTRY.register({ + id: NOOP_ID, + name: "Test: noop transform", + description: "Calls prev and returns output unchanged.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + _pieceId, + prev, + ) => (session: Session, id: EntityId) => prev(session, id), + }); + } + + // A preset that wraps the pawn generator specifically and adds a + // backward-1 non-capture move. We don't touch non-pawn pieces: for + // those we return prev unchanged. + if (!PRESET_REGISTRY.get(PAWN_BACKWARD_ID)) { + PRESET_REGISTRY.register({ + id: PAWN_BACKWARD_ID, + name: "Test: transform adds pawn backward move", + description: + "Wraps the pawn generator to add a backward-1 non-capture move.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + const type = session.get(pieceId, "PieceType"); + if (type !== "pawn") return base; + const from = session.get(pieceId, "Position") as number | null; + const color = session.get(pieceId, "Color") as PieceColor | null; + if (from === null || color === null) return base; + const dir = color === "white" ? -8 : 8; + const targetRank = rankOf(from as Square) + (color === "white" ? -1 : 1); + if (targetRank < 0 || targetRank > 7) return base; + const to = (from + dir) as Square; + // Only add if the square is empty. + const occupied = engine.session + .allFacts() + .some((f) => f.attr === "Position" && f.value === to); + if (occupied) return base; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } + + // A second wrapping preset that ALSO targets pawns but adds a + // sideways-right move to an arbitrary square (for composition + // testing — doesn't need to be a sensible chess move). + if (!PRESET_REGISTRY.get(SECOND_WRAP_ID)) { + PRESET_REGISTRY.register({ + id: SECOND_WRAP_ID, + name: "Test: transform adds pawn sideways move", + description: + "Wraps the pawn generator (or whatever the previous wrap produced) " + + "to add a sideways-right pseudo move.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + const type = session.get(pieceId, "PieceType"); + if (type !== "pawn") return base; + const from = session.get(pieceId, "Position") as number | null; + if (from === null) return base; + // Avoid going off-board. + if ((from % 8) >= 7) return base; + const to = (from + 1) as Square; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } + + // A helper preset for the self-check test: a transform that adds a + // move that LEAVES THE KING IN CHECK. We need this to prove the + // self-check filter still runs AFTER the transformed generator. + if (!PRESET_REGISTRY.get(EXTRA_MOVES_ID)) { + PRESET_REGISTRY.register({ + id: EXTRA_MOVES_ID, + name: "Test: transform adds a self-check move", + description: + "Wraps the generator for a specific piece to add a move that " + + "would leave the mover's king in check. The engine's self-check " + + "filter MUST prune it.", + incompatibleWith: [], + requires: [], + transformMoveGenerator: ( + _engine, + pieceId, + prev, + ) => (session: Session, id: EntityId) => { + const base = prev(session, id); + // We target a specific piece via its entity id — the test sets + // up a pinned bishop and we only augment IT (other pieces pass + // through unchanged). + if (pieceId !== id) return base; + const from = session.get(pieceId, "Position") as number | null; + if (from === null) return base; + // Generate "move 1 square up"; in the pin-pos below this + // abandons the pin and exposes the king. + const to = (from + 8) as Square; + if (to > 63) return base; + return [ + ...base, + { pieceId: id, from: from as Square, to, isCapture: false }, + ]; + }, + }); + } +}); + +// ─── 1. Noop transform preserves legal moves ───────────────────────── + +describe("transformMoveGenerator — noop preset", () => { + it("legal moves are identical to without the preset", () => { + const without = new ChessEngine(); + const withNoop = new ChessEngine(); + withNoop.setActivePresets([ + { id: NOOP_ID, scope: "both", turnsRemaining: null }, + ]); + + // Normalize for deterministic comparison: project each move to a + // comparable tuple and sort. + const proj = (ms: LegalMove[]) => + ms + .map((m) => `${m.from}->${m.to}:${m.isCapture ? 1 : 0}:${m.promoteTo ?? ""}`) + .sort(); + + expect(proj(withNoop.getAllLegalMoves())).toEqual( + proj(without.getAllLegalMoves()), + ); + }); +}); + +// ─── 2. Wrapping preset adds backward move to pawn ─────────────────── + +describe("transformMoveGenerator — pawn backward wrapper", () => { + it("adds backward move, preserves base forward moves", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + ]); + + // Move the white a-pawn to a4 (square 24), leave a3 (square 16) + // empty by retracting the rook on a1 so the square in front of + // the rook doesn't actually matter — we only care that a3 is + // empty which it is when the pawn is on a4. + const whiteAPawn = pieceAt(engine, 8)!; + expect(whiteAPawn).not.toBeNull(); + engine.session.insert(whiteAPawn, "Position", 24 as Square); + + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + + // Base forward move: a4 -> a5 (24 -> 32), non-capture, still present. + expect(moves.some((m) => m.from === 24 && m.to === 32 && !m.isCapture)).toBe( + true, + ); + // Our wrapper's addition: a4 -> a3 (24 -> 16), non-capture, present. + expect(moves.some((m) => m.from === 24 && m.to === 16 && !m.isCapture)).toBe( + true, + ); + }); + + it("does not add backward move when target square is occupied", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + ]); + + // A-pawn stays on a2 (square 8); a1 (square 0) holds the rook. + // Backward target a1 is occupied -> not added. + const whiteAPawn = pieceAt(engine, 8)!; + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + expect(moves.some((m) => m.to === 0)).toBe(false); + }); +}); + +// ─── 3. Two wrapping presets compose ───────────────────────────────── + +describe("transformMoveGenerator — composition", () => { + it("second preset receives first preset's output; both contributions land", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: PAWN_BACKWARD_ID, scope: "both", turnsRemaining: null }, + { id: SECOND_WRAP_ID, scope: "both", turnsRemaining: null }, + ]); + + // Move white a-pawn to a4 (24). Backward target a3 (16) is empty. + // Sideways target b4 (25) is empty (b-pawn is on b2=9). + const whiteAPawn = pieceAt(engine, 8)!; + engine.session.insert(whiteAPawn, "Position", 24 as Square); + + const moves = engine + .getAllLegalMoves() + .filter((m) => m.pieceId === whiteAPawn); + + // Contribution from PAWN_BACKWARD_ID: a4 -> a3. + expect(moves.some((m) => m.to === 16)).toBe(true); + // Contribution from SECOND_WRAP_ID: a4 -> b4 (24 -> 25). + expect(moves.some((m) => m.to === 25)).toBe(true); + // Base forward move still present. + expect(moves.some((m) => m.to === 32)).toBe(true); + }); +}); + +// ─── 4. Self-check filter still applies after transform ────────────── + +describe("transformMoveGenerator — self-check filter runs downstream", () => { + it("transform cannot bypass self-check filter", () => { + // Set up a custom position where the white bishop is absolutely + // pinned by the black rook: W king on e1, white bishop on e2, + // black rook on e8. Moving the bishop off the e-file would expose + // the king. The EXTRA_MOVES_ID transform adds a bishop move 1 + // square up (e2 -> e3) which stays on the e-file (so that's + // actually legal in this setup) — so we target a DIFFERENT + // position: we place the bishop OFF the e-file and have it add + // a move that leaves the e-file. Easier: put the king and + // attacker on a rank, bishop as the pinned piece, and have the + // transform add a move that steps off the rank. + const engine = new ChessEngine({ layout: EMPTY_LAYOUT }); + + // White king on a1 (0); white bishop on a2 (8); black rook on a8 (56). + // Bishop is pinned along the a-file. Adding a move "up 1" = a3 + // (a8-a1 is the pin line); a3 is still on the a-file, so the pin + // remains intact. NO — "up 1" in our helper is +8 = from+8 so + // a2(8) + 8 = a3(16), still on file a, pin intact. + // + // Better: put the bishop on b2 (9), king on a1 (0), rook on h1 (7) + // along rank 1. The bishop is NOT pinned — king is on a1, not + // along b2's lines. We need a real pin. + // + // Cleanest pin: king on a1 (0), white piece on d1 (3), black rook + // on h1 (7) — that's a pin along rank 1. The transform adds + // "+8" = up one rank. The white piece moving up one rank steps + // off rank 1 and exposes the king to the rook. The self-check + // filter MUST prune it. + const whiteKingId = engine.session.nextId(); + engine.session.insert(whiteKingId, "PieceType", "king"); + engine.session.insert(whiteKingId, "Color", "white"); + engine.session.insert(whiteKingId, "Position", 0 as Square); + engine.session.insert(whiteKingId, "HasMoved", false); + + // Pinned white piece: use a knight because its base moves include + // natural off-rank moves anyway — those will all be pruned by the + // filter. The transform adds one more off-rank move; it must ALSO + // be pruned. + const whiteKnightId = engine.session.nextId(); + engine.session.insert(whiteKnightId, "PieceType", "knight"); + engine.session.insert(whiteKnightId, "Color", "white"); + engine.session.insert(whiteKnightId, "Position", 3 as Square); // d1 + engine.session.insert(whiteKnightId, "HasMoved", false); + + // Black rook on h1 — pinning the knight along rank 1. + const blackRookId = engine.session.nextId(); + engine.session.insert(blackRookId, "PieceType", "rook"); + engine.session.insert(blackRookId, "Color", "black"); + engine.session.insert(blackRookId, "Position", 7 as Square); // h1 + engine.session.insert(blackRookId, "HasMoved", false); + + // Black king somewhere safe so the board is well-formed. + const blackKingId = engine.session.nextId(); + engine.session.insert(blackKingId, "PieceType", "king"); + engine.session.insert(blackKingId, "Color", "black"); + engine.session.insert(blackKingId, "Position", 63 as Square); // h8 + engine.session.insert(blackKingId, "HasMoved", false); + + // Activate the transform preset. It adds a "+8 squares" move for + // the entity whose id it targets — we need to target the knight. + // But EXTRA_MOVES_ID fires for every piece its wrapper sees. That's + // fine: the effect on any NON-knight piece is also an off-rank + // move that either stays legal on its own merits or is pruned. The + // assertion we care about is that the move d1->d2 (knight off the + // pin) does NOT appear in legal moves. + engine.setActivePresets([ + { id: EXTRA_MOVES_ID, scope: "both", turnsRemaining: null }, + ]); + + const legal = engine.getAllLegalMoves(); + + // The transform added a pseudo-legal move d1 -> d2 (3 -> 11) for + // the knight. The self-check filter MUST prune it because the + // knight is absolutely pinned. If the self-check filter were + // bypassed, this assertion would fail. + const knightOffRank = legal.find( + (m) => m.pieceId === whiteKnightId && m.to === 11, + ); + expect(knightOffRank).toBeUndefined(); + + // Sanity: the knight has NO legal moves at all (every knight jump + // off rank 1 would expose the king). If the self-check filter were + // bypassed we'd see moves here. + const knightMoves = legal.filter((m) => m.pieceId === whiteKnightId); + expect(knightMoves).toEqual([]); + }); +}); From 72ca8f4bd857daedb6544f2318d5462cbc9c413a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:11:16 -0600 Subject: [PATCH 05/88] feat(engine): modifier registry pattern --- packages/chess/src/modifiers/index.ts | 28 ++++++ packages/chess/src/modifiers/registry.test.ts | 87 +++++++++++++++++++ packages/chess/src/modifiers/registry.ts | 66 ++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 packages/chess/src/modifiers/index.ts create mode 100644 packages/chess/src/modifiers/registry.test.ts create mode 100644 packages/chess/src/modifiers/registry.ts diff --git a/packages/chess/src/modifiers/index.ts b/packages/chess/src/modifiers/index.ts new file mode 100644 index 0000000..013a863 --- /dev/null +++ b/packages/chess/src/modifiers/index.ts @@ -0,0 +1,28 @@ +/** + * Barrel for the piece modifier profile system. + * + * Importing this module ensures every T1 modifier descriptor is + * registered in `MODIFIER_REGISTRY` via side-effect imports (ADR-8). + * Descriptor imports are added by T6-T11 as each modifier is + * implemented; until then the registry starts empty. + */ + +// Re-export registry and types for consumers. +export { MODIFIER_REGISTRY } from "./registry.js"; +export type { + ModifierKindId, + Direction, + TypeModifier, + InstanceModifier, + ModifierProfile, + ModifierDescriptor, +} from "./types.js"; +export { typeModifier, instanceModifier } from "./types.js"; + +// Descriptor side-effect imports (added by T6-T11): +// import "./descriptors/hp-bonus.js"; +// import "./descriptors/range-bonus.js"; +// import "./descriptors/direction-additions.js"; +// import "./descriptors/capture-flags.js"; +// import "./descriptors/promotion-override.js"; +// import "./descriptors/damage-resistance.js"; diff --git a/packages/chess/src/modifiers/registry.test.ts b/packages/chess/src/modifiers/registry.test.ts new file mode 100644 index 0000000..eaeff98 --- /dev/null +++ b/packages/chess/src/modifiers/registry.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for MODIFIER_REGISTRY. + * + * Deliberately does NOT import `./index.js` — that would pull in every + * T1 descriptor via side-effects and pre-populate the registry, which + * would break the "starts empty" assertion. Once descriptors land + * (T6-T11), those tests live in their own files. + * + * IMPORTANT: these tests mutate the shared `MODIFIER_REGISTRY` + * singleton (there's no reset/clear API by design — the real registry + * is populated once at module load time). To stay isolated we use + * fresh, unique ids inside the test and only assert properties of + * descriptors we own. + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "./registry.js"; +import type { ModifierDescriptor, ModifierKindId } from "./types.js"; + +/** + * Build a minimal mock descriptor. We cast the id through string + * because the test descriptors use synthetic ids ("test-*") to avoid + * colliding with real ones that T6-T11 will register. + */ +function mockDescriptor(id: string): ModifierDescriptor { + return { + id: id as ModifierKindId, + attrName: "Hp", + label: `Mock ${id}`, + valueSchema: z.unknown(), + stackingRule: "additive", + apply: (_session: Session, _pieceId: EntityId, _value: unknown) => { + // no-op + }, + describe: (value) => `mock=${String(value)}`, + uiForm: "number", + }; +} + +describe("MODIFIER_REGISTRY", () => { + it("starts empty (no T1 descriptors registered yet)", () => { + // At the time this test file is loaded, no descriptor side-effects + // have run (we don't import ./index.js), so the registry should be + // empty. If this ever fires, someone registered a descriptor at + // module scope without a corresponding import guard — investigate + // before relaxing. + expect(MODIFIER_REGISTRY.list()).toEqual([]); + }); + + it("register(descriptor) makes it retrievable via get(id)", () => { + const desc = mockDescriptor("test-get"); + MODIFIER_REGISTRY.register(desc); + expect(MODIFIER_REGISTRY.get("test-get" as ModifierKindId)).toBe(desc); + }); + + it("has(id) returns true after registration, false before", () => { + const id = "test-has" as ModifierKindId; + expect(MODIFIER_REGISTRY.has(id)).toBe(false); + MODIFIER_REGISTRY.register(mockDescriptor("test-has")); + expect(MODIFIER_REGISTRY.has(id)).toBe(true); + }); + + it("list() returns every registered descriptor", () => { + const a = mockDescriptor("test-list-a"); + const b = mockDescriptor("test-list-b"); + MODIFIER_REGISTRY.register(a); + MODIFIER_REGISTRY.register(b); + const all = MODIFIER_REGISTRY.list(); + expect(all).toContain(a); + expect(all).toContain(b); + }); + + it("register() with duplicate id throws an error mentioning the id", () => { + const id = "test-dup"; + MODIFIER_REGISTRY.register(mockDescriptor(id)); + expect(() => MODIFIER_REGISTRY.register(mockDescriptor(id))).toThrow( + /test-dup/, + ); + }); + + it("get() returns undefined for unknown ids", () => { + expect( + MODIFIER_REGISTRY.get("test-never-registered" as ModifierKindId), + ).toBeUndefined(); + }); +}); diff --git a/packages/chess/src/modifiers/registry.ts b/packages/chess/src/modifiers/registry.ts new file mode 100644 index 0000000..b7aeeb1 --- /dev/null +++ b/packages/chess/src/modifiers/registry.ts @@ -0,0 +1,66 @@ +/** + * Modifier descriptor registry. + * + * Mirrors `LAYOUT_REGISTRY`: the six T1 modifier categories register + * themselves via side-effect imports from their own module files, and + * `./index.ts` is the barrel that imports every descriptor so that + * consumers get them all registered by importing anywhere from + * `./modifiers`. + * + * Duplicate-id registration throws — this is the signal that two + * modules are trying to claim the same `ModifierKindId`, which would + * silently overwrite in a Map. Throwing surfaces the collision at load + * time rather than at runtime when a modifier would mysteriously + * "disappear" because a later import clobbered the earlier descriptor. + * + * Descriptors are read-only once registered; the registry exposes no + * mutation paths. The set of modifier kinds is fixed at module-load + * time (ADR-8) — there's no runtime registration of new modifier + * categories from userland. + */ +import type { ModifierDescriptor, ModifierKindId } from "./types.js"; + +class ModifierRegistryClass { + readonly #byId = new Map(); + + /** + * Register a descriptor under its `id`. Throws if the id is already + * taken — defensive check because silently overwriting would create + * confusing debugging ("which of my two hp-bonus descriptors won?" + * races on module import order). + */ + register(descriptor: ModifierDescriptor): void { + if (this.#byId.has(descriptor.id)) { + throw new Error( + `ModifierRegistry: duplicate modifier id "${descriptor.id}". ` + + `Each modifier descriptor must have a unique id.`, + ); + } + this.#byId.set(descriptor.id, descriptor); + } + + /** + * Look up a descriptor by id. Returns undefined for unknown ids — + * the caller decides whether that's an error (validator rejecting a + * profile referencing an unknown modifier kind) or a benign miss. + */ + get(id: ModifierKindId): ModifierDescriptor | undefined { + return this.#byId.get(id); + } + + /** + * All registered descriptors, in registration (= import) order. UI + * code iterating descriptors to render form rows relies on this + * being a stable order. + */ + list(): readonly ModifierDescriptor[] { + return [...this.#byId.values()]; + } + + /** True if a descriptor with the given id is registered. */ + has(id: ModifierKindId): boolean { + return this.#byId.has(id); + } +} + +export const MODIFIER_REGISTRY = new ModifierRegistryClass(); From e58bb0260565914478c452f74680fe70abeb6edc Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:17:41 -0600 Subject: [PATCH 06/88] feat(engine): ModifierProfile Zod schema + roundtrip tests --- .../modifiers/descriptors/capture-flags.ts | 39 +++ packages/chess/src/modifiers/schema.test.ts | 283 ++++++++++++++++++ packages/chess/src/modifiers/schema.ts | 90 ++++++ 3 files changed, 412 insertions(+) create mode 100644 packages/chess/src/modifiers/descriptors/capture-flags.ts create mode 100644 packages/chess/src/modifiers/schema.test.ts create mode 100644 packages/chess/src/modifiers/schema.ts diff --git a/packages/chess/src/modifiers/descriptors/capture-flags.ts b/packages/chess/src/modifiers/descriptors/capture-flags.ts new file mode 100644 index 0000000..1449306 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/capture-flags.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; +import { CaptureFlag } from "../../schema.js"; + +// Bitflag validation: valid values are any combination of the 3 flags +const MAX_FLAGS = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT; +const schema = z.number().int().min(0).max(MAX_FLAGS); +type Value = z.infer; + +function describeFlags(value: Value): string { + const parts: string[] = []; + if (value & CaptureFlag.CAN_CAPTURE_OWN) parts.push("can-capture-own"); + if (value & CaptureFlag.CANNOT_BE_CAPTURED) parts.push("cannot-be-captured"); + if (value & CaptureFlag.EN_PASSANT) parts.push("en-passant"); + return parts.length > 0 ? parts.join(", ") : "no flags"; +} + +export const CAPTURE_FLAGS_DESCRIPTOR: ModifierDescriptor = { + id: "capture-flags", + attrName: "CaptureFlags", + label: "Capture Behavior", + valueSchema: schema, + stackingRule: "union", // bitwise OR for stacking + uiForm: "capture-flags", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "CaptureFlags", effectiveValue); + }, + describe: describeFlags, +}; + +MODIFIER_REGISTRY.register(CAPTURE_FLAGS_DESCRIPTOR); + +/** Check if a piece has a specific capture flag set. */ +export function hasCaptureFlag(session: Session, pieceId: EntityId, flag: number): boolean { + const flags = session.get(pieceId, "CaptureFlags"); + return typeof flags === "number" && (flags & flag) !== 0; +} diff --git a/packages/chess/src/modifiers/schema.test.ts b/packages/chess/src/modifiers/schema.test.ts new file mode 100644 index 0000000..43d94ea --- /dev/null +++ b/packages/chess/src/modifiers/schema.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { + TypeModifierSchema, + InstanceModifierSchema, + ModifierProfileSchema, + parseModifierProfile, + serializeModifierProfile, +} from "./schema.js"; +import type { ModifierProfile } from "./types.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_PROFILE: ModifierProfile = { + id: "profile-001", + name: "Knight Buff", + description: "Gives all white knights +2 HP", + layoutId: "classic", + perType: [ + { kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }, + { kind: "range-bonus", pieceType: "rook", color: "both", value: 1 }, + ], + perInstance: [ + { kind: "hp-bonus", square: "b1", value: 5 }, + ], + version: 1, + source: "premade", +}; + +const VALID_PROFILE_NO_LAYOUT: ModifierProfile = { + id: "profile-002", + name: "Custom Pawns", + description: "Custom pawn modifiers", + perType: [], + perInstance: [], + version: 1, + source: "custom", +}; + +// --------------------------------------------------------------------------- +// TypeModifierSchema +// --------------------------------------------------------------------------- + +describe("TypeModifierSchema", () => { + it("parses a valid hp-bonus TypeModifier", () => { + const result = TypeModifierSchema.parse({ + kind: "hp-bonus", + pieceType: "knight", + color: "white", + value: 3, + }); + expect(result.kind).toBe("hp-bonus"); + expect(result.value).toBe(3); + }); + + it("parses direction-additions with 'both' color", () => { + const result = TypeModifierSchema.parse({ + kind: "direction-additions", + pieceType: "pawn", + color: "both", + value: ["forward", "backward"], + }); + expect(result.color).toBe("both"); + }); + + it("parses capture-flags TypeModifier", () => { + const result = TypeModifierSchema.parse({ + kind: "capture-flags", + pieceType: "queen", + color: "black", + value: 3, + }); + expect(result.kind).toBe("capture-flags"); + }); + + it("rejects unknown modifier kind", () => { + expect(() => + TypeModifierSchema.parse({ + kind: "nonexistent", + pieceType: "knight", + color: "white", + value: 1, + }) + ).toThrow(z.ZodError); + }); + + it("rejects unknown piece type", () => { + expect(() => + TypeModifierSchema.parse({ + kind: "hp-bonus", + pieceType: "dragon", + color: "white", + value: 1, + }) + ).toThrow(z.ZodError); + }); +}); + +// --------------------------------------------------------------------------- +// InstanceModifierSchema +// --------------------------------------------------------------------------- + +describe("InstanceModifierSchema", () => { + it("parses a valid instance modifier with square 'b1'", () => { + const result = InstanceModifierSchema.parse({ + kind: "hp-bonus", + square: "b1", + value: 5, + }); + expect(result.square).toBe("b1"); + }); + + it("parses a valid instance modifier at 'h8'", () => { + const result = InstanceModifierSchema.parse({ + kind: "range-bonus", + square: "h8", + value: 2, + }); + expect(result.square).toBe("h8"); + }); + + it("rejects invalid square 'z9'", () => { + expect(() => + InstanceModifierSchema.parse({ + kind: "hp-bonus", + square: "z9", + value: 1, + }) + ).toThrow(z.ZodError); + }); + + it("rejects invalid square 'a9' (rank out of range)", () => { + expect(() => + InstanceModifierSchema.parse({ + kind: "hp-bonus", + square: "a9", + value: 1, + }) + ).toThrow(z.ZodError); + }); + + it("rejects invalid square 'i1' (file out of range)", () => { + expect(() => + InstanceModifierSchema.parse({ + kind: "hp-bonus", + square: "i1", + value: 1, + }) + ).toThrow(z.ZodError); + }); +}); + +// --------------------------------------------------------------------------- +// ModifierProfileSchema — valid cases +// --------------------------------------------------------------------------- + +describe("ModifierProfileSchema — valid", () => { + it("parses a full valid profile", () => { + const result = ModifierProfileSchema.parse(VALID_PROFILE); + expect(result.id).toBe("profile-001"); + expect(result.version).toBe(1); + expect(result.source).toBe("premade"); + expect(result.perType).toHaveLength(2); + expect(result.perInstance).toHaveLength(1); + }); + + it("parses a profile without layoutId (optional field)", () => { + const result = ModifierProfileSchema.parse(VALID_PROFILE_NO_LAYOUT); + expect(result.layoutId).toBeUndefined(); + }); + + it("parses a profile with empty perType and perInstance arrays", () => { + const result = ModifierProfileSchema.parse({ + id: "empty-profile", + name: "Empty", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", + }); + expect(result.perType).toHaveLength(0); + expect(result.perInstance).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// ModifierProfileSchema — invalid cases +// --------------------------------------------------------------------------- + +describe("ModifierProfileSchema — invalid", () => { + it("rejects profile with version=2", () => { + expect(() => + ModifierProfileSchema.parse({ ...VALID_PROFILE, version: 2 }) + ).toThrow(z.ZodError); + }); + + it("rejects profile with missing 'name' field", () => { + const { name: _name, ...rest } = VALID_PROFILE; // eslint-disable-line @typescript-eslint/no-unused-vars + expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError); + }); + + it("rejects profile with source='admin'", () => { + expect(() => + ModifierProfileSchema.parse({ ...VALID_PROFILE, source: "admin" }) + ).toThrow(z.ZodError); + }); + + it("rejects profile with empty id string", () => { + expect(() => + ModifierProfileSchema.parse({ ...VALID_PROFILE, id: "" }) + ).toThrow(z.ZodError); + }); + + it("rejects profile with invalid perInstance square", () => { + expect(() => + ModifierProfileSchema.parse({ + ...VALID_PROFILE, + perInstance: [{ kind: "hp-bonus", square: "z9", value: 1 }], + }) + ).toThrow(z.ZodError); + }); +}); + +// --------------------------------------------------------------------------- +// parseModifierProfile / serializeModifierProfile +// --------------------------------------------------------------------------- + +describe("parseModifierProfile", () => { + it("returns a typed ModifierProfile for valid input", () => { + const result = parseModifierProfile(VALID_PROFILE); + expect(result.id).toBe("profile-001"); + }); + + it("throws ZodError for invalid input", () => { + expect(() => parseModifierProfile({ invalid: true })).toThrow(z.ZodError); + }); +}); + +// --------------------------------------------------------------------------- +// Roundtrip tests +// --------------------------------------------------------------------------- + +describe("roundtrip", () => { + it("full profile roundtrips through JSON.parse(JSON.stringify(...))", () => { + const serialized = JSON.parse(JSON.stringify(VALID_PROFILE)); + const result = parseModifierProfile(serialized); + expect(result).toEqual(VALID_PROFILE); + }); + + it("profile without layoutId roundtrips losslessly", () => { + const serialized = JSON.parse(JSON.stringify(VALID_PROFILE_NO_LAYOUT)); + const result = parseModifierProfile(serialized); + expect(result).toEqual(VALID_PROFILE_NO_LAYOUT); + }); + + it("serializeModifierProfile output can be re-parsed", () => { + const serialized = serializeModifierProfile(VALID_PROFILE); + const reparsed = parseModifierProfile(JSON.parse(JSON.stringify(serialized))); + expect(reparsed).toEqual(VALID_PROFILE); + }); + + it("profile with complex value types roundtrips", () => { + const profile: ModifierProfile = { + id: "complex-001", + name: "Complex Profile", + description: "Has various value types", + perType: [ + { kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward", "diagonal-fl"] }, + { kind: "promotion-override", pieceType: "pawn", color: "black", value: "queen" }, + ], + perInstance: [ + { kind: "capture-flags", square: "e4", value: 3 }, + ], + version: 1, + source: "custom", + }; + const result = parseModifierProfile(JSON.parse(JSON.stringify(profile))); + expect(result).toEqual(profile); + }); +}); diff --git a/packages/chess/src/modifiers/schema.ts b/packages/chess/src/modifiers/schema.ts new file mode 100644 index 0000000..5ace534 --- /dev/null +++ b/packages/chess/src/modifiers/schema.ts @@ -0,0 +1,90 @@ +/** + * Zod schemas for ModifierProfile serialization and validation. + * + * The `value` field on TypeModifier and InstanceModifier is intentionally + * `z.unknown()` — per-kind value validation happens at descriptor + * registration time, not at profile-parse time. This keeps the schema + * forwards-compatible with future modifier kinds. + */ +import { z } from "zod"; +import type { ModifierProfile } from "./types.js"; + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +const ModifierKindIdSchema = z.enum([ + "hp-bonus", + "range-bonus", + "direction-additions", + "capture-flags", + "promotion-override", + "damage-resistance", +]); + +const PieceTypeSchema = z.enum([ + "pawn", + "knight", + "bishop", + "rook", + "queen", + "king", +]); + +// PieceColor extended with "both" for TypeModifier.color +const PieceColorExtSchema = z.enum(["white", "black", "both"]); + +/** Algebraic notation square: "a1".."h8" */ +const SquareStringSchema = z + .string() + .regex(/^[a-h][1-8]$/, "Must be algebraic notation (a1-h8)"); + +// --------------------------------------------------------------------------- +// TypeModifier +// --------------------------------------------------------------------------- + +export const TypeModifierSchema = z.object({ + kind: ModifierKindIdSchema, + pieceType: PieceTypeSchema, + color: PieceColorExtSchema, + value: z.unknown(), +}); + +// --------------------------------------------------------------------------- +// InstanceModifier +// --------------------------------------------------------------------------- + +export const InstanceModifierSchema = z.object({ + kind: ModifierKindIdSchema, + square: SquareStringSchema, + value: z.unknown(), +}); + +// --------------------------------------------------------------------------- +// ModifierProfile +// --------------------------------------------------------------------------- + +export const ModifierProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + layoutId: z.string().optional(), + perType: z.array(TypeModifierSchema), + perInstance: z.array(InstanceModifierSchema), + version: z.literal(1), + source: z.enum(["premade", "custom"]), +}); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Parse an unknown value as a ModifierProfile. Throws ZodError on failure. */ +export function parseModifierProfile(raw: unknown): ModifierProfile { + return ModifierProfileSchema.parse(raw) as unknown as ModifierProfile; +} + +/** Serialize a ModifierProfile to a JSON-safe plain object. */ +export function serializeModifierProfile(profile: ModifierProfile): unknown { + return ModifierProfileSchema.parse(profile); +} From 761adfb699ebfaa763e90b1b0c4b373ba29fef29 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:20:24 -0600 Subject: [PATCH 07/88] feat(engine): range-bonus modifier descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RANGE_BONUS_DESCRIPTOR that seeds RangeBonus fact on piece entities, and update getSlidingMoves to respect it via a per-ray maxSteps cap. Clamped to [0,7] in apply(); standard boards are unaffected (RangeBonus=0 → maxSteps=7, identical to prior behaviour). Foundation for range-limit presets. --- .../modifiers/descriptors/range-bonus.test.ts | 67 +++++++++++++++++++ .../src/modifiers/descriptors/range-bonus.ts | 25 +++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/chess/src/modifiers/descriptors/range-bonus.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/range-bonus.ts diff --git a/packages/chess/src/modifiers/descriptors/range-bonus.test.ts b/packages/chess/src/modifiers/descriptors/range-bonus.test.ts new file mode 100644 index 0000000..bd6e69d --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/range-bonus.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { RANGE_BONUS_DESCRIPTOR } from "./range-bonus.js"; +import { MODIFIER_REGISTRY } from "../registry.js"; + +function setupSession(): Session { + return new Session({ autoFire: false }); +} + +describe("RANGE_BONUS_DESCRIPTOR", () => { + it("registers in MODIFIER_REGISTRY with id 'range-bonus'", () => { + expect(MODIFIER_REGISTRY.has("range-bonus")).toBe(true); + expect(MODIFIER_REGISTRY.get("range-bonus")).toBe(RANGE_BONUS_DESCRIPTOR); + }); + + it("has correct metadata", () => { + expect(RANGE_BONUS_DESCRIPTOR.id).toBe("range-bonus"); + expect(RANGE_BONUS_DESCRIPTOR.attrName).toBe("RangeBonus"); + expect(RANGE_BONUS_DESCRIPTOR.stackingRule).toBe("additive"); + expect(RANGE_BONUS_DESCRIPTOR.uiForm).toBe("number"); + }); + + describe("describe()", () => { + it("returns 'Range +N' for non-negative values", () => { + expect(RANGE_BONUS_DESCRIPTOR.describe(2)).toBe("Range +2"); + expect(RANGE_BONUS_DESCRIPTOR.describe(0)).toBe("Range +0"); + expect(RANGE_BONUS_DESCRIPTOR.describe(7)).toBe("Range +7"); + }); + + it("returns 'Range -N' for negative values", () => { + expect(RANGE_BONUS_DESCRIPTOR.describe(-1)).toBe("Range -1"); + expect(RANGE_BONUS_DESCRIPTOR.describe(-7)).toBe("Range -7"); + }); + }); + + describe("apply()", () => { + it("inserts RangeBonus fact on the piece entity", () => { + const session = setupSession(); + RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 3); + expect(session.get(1 as EntityId, "RangeBonus")).toBe(3); + }); + + it("clamps effectiveValue above RANGE_MAX (7) down to 7", () => { + const session = setupSession(); + RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 10 as number); + expect(session.get(1 as EntityId, "RangeBonus")).toBe(7); + }); + + it("clamps negative effectiveValue to 0", () => { + const session = setupSession(); + RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, -5 as number); + expect(session.get(1 as EntityId, "RangeBonus")).toBe(0); + }); + + it("applies exactly RANGE_MAX (7) without clamping", () => { + const session = setupSession(); + RANGE_BONUS_DESCRIPTOR.apply(session, 2 as EntityId, 7); + expect(session.get(2 as EntityId, "RangeBonus")).toBe(7); + }); + + it("applies zero without clamping", () => { + const session = setupSession(); + RANGE_BONUS_DESCRIPTOR.apply(session, 3 as EntityId, 0); + expect(session.get(3 as EntityId, "RangeBonus")).toBe(0); + }); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/range-bonus.ts b/packages/chess/src/modifiers/descriptors/range-bonus.ts new file mode 100644 index 0000000..d56bfdc --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/range-bonus.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +const RANGE_MAX = 7; +const schema = z.number().int().min(-7).max(7); +type Value = z.infer; + +export const RANGE_BONUS_DESCRIPTOR: ModifierDescriptor = { + id: "range-bonus", + attrName: "RangeBonus", + label: "Range Bonus", + valueSchema: schema, + stackingRule: "additive", + uiForm: "number", + apply(session, pieceId, effectiveValue) { + const clamped = Math.max(0, Math.min(RANGE_MAX, effectiveValue)); + session.insert(pieceId, "RangeBonus", clamped); + }, + describe(value) { + return `Range ${value >= 0 ? "+" : ""}${value}`; + }, +}; + +MODIFIER_REGISTRY.register(RANGE_BONUS_DESCRIPTOR); From ff8b8d9bb036c6b8468b0f14117b8c67eddd54be Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:21:07 -0600 Subject: [PATCH 08/88] feat(engine): capture-flags modifier descriptor --- .../descriptors/capture-flags.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/chess/src/modifiers/descriptors/capture-flags.test.ts diff --git a/packages/chess/src/modifiers/descriptors/capture-flags.test.ts b/packages/chess/src/modifiers/descriptors/capture-flags.test.ts new file mode 100644 index 0000000..c2a883c --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/capture-flags.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import { CaptureFlag } from "../../schema.js"; +import "./capture-flags.js"; +import { CAPTURE_FLAGS_DESCRIPTOR, hasCaptureFlag } from "./capture-flags.js"; + +describe("capture-flags descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'capture-flags'", () => { + expect(MODIFIER_REGISTRY.has("capture-flags")).toBe(true); + expect(MODIFIER_REGISTRY.get("capture-flags")).toBe(CAPTURE_FLAGS_DESCRIPTOR); + }); +}); + +describe("capture-flags descriptor — apply()", () => { + it("seeds CaptureFlags fact on piece entity in session", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED); + + expect(session.contains(pieceId, "CaptureFlags")).toBe(true); + expect(session.get(pieceId, "CaptureFlags")).toBe(CaptureFlag.CANNOT_BE_CAPTURED); + }); +}); + +describe("capture-flags descriptor — describe()", () => { + it("returns 'no flags' for 0", () => { + expect(CAPTURE_FLAGS_DESCRIPTOR.describe(0)).toBe("no flags"); + }); + + it("describes combined CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED flags", () => { + const combined = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED; + expect(CAPTURE_FLAGS_DESCRIPTOR.describe(combined)).toBe("can-capture-own, cannot-be-captured"); + }); + + it("describes all three flags", () => { + const allFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT; + expect(CAPTURE_FLAGS_DESCRIPTOR.describe(allFlags)).toBe( + "can-capture-own, cannot-be-captured, en-passant", + ); + }); + + it("describes a single flag", () => { + expect(CAPTURE_FLAGS_DESCRIPTOR.describe(CaptureFlag.EN_PASSANT)).toBe("en-passant"); + }); +}); + +describe("capture-flags descriptor — hasCaptureFlag()", () => { + it("returns true when the flag is set", () => { + const session = new Session(); + const pieceId = session.nextId(); + CAPTURE_FLAGS_DESCRIPTOR.apply( + session, + pieceId, + CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED, + ); + + expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(true); + expect(hasCaptureFlag(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN)).toBe(true); + }); + + it("returns false when the flag is not set", () => { + const session = new Session(); + const pieceId = session.nextId(); + CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN); + + expect(hasCaptureFlag(session, pieceId, CaptureFlag.EN_PASSANT)).toBe(false); + }); + + it("returns false when CaptureFlags fact is absent", () => { + const session = new Session(); + const pieceId = session.nextId(); + + expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(false); + }); +}); + +describe("capture-flags descriptor — valueSchema", () => { + const schema = CAPTURE_FLAGS_DESCRIPTOR.valueSchema; + + it("accepts 0 (no flags)", () => { + expect(() => schema.parse(0)).not.toThrow(); + }); + + it("accepts MAX_FLAGS (all flags OR'd together = 7)", () => { + const maxFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT; + expect(() => schema.parse(maxFlags)).not.toThrow(); + }); + + it("rejects -1 (below min)", () => { + expect(() => schema.parse(-1)).toThrow(); + }); + + it("rejects 8 (above MAX_FLAGS = 7)", () => { + expect(() => schema.parse(8)).toThrow(); + }); + + it("rejects non-integer values", () => { + expect(() => schema.parse(1.5)).toThrow(); + }); +}); From 99a091509d7923655c39d1c90144e6929535cff2 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:22:31 -0600 Subject: [PATCH 09/88] feat(engine): damage-resistance modifier descriptor - Add DAMAGE_RESISTANCE_DESCRIPTOR with multiplicative stacking rule - Export applyResistance() and stackResistances() math helpers - 14 tests covering registry, apply, describe, applyResistance, stackResistances - Fix eslint varsIgnorePattern to allow _-prefixed destructuring vars - Remove now-redundant eslint-disable comment in schema.test.ts --- eslint.config.js | 2 +- .../descriptors/damage-resistance.test.ts | 86 +++++++++++++++++++ .../descriptors/damage-resistance.ts | 49 +++++++++++ packages/chess/src/modifiers/schema.test.ts | 2 +- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/chess/src/modifiers/descriptors/damage-resistance.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/damage-resistance.ts diff --git a/eslint.config.js b/eslint.config.js index 30bd4e4..515b2b7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ export default tseslint.config( { rules: { "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], }, }, // Engine RHS purity: ban impure globals in rete/src diff --git a/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts b/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts new file mode 100644 index 0000000..d20731e --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./damage-resistance.js"; +import { + DAMAGE_RESISTANCE_DESCRIPTOR, + applyResistance, + stackResistances, +} from "./damage-resistance.js"; + +describe("damage-resistance descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'damage-resistance'", () => { + expect(MODIFIER_REGISTRY.has("damage-resistance")).toBe(true); + expect(MODIFIER_REGISTRY.get("damage-resistance")).toBe(DAMAGE_RESISTANCE_DESCRIPTOR); + }); +}); + +describe("damage-resistance descriptor — apply()", () => { + it("seeds DamageResistance fact on piece entity in session", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + DAMAGE_RESISTANCE_DESCRIPTOR.apply(session, pieceId, 0.5); + + expect(session.contains(pieceId, "DamageResistance")).toBe(true); + expect(session.get(pieceId, "DamageResistance")).toBe(0.5); + }); +}); + +describe("damage-resistance descriptor — describe()", () => { + it("formats 0.5 as '50% damage resistance'", () => { + expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0.5)).toBe("50% damage resistance"); + }); + + it("formats 0 as '0% damage resistance'", () => { + expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0)).toBe("0% damage resistance"); + }); + + it("formats 1 as '100% damage resistance'", () => { + expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(1)).toBe("100% damage resistance"); + }); +}); + +describe("applyResistance()", () => { + it("halves damage at 0.5 resistance", () => { + expect(applyResistance(4, 0.5)).toBe(2); + }); + + it("passes full damage through at 0 resistance", () => { + expect(applyResistance(4, 0)).toBe(4); + }); + + it("reduces damage to 0 at 1 (full immunity)", () => { + expect(applyResistance(4, 1)).toBe(0); + }); + + it("clamps negative results to 0", () => { + // Resistance > 1 is schema-invalid, but applyResistance is defensive + expect(applyResistance(4, 1.5)).toBe(0); + }); +}); + +describe("stackResistances()", () => { + it("returns 0 for an empty array", () => { + expect(stackResistances([])).toBe(0); + }); + + it("stacks two 0.5 resistances multiplicatively to 0.75", () => { + // 1 - (0.5 * 0.5) = 0.75 + expect(stackResistances([0.5, 0.5])).toBe(0.75); + }); + + it("stacks three 0.5 resistances to 0.875", () => { + // 1 - (0.5 * 0.5 * 0.5) = 0.875 + expect(stackResistances([0.5, 0.5, 0.5])).toBe(0.875); + }); + + it("returns 0 for a single 0 resistance", () => { + expect(stackResistances([0])).toBe(0); + }); + + it("returns 1 (clamped) for a single full immunity", () => { + expect(stackResistances([1])).toBe(1); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/damage-resistance.ts b/packages/chess/src/modifiers/descriptors/damage-resistance.ts new file mode 100644 index 0000000..28ef80b --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/damage-resistance.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +// Resistance: 0 = no resistance, 1 = immune (100% damage reduction) +const schema = z.number().min(0).max(1); +type Value = z.infer; + +export const DAMAGE_RESISTANCE_DESCRIPTOR: ModifierDescriptor = { + id: "damage-resistance", + attrName: "DamageResistance", + label: "Damage Resistance", + valueSchema: schema, + stackingRule: "multiplicative", + uiForm: "percentage", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "DamageResistance", effectiveValue); + }, + describe(value: Value): string { + return `${Math.round(value * 100)}% damage resistance`; + }, +}; + +MODIFIER_REGISTRY.register(DAMAGE_RESISTANCE_DESCRIPTOR); + +/** + * Apply damage resistance to an incoming damage amount. + * Returns the effective damage after applying resistance. + * Result is always ≥ 0. + * + * Formula (from ADR-4 stacking): effective resistance already stacked + * multiplicatively before being seeded in DamageResistance fact. + * So: effectiveDamage = amount * (1 - resistance), clamped to [0, ∞). + */ +export function applyResistance(amount: number, resistance: number): number { + return Math.max(0, amount * (1 - resistance)); +} + +/** + * Compute stacked resistance from multiple sources using multiplicative formula. + * ADR-4: effectiveResistance = 1 - ∏(1 - r_i) + * Result clamped to [0, 1]. + */ +export function stackResistances(resistances: readonly number[]): number { + if (resistances.length === 0) return 0; + const product = resistances.reduce((acc, r) => acc * (1 - r), 1); + return Math.max(0, Math.min(1, 1 - product)); +} diff --git a/packages/chess/src/modifiers/schema.test.ts b/packages/chess/src/modifiers/schema.test.ts index 43d94ea..05f2b96 100644 --- a/packages/chess/src/modifiers/schema.test.ts +++ b/packages/chess/src/modifiers/schema.test.ts @@ -198,7 +198,7 @@ describe("ModifierProfileSchema — invalid", () => { }); it("rejects profile with missing 'name' field", () => { - const { name: _name, ...rest } = VALID_PROFILE; // eslint-disable-line @typescript-eslint/no-unused-vars + const { name: _name, ...rest } = VALID_PROFILE; expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError); }); From ef93eb610139634903df5facfa46e6c79c202dc5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:22:45 -0600 Subject: [PATCH 10/88] feat(engine): promotion-override modifier descriptor - Add PromotionOverride descriptor (queen/rook/bishop/knight/disabled) - getPromotionMoves: returns [] when override='disabled'; single-type moves when override is a piece type - applyPromotion: uses override value instead of promoteTo arg when set - 24 tests: descriptor registry, describe(), apply(), valueSchema, getPromotionMoves integration (6 scenarios), applyPromotion integration --- .../descriptors/promotion-override.test.ts | 245 ++++++++++++++++++ .../descriptors/promotion-override.ts | 27 ++ packages/chess/src/rules/promotion.ts | 32 ++- 3 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 packages/chess/src/modifiers/descriptors/promotion-override.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/promotion-override.ts diff --git a/packages/chess/src/modifiers/descriptors/promotion-override.test.ts b/packages/chess/src/modifiers/descriptors/promotion-override.test.ts new file mode 100644 index 0000000..b2819a0 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/promotion-override.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./promotion-override.js"; +import { PROMOTION_OVERRIDE_DESCRIPTOR } from "./promotion-override.js"; +import type { PieceType, PieceColor, Square } from "../../schema.js"; +import { getPromotionMoves, applyPromotion } from "../../rules/promotion.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function setupSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = id as EntityId; + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'promotion-override'", () => { + expect(MODIFIER_REGISTRY.has("promotion-override")).toBe(true); + expect(MODIFIER_REGISTRY.get("promotion-override")).toBe(PROMOTION_OVERRIDE_DESCRIPTOR); + }); + + it("has the correct id, attrName, and uiForm", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.id).toBe("promotion-override"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.attrName).toBe("PromotionOverride"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.uiForm).toBe("promotion-target"); + expect(PROMOTION_OVERRIDE_DESCRIPTOR.stackingRule).toBe("priority-wins"); + }); +}); + +// ─── describe() ────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — describe()", () => { + it("returns 'Cannot promote' for 'disabled'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("disabled")).toBe("Cannot promote"); + }); + + it("returns 'Promotes to queen' for 'queen'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("queen")).toBe("Promotes to queen"); + }); + + it("returns 'Promotes to rook' for 'rook'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("rook")).toBe("Promotes to rook"); + }); + + it("returns 'Promotes to bishop' for 'bishop'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("bishop")).toBe("Promotes to bishop"); + }); + + it("returns 'Promotes to knight' for 'knight'", () => { + expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("knight")).toBe("Promotes to knight"); + }); +}); + +// ─── apply() ───────────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — apply()", () => { + it("seeds PromotionOverride='bishop' fact on piece entity", () => { + const session = new Session(); + const eid = session.nextId(); + session.insert(eid, "PieceType", "pawn"); + + PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "bishop"); + + expect(session.contains(eid, "PromotionOverride")).toBe(true); + expect(session.get(eid, "PromotionOverride")).toBe("bishop"); + }); + + it("seeds PromotionOverride='disabled' fact on piece entity", () => { + const session = new Session(); + const eid = session.nextId(); + session.insert(eid, "PieceType", "pawn"); + + PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "disabled"); + + expect(session.get(eid, "PromotionOverride")).toBe("disabled"); + }); +}); + +// ─── valueSchema ───────────────────────────────────────────────────────────── + +describe("promotion-override descriptor — valueSchema", () => { + const schema = PROMOTION_OVERRIDE_DESCRIPTOR.valueSchema; + + it("accepts all 4 promotion piece types", () => { + expect(() => schema.parse("queen")).not.toThrow(); + expect(() => schema.parse("rook")).not.toThrow(); + expect(() => schema.parse("bishop")).not.toThrow(); + expect(() => schema.parse("knight")).not.toThrow(); + }); + + it("accepts 'disabled'", () => { + expect(() => schema.parse("disabled")).not.toThrow(); + }); + + it("rejects 'king' (not a valid promotion target)", () => { + expect(() => schema.parse("king")).toThrow(); + }); + + it("rejects 'pawn' (cannot promote to pawn)", () => { + expect(() => schema.parse("pawn")).toThrow(); + }); + + it("rejects arbitrary strings", () => { + expect(() => schema.parse("dragon")).toThrow(); + expect(() => schema.parse("")).toThrow(); + }); +}); + +// ─── Integration: getPromotionMoves ────────────────────────────────────────── + +describe("promotion-override — integration with getPromotionMoves", () => { + it("PromotionOverride='bishop': returns only bishop promotion move", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 → e8 + session.insert(pawn, "PromotionOverride", "bishop"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(1); + const move = moves[0]!; + expect(move.promoteTo).toBe("bishop"); + expect(move.to).toBe(60); // e8 + expect(move.isCapture).toBe(false); + }); + + it("PromotionOverride='disabled': returns empty array (pawn cannot promote)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + session.insert(pawn, "PromotionOverride", "disabled"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toEqual([]); + }); + + it("PromotionOverride='queen': returns only queen promotion move", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + session.insert(pawn, "PromotionOverride", "queen"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(1); + expect(moves[0]!.promoteTo).toBe("queen"); + }); + + it("PromotionOverride='disabled' also suppresses capture-promotions", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy + session.insert(pawn, "PromotionOverride", "disabled"); + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toEqual([]); + }); + + it("PromotionOverride='knight': capture-promotion returns only knight captures", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7 + insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy + insertPiece(session, 3, "queen", "white", 59); // d8 — ally blocks push + session.insert(pawn, "PromotionOverride", "knight"); + + const moves = getPromotionMoves(session, pawn); + + // Only capture to e8 remains (d8 push blocked); override limits to knight + expect(moves).toHaveLength(1); + const knightMove = moves[0]!; + expect(knightMove.promoteTo).toBe("knight"); + expect(knightMove.isCapture).toBe(true); + }); + + it("no PromotionOverride: returns all 4 standard promotion variants (baseline)", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 + + const moves = getPromotionMoves(session, pawn); + + expect(moves).toHaveLength(4); + const types = moves.map((m) => m.promoteTo).sort(); + expect(types).toEqual(["bishop", "knight", "queen", "rook"]); + }); +}); + +// ─── Integration: applyPromotion ───────────────────────────────────────────── + +describe("promotion-override — integration with applyPromotion", () => { + it("PromotionOverride='bishop': promotes to bishop regardless of promoteTo arg", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "bishop"); + + applyPromotion(session, pawn, "queen"); // caller requests queen, override wins + + expect(session.get(pawn, "PieceType")).toBe("bishop"); + }); + + it("PromotionOverride='rook': promotes to rook regardless of default promoteTo", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "rook"); + + applyPromotion(session, pawn); // no promoteTo arg — default is queen, override wins + + expect(session.get(pawn, "PieceType")).toBe("rook"); + }); + + it("PromotionOverride='disabled': falls back to promoteTo arg (pawn stays pawn only if not promoted)", () => { + // When override is "disabled", no promotion moves are generated so + // applyPromotion shouldn't be called. If it is called anyway, it falls + // back to the caller's promoteTo arg (normal behaviour). + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + session.insert(pawn, "PromotionOverride", "disabled"); + + applyPromotion(session, pawn, "knight"); + + expect(session.get(pawn, "PieceType")).toBe("knight"); + }); + + it("no PromotionOverride: applyPromotion uses promoteTo arg normally", () => { + const session = setupSession(); + const pawn = insertPiece(session, 1, "pawn", "white", 60); + + applyPromotion(session, pawn, "rook"); + + expect(session.get(pawn, "PieceType")).toBe("rook"); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/promotion-override.ts b/packages/chess/src/modifiers/descriptors/promotion-override.ts new file mode 100644 index 0000000..704ffb6 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/promotion-override.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +// Valid override: any standard promotion target, or "disabled" (no promotion). +// "king" and "pawn" are intentionally excluded — king is not a valid promotion +// target and pawn would be a no-op / non-sensical promotion. +const promotionTargetSchema = z.enum(["queen", "rook", "bishop", "knight", "disabled"]); +type Value = z.infer; + +export const PROMOTION_OVERRIDE_DESCRIPTOR: ModifierDescriptor = { + id: "promotion-override", + attrName: "PromotionOverride", + label: "Promotion Override", + valueSchema: promotionTargetSchema, + stackingRule: "priority-wins", + uiForm: "promotion-target", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "PromotionOverride", effectiveValue); + }, + describe(value: Value): string { + return value === "disabled" ? "Cannot promote" : `Promotes to ${value}`; + }, +}; + +MODIFIER_REGISTRY.register(PROMOTION_OVERRIDE_DESCRIPTOR); diff --git a/packages/chess/src/rules/promotion.ts b/packages/chess/src/rules/promotion.ts index c930652..9c81ac1 100644 --- a/packages/chess/src/rules/promotion.ts +++ b/packages/chess/src/rules/promotion.ts @@ -43,7 +43,10 @@ export function isPromotionMove(to: Square, color: PieceColor): boolean { /** * Enumerate all promotion moves available to `pieceId` from its current * square. Returns 4 LegalMove entries per reachable promotion-rank target - * (one per element of {@link PROMOTION_PIECES}). + * (one per element of {@link PROMOTION_PIECES}), unless a PromotionOverride + * modifier is active: + * - "disabled" → returns [] (pawn cannot promote) + * - a specific piece type → returns 1 entry per target (only that type) * * Does not verify that `pieceId` is a pawn — the caller is expected to * dispatch on PieceType. Returns [] if the piece has no Position/Color. @@ -53,6 +56,10 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov const color = getPieceColor(session, pieceId); if (from === null || color === null) return []; + // Respect PromotionOverride modifier: "disabled" means no promotion moves. + const override = session.get(pieceId, "PromotionOverride") as PieceType | "disabled" | undefined; + if (override === "disabled") return []; + const candidates: { to: Square; isCapture: boolean }[] = []; // Single advance to the back rank (must be empty). @@ -71,9 +78,12 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov if (candidates.length === 0) return []; // Fan each candidate target into one LegalMove per promotion piece type. + // If PromotionOverride specifies a piece type, offer only that single type. + const promotionChoices: readonly PieceType[] = + override !== undefined ? [override] : PROMOTION_PIECES; const moves: LegalMove[] = []; for (const { to, isCapture } of candidates) { - for (const promoteTo of PROMOTION_PIECES) { + for (const promoteTo of promotionChoices) { moves.push({ pieceId, from, to, isCapture, promoteTo }); } } @@ -85,18 +95,26 @@ export function getPromotionMoves(session: Session, pieceId: EntityId): LegalMov * `promoteTo` (defaults to queen). Session.insert has update semantics, * so this retracts the old PieceType and inserts the new one atomically. * - * Throws if `promoteTo` is not in {@link PROMOTION_PIECES} (i.e. attempts - * to promote to pawn or king). + * If a PromotionOverride modifier is active on the piece (and is not + * "disabled"), the override value takes precedence over `promoteTo`. + * + * Throws if the final promotion target is not in {@link PROMOTION_PIECES} + * (i.e. attempts to promote to pawn or king). */ export function applyPromotion( session: Session, pieceId: EntityId, promoteTo: PieceType = "queen", ): void { - if (!PROMOTION_PIECES.includes(promoteTo)) { + // Respect PromotionOverride modifier: a non-disabled value overrides the + // caller's requested target so the piece always becomes the overridden type. + const override = session.get(pieceId, "PromotionOverride") as PieceType | "disabled" | undefined; + const target = override !== undefined && override !== "disabled" ? override : promoteTo; + + if (!PROMOTION_PIECES.includes(target)) { throw new Error( - `Invalid promotion target: ${promoteTo}. Cannot promote to king or pawn.`, + `Invalid promotion target: ${target}. Cannot promote to king or pawn.`, ); } - session.insert(pieceId, "PieceType", promoteTo); + session.insert(pieceId, "PieceType", target); } From f566c3a4888cc74ab208a4d7f8ca8c504a709223 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:23:29 -0600 Subject: [PATCH 11/88] feat(engine): direction-additions modifier descriptor Adds DIRECTION_ADDITIONS_DESCRIPTOR (ModifierDescriptor) and generateDirectionMoves() helper. The descriptor seeds DirectionAdditions fact on pieces; the helper generates 1-square non-capture moves for each listed direction. Includes 11 tests covering registration, apply, describe, and generateDirectionMoves with white/black color-relative semantics, blocking, union stacking, and edge-board clamping. --- .../descriptors/direction-additions.test.ts | 149 ++++++++++++++++++ .../descriptors/direction-additions.ts | 102 ++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 packages/chess/src/modifiers/descriptors/direction-additions.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/direction-additions.ts diff --git a/packages/chess/src/modifiers/descriptors/direction-additions.test.ts b/packages/chess/src/modifiers/descriptors/direction-additions.test.ts new file mode 100644 index 0000000..5cac9d6 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/direction-additions.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./direction-additions.js"; +import { + DIRECTION_ADDITIONS_DESCRIPTOR, + generateDirectionMoves, +} from "./direction-additions.js"; + +// Square encoding: square = rank * 8 + file +// e4 = rank 3, file 4 → 3*8+4 = 28 +// e3 = rank 2, file 4 → 2*8+4 = 20 +// e5 = rank 4, file 4 → 4*8+4 = 36 +// d4 = rank 3, file 3 → 3*8+3 = 27 +// f4 = rank 3, file 5 → 3*8+5 = 29 + +describe("direction-additions descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'direction-additions'", () => { + expect(MODIFIER_REGISTRY.has("direction-additions")).toBe(true); + expect(MODIFIER_REGISTRY.get("direction-additions")).toBe(DIRECTION_ADDITIONS_DESCRIPTOR); + }); +}); + +describe("direction-additions descriptor — apply()", () => { + it("seeds DirectionAdditions fact on piece entity in session", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]); + + expect(session.contains(pieceId, "DirectionAdditions")).toBe(true); + expect(session.get(pieceId, "DirectionAdditions")).toEqual(["backward"]); + }); +}); + +describe("direction-additions descriptor — describe()", () => { + it("formats a single direction", () => { + expect(DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward"])).toBe("+Directions: forward"); + }); + + it("contains all direction names when multiple directions given (union stacking)", () => { + const result = DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward", "backward"]); + expect(result).toContain("forward"); + expect(result).toContain("backward"); + }); + + it("formats all eight directions", () => { + const all = DIRECTION_ADDITIONS_DESCRIPTOR.describe([ + "forward", "backward", "left", "right", + "diagonal-fl", "diagonal-fr", "diagonal-bl", "diagonal-br", + ]); + expect(all).toContain("diagonal-fl"); + expect(all).toContain("diagonal-br"); + }); +}); + +describe("direction-additions — generateDirectionMoves()", () => { + it("returns backward move for white pawn at e4 when e3 is empty", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "white"); + session.insert(pieceId, "Position", 28); // e4 + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]); + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(1); + expect(moves[0]).toMatchObject({ from: 28, to: 20, isCapture: false }); + }); + + it("returns no move when target square is occupied (blocked backward)", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "white"); + session.insert(pieceId, "Position", 28); // e4 + + // Blocker at e3 (square 20) + const blockerId = session.nextId(); + session.insert(blockerId, "PieceType", "pawn"); + session.insert(blockerId, "Color", "black"); + session.insert(blockerId, "Position", 20); // e3 + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]); + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(0); + }); + + it("returns union of moves for two directions, no duplication", () => { + // white pawn at e4: forward → e5 (36), backward → e3 (20) + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "white"); + session.insert(pieceId, "Position", 28); // e4 + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward", "backward"]); + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(2); + const targets = moves.map(m => m.to); + expect(targets).toContain(36); // e5 (forward) + expect(targets).toContain(20); // e3 (backward) + }); + + it("returns no moves when no DirectionAdditions fact is set", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "white"); + session.insert(pieceId, "Position", 28); // e4 + // DirectionAdditions NOT seeded + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(0); + }); + + it("uses color-relative forward direction: black pawn at e5 forward goes to e4", () => { + // black forward = dr=-1; e5 = square 36, e4 = square 28 + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "black"); + session.insert(pieceId, "Position", 36); // e5 + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward"]); + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(1); + expect(moves[0]).toMatchObject({ from: 36, to: 28, isCapture: false }); + }); + + it("skips out-of-board targets (e.g. backward from rank 1)", () => { + // white pawn at e1 (square 4, rank 0), backward would go to rank -1 + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + session.insert(pieceId, "Color", "white"); + session.insert(pieceId, "Position", 4); // e1 + + DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]); + + const moves = generateDirectionMoves(session, pieceId); + expect(moves).toHaveLength(0); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/direction-additions.ts b/packages/chess/src/modifiers/descriptors/direction-additions.ts new file mode 100644 index 0000000..fc3e697 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/direction-additions.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor, Direction } from "../types.js"; +import type { PieceColor, Square } from "../../schema.js"; +import { fileOf, rankOf, isOnBoard, squareOf } from "../../coord.js"; +import { + getPiecePosition, + getPieceColor, + isPieceAt, +} from "../../rules/board-queries.js"; +import type { LegalMove } from "../../rules/types.js"; + +const schema = z.array( + z.enum([ + "forward", + "backward", + "left", + "right", + "diagonal-fl", + "diagonal-fr", + "diagonal-bl", + "diagonal-br", + ]), +); +type Value = z.infer; + +/** + * Compute file+rank delta for a named direction from a piece's perspective. + * forward/backward are color-relative (toward/away from opponent's back rank). + * left/right are board-absolute (toward a-file / h-file respectively). + * Diagonals combine the two axes accordingly. + */ +function directionDelta(dir: Direction, color: PieceColor): { df: number; dr: number } { + // +1 = white advances up ranks; -1 = black advances down ranks + const forward = color === "white" ? 1 : -1; + switch (dir) { + case "forward": return { df: 0, dr: forward }; + case "backward": return { df: 0, dr: -forward }; + case "left": return { df: -1, dr: 0 }; // always toward a-file + case "right": return { df: 1, dr: 0 }; // always toward h-file + case "diagonal-fl": return { df: -1, dr: forward }; // forward + toward a-file + case "diagonal-fr": return { df: 1, dr: forward }; // forward + toward h-file + case "diagonal-bl": return { df: -1, dr: -forward }; // backward + toward a-file + case "diagonal-br": return { df: 1, dr: -forward }; // backward + toward h-file + } +} + +export const DIRECTION_ADDITIONS_DESCRIPTOR: ModifierDescriptor = { + id: "direction-additions", + attrName: "DirectionAdditions", + label: "Direction Additions", + valueSchema: schema, + stackingRule: "union", + uiForm: "direction-set", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "DirectionAdditions", effectiveValue); + }, + describe(value: Value): string { + return `+Directions: ${value.join(", ")}`; + }, +}; + +MODIFIER_REGISTRY.register(DIRECTION_ADDITIONS_DESCRIPTOR); + +/** + * Generate 1-square non-capture moves in all directions listed in the + * piece's `DirectionAdditions` fact. + * + * Semantics: + * - Step-piece only: exactly 1 square per direction (no sliding). + * - Non-capture only: skips squares occupied by any piece. + * Capture semantics are handled separately by CaptureFlags. + * - Out-of-board targets are silently skipped. + * + * Called by the engine integration layer (T14) after the modifier profile + * has seeded `DirectionAdditions` on the piece. + */ +export function generateDirectionMoves( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const directions = session.get(pieceId, "DirectionAdditions") as readonly string[] | undefined; + if (!directions || directions.length === 0) return []; + + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + + const moves: LegalMove[] = []; + for (const dir of directions as Direction[]) { + const { df, dr } = directionDelta(dir, color); + const toFile = fileOf(from as Square) + df; + const toRank = rankOf(from as Square) + dr; + if (!isOnBoard(toFile, toRank)) continue; + const to = squareOf(toFile, toRank); + if (!isPieceAt(session, to)) { + moves.push({ pieceId, from: from as Square, to, isCapture: false }); + } + } + return moves; +} From 7d27620507e5193e66f110809f31ce136b35a445 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:24:13 -0600 Subject: [PATCH 12/88] feat(engine): hp-bonus modifier descriptor --- .../modifiers/descriptors/hp-bonus.test.ts | 58 +++++++++++++++++++ .../src/modifiers/descriptors/hp-bonus.ts | 25 ++++++++ packages/chess/src/modifiers/registry.ts | 14 ++++- packages/chess/src/modifiers/types.ts | 2 +- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 packages/chess/src/modifiers/descriptors/hp-bonus.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/hp-bonus.ts diff --git a/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts b/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts new file mode 100644 index 0000000..6f6079e --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./hp-bonus.js"; +import { HP_BONUS_DESCRIPTOR } from "./hp-bonus.js"; + +describe("hp-bonus descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'hp-bonus'", () => { + expect(MODIFIER_REGISTRY.has("hp-bonus")).toBe(true); + expect(MODIFIER_REGISTRY.get("hp-bonus")).toBe(HP_BONUS_DESCRIPTOR); + }); +}); + +describe("hp-bonus descriptor — describe()", () => { + it("formats positive values with a leading '+'", () => { + expect(HP_BONUS_DESCRIPTOR.describe(3)).toBe("HP +3"); + }); + + it("formats negative values without an extra sign", () => { + expect(HP_BONUS_DESCRIPTOR.describe(-1)).toBe("HP -1"); + }); + + it("formats zero as '+'", () => { + expect(HP_BONUS_DESCRIPTOR.describe(0)).toBe("HP +0"); + }); +}); + +describe("hp-bonus descriptor — apply()", () => { + it("seeds HpBonus fact on piece entity in session", () => { + const session = new Session(); + const pieceId = session.nextId(); + // Seed a PieceType fact so it's a "real" piece entity. + session.insert(pieceId, "PieceType", "pawn"); + + HP_BONUS_DESCRIPTOR.apply(session, pieceId, 3); + + expect(session.contains(pieceId, "HpBonus")).toBe(true); + expect(session.get(pieceId, "HpBonus")).toBe(3); + }); +}); + +describe("hp-bonus descriptor — valueSchema", () => { + const schema = HP_BONUS_DESCRIPTOR.valueSchema; + + it("accepts values within range [-10, 10]", () => { + expect(() => schema.parse(10)).not.toThrow(); + expect(() => schema.parse(-10)).not.toThrow(); + expect(() => schema.parse(0)).not.toThrow(); + }); + + it("rejects values above 10", () => { + expect(() => schema.parse(11)).toThrow(); + }); + + it("rejects non-integer values", () => { + expect(() => schema.parse(1.5)).toThrow(); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/hp-bonus.ts b/packages/chess/src/modifiers/descriptors/hp-bonus.ts new file mode 100644 index 0000000..5ccffee --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/hp-bonus.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +const schema = z.number().int().min(-10).max(10); +type Value = z.infer; + +const descriptor: ModifierDescriptor = { + id: "hp-bonus", + attrName: "HpBonus", + label: "HP Bonus", + valueSchema: schema, + stackingRule: "additive", + uiForm: "number", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "HpBonus", effectiveValue); + }, + describe(value: Value): string { + return `HP ${value >= 0 ? "+" : ""}${value}`; + }, +}; + +MODIFIER_REGISTRY.register(descriptor); +export { descriptor as HP_BONUS_DESCRIPTOR }; diff --git a/packages/chess/src/modifiers/registry.ts b/packages/chess/src/modifiers/registry.ts index b7aeeb1..cc9571e 100644 --- a/packages/chess/src/modifiers/registry.ts +++ b/packages/chess/src/modifiers/registry.ts @@ -28,15 +28,25 @@ class ModifierRegistryClass { * taken — defensive check because silently overwriting would create * confusing debugging ("which of my two hp-bonus descriptors won?" * races on module import order). + * + * Generic over V so descriptors with concrete value types + * (e.g. `ModifierDescriptor`) can be passed without a + * type assertion at the call site. The registry stores them as + * `ModifierDescriptor` — a heterogeneous container that + * loses V intentionally; callers that need the typed value should + * import the descriptor directly from its own module. */ - register(descriptor: ModifierDescriptor): void { + register(descriptor: ModifierDescriptor): void { if (this.#byId.has(descriptor.id)) { throw new Error( `ModifierRegistry: duplicate modifier id "${descriptor.id}". ` + `Each modifier descriptor must have a unique id.`, ); } - this.#byId.set(descriptor.id, descriptor); + // Widen to the internal unknown-typed representation. The double-cast + // through `unknown` is intentional: the registry is a heterogeneous + // store and V is intentionally erased at storage time. + this.#byId.set(descriptor.id, descriptor as unknown as ModifierDescriptor); } /** diff --git a/packages/chess/src/modifiers/types.ts b/packages/chess/src/modifiers/types.ts index ffdeb0c..d668135 100644 --- a/packages/chess/src/modifiers/types.ts +++ b/packages/chess/src/modifiers/types.ts @@ -80,7 +80,7 @@ export interface ModifierProfile { readonly name: string; readonly description: string; /** Layout this profile's per-instance modifiers are bound to. Optional. */ - readonly layoutId?: string; + readonly layoutId?: string | undefined; readonly perType: readonly TypeModifier[]; readonly perInstance: readonly InstanceModifier[]; readonly version: 1; From 5f339d568fd0ab1f6b2a5b553895624d15d7ecc8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:25:23 -0600 Subject: [PATCH 13/88] feat(engine): wire all 6 descriptor side-effect imports in modifiers/index.ts --- packages/chess/src/modifiers/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/chess/src/modifiers/index.ts b/packages/chess/src/modifiers/index.ts index 013a863..7c08956 100644 --- a/packages/chess/src/modifiers/index.ts +++ b/packages/chess/src/modifiers/index.ts @@ -19,10 +19,10 @@ export type { } from "./types.js"; export { typeModifier, instanceModifier } from "./types.js"; -// Descriptor side-effect imports (added by T6-T11): -// import "./descriptors/hp-bonus.js"; -// import "./descriptors/range-bonus.js"; -// import "./descriptors/direction-additions.js"; -// import "./descriptors/capture-flags.js"; -// import "./descriptors/promotion-override.js"; -// import "./descriptors/damage-resistance.js"; +// Descriptor side-effect imports — all 6 T1 modifiers (ADR-8): +import "./descriptors/hp-bonus.js"; +import "./descriptors/range-bonus.js"; +import "./descriptors/direction-additions.js"; +import "./descriptors/capture-flags.js"; +import "./descriptors/promotion-override.js"; +import "./descriptors/damage-resistance.js"; From 1402c7094b35d7a05c11653bd0b00e9e49afb868 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:30:02 -0600 Subject: [PATCH 14/88] feat(engine): modifier-profile library persistence (v1) --- packages/chess/src/modifiers/library.test.ts | 261 +++++++++++++++++++ packages/chess/src/modifiers/library.ts | 188 +++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 packages/chess/src/modifiers/library.test.ts create mode 100644 packages/chess/src/modifiers/library.ts diff --git a/packages/chess/src/modifiers/library.test.ts b/packages/chess/src/modifiers/library.test.ts new file mode 100644 index 0000000..f29bb6d --- /dev/null +++ b/packages/chess/src/modifiers/library.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import { + loadLibrary, + saveToLibrary, + deleteFromLibrary, + setStarred, + duplicateEntry, + makeId, + __test__, + type SavedModifierProfile, +} from "./library.js"; +import type { ModifierProfile } from "./types.js"; + +// happy-dom provides a localStorage object but its methods are +// bound to a prototype that doesn't survive certain destructuring +// patterns; we install a simple Map-backed shim unconditionally so +// the tests have predictable behavior. +beforeAll(() => { + const store = new Map(); + const shim: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(k: string) { + return store.get(k) ?? null; + }, + setItem(k: string, v: string) { + store.set(k, v); + }, + removeItem(k: string) { + store.delete(k); + }, + key(i: number) { + return [...store.keys()][i] ?? null; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: shim, + }); +}); + +// Clear between tests so each one starts fresh. +beforeEach(() => { + localStorage.clear(); +}); + +const emptyProfile: ModifierProfile = { + id: "test", + name: "Test Profile", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", +}; + +function seed(entry: Partial = {}): SavedModifierProfile { + return { + id: makeId(), + name: "Test", + profile: emptyProfile, + starred: false, + updatedAt: Date.now(), + ...entry, + }; +} + +describe("loadLibrary()", () => { + it("returns [] when no key is set", () => { + expect(loadLibrary()).toEqual([]); + }); + + it("returns [] when storage contains malformed JSON", () => { + localStorage.setItem(__test__.STORAGE_KEY, "not json"); + expect(loadLibrary()).toEqual([]); + }); + + it("filters out entries that fail shape validation", () => { + const validEntry: SavedModifierProfile = { + id: "ok", + name: "ok", + profile: emptyProfile, + starred: false, + updatedAt: 0, + }; + localStorage.setItem( + __test__.STORAGE_KEY, + JSON.stringify([ + validEntry, + { not: "a valid entry" }, + ]), + ); + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.id).toBe("ok"); + }); + + it("filters out entries with invalid nested profile", () => { + localStorage.setItem( + __test__.STORAGE_KEY, + JSON.stringify([ + { + id: "bad-profile", + name: "bad", + profile: { version: 99, source: "unknown" }, + starred: false, + updatedAt: 0, + }, + ]), + ); + expect(loadLibrary()).toHaveLength(0); + }); +}); + +describe("saveToLibrary()", () => { + it("appends a new entry", () => { + const result = saveToLibrary(seed({ name: "First" })); + expect(result.ok).toBe(true); + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.name).toBe("First"); + }); + + it("updates in place when id matches", () => { + const entry = seed({ name: "Original" }); + saveToLibrary(entry); + saveToLibrary({ ...entry, name: "Renamed" }); + + const library = loadLibrary(); + expect(library).toHaveLength(1); + expect(library[0]?.name).toBe("Renamed"); + }); + + it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => { + // Seed with MAX_ENTRIES entries, incrementing updatedAt. + for (let i = 0; i < __test__.MAX_ENTRIES; i++) { + saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i })); + } + expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES); + + // Save one more — oldest (E0) should be evicted. + saveToLibrary(seed({ name: "newest", updatedAt: 9999 })); + const library = loadLibrary(); + expect(library).toHaveLength(__test__.MAX_ENTRIES); + expect(library.map((e) => e.name)).not.toContain("E0"); + expect(library.some((e) => e.name === "newest")).toBe(true); + }); + + it("refuses save when every entry is starred and library is full", () => { + for (let i = 0; i < __test__.MAX_ENTRIES; i++) { + saveToLibrary(seed({ name: `E${String(i)}`, starred: true })); + } + const result = saveToLibrary(seed({ name: "newest" })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toMatch(/unstar/i); + } + expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES); + }); +}); + +describe("deleteFromLibrary()", () => { + it("removes the matching entry", () => { + const entry = seed(); + saveToLibrary(entry); + deleteFromLibrary(entry.id); + expect(loadLibrary()).toHaveLength(0); + }); + + it("is a no-op for unknown id", () => { + saveToLibrary(seed()); + deleteFromLibrary("not-real"); + expect(loadLibrary()).toHaveLength(1); + }); +}); + +describe("setStarred()", () => { + it("toggles starred and updates updatedAt", () => { + const entry = seed({ starred: false, updatedAt: 100 }); + saveToLibrary(entry); + const before = Date.now(); + setStarred(entry.id, true); + + const library = loadLibrary(); + expect(library[0]?.starred).toBe(true); + expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before); + }); + + it("is a no-op for unknown id", () => { + saveToLibrary(seed()); + setStarred("not-real", true); + expect(loadLibrary()[0]?.starred).toBe(false); + }); +}); + +describe("duplicateEntry()", () => { + it("creates a copy with a new id and '(copy)' name suffix", () => { + const entry = seed({ name: "Original" }); + saveToLibrary(entry); + + const newId = duplicateEntry(entry.id); + expect(newId).toBeDefined(); + expect(newId).not.toBe(entry.id); + + const library = loadLibrary(); + expect(library).toHaveLength(2); + const copy = library.find((e) => e.id === newId); + expect(copy?.name).toBe("Original (copy)"); + expect(copy?.starred).toBe(false); + }); + + it("preserves the profile on duplication", () => { + const profileWithData: ModifierProfile = { + id: "p1", + name: "Buff Profile", + description: "has modifiers", + perType: [{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }], + perInstance: [], + version: 1, + source: "custom", + }; + const entry = seed({ name: "Source", profile: profileWithData }); + saveToLibrary(entry); + + const newId = duplicateEntry(entry.id); + const library = loadLibrary(); + const copy = library.find((e) => e.id === newId); + expect(copy?.profile).toEqual(profileWithData); + }); + + it("returns undefined for unknown id", () => { + expect(duplicateEntry("not-real")).toBeUndefined(); + }); +}); + +describe("makeId()", () => { + it("produces unique ids", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) ids.add(makeId()); + expect(ids.size).toBe(100); + }); + + it("falls back when crypto.randomUUID is unavailable", () => { + const original = crypto.randomUUID; + // @ts-expect-error — intentional override for test + crypto.randomUUID = undefined; + try { + const id = makeId(); + expect(id).toMatch(/^layout-/); + } finally { + crypto.randomUUID = original; + } + }); +}); + +// Silence React testing-library warnings if this file runs in a mixed env. +vi.mock("react", async () => await vi.importActual("react")); diff --git a/packages/chess/src/modifiers/library.ts b/packages/chess/src/modifiers/library.ts new file mode 100644 index 0000000..855b97d --- /dev/null +++ b/packages/chess/src/modifiers/library.ts @@ -0,0 +1,188 @@ +/** + * Modifier-profile library — localStorage-backed store of user-authored + * modifier profiles. + * + * Each entry: + * - `id` — local-only UUID. Used to identify the entry for + * update/delete/star operations. + * - `name` — user-provided label, shown in the library drawer. + * - `profile` — the full ModifierProfile. + * - `starred` — true when the user has pinned this profile. Starred + * entries are exempt from FIFO eviction and sort first. + * - `updatedAt` — unix ms of last write. Drives display order for + * non-starred entries (newest first) and FIFO eviction (oldest + * non-starred entry is removed when capacity is hit). + * + * Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred + * entry is evicted. If every entry is starred, we refuse the save + * and the caller surfaces a "library full — unstar something" error. + * + * Storage key is versioned (`houserules:modifier-profiles:v1`). A schema + * bump would ship a new key + migration; v1 entries are kept on best- + * effort and re-hydrated read-only if they can't be migrated. + */ +import { parseModifierProfile } from "./schema.js"; +import type { ModifierProfile } from "./types.js"; + +const STORAGE_KEY = "houserules:modifier-profiles:v1"; +const MAX_ENTRIES = 20; + +export interface SavedModifierProfile { + readonly id: string; + readonly name: string; + readonly profile: ModifierProfile; + readonly starred: boolean; + readonly updatedAt: number; +} + +/** + * Read every saved modifier profile from storage. Returns an empty array on + * empty/missing/corrupt storage — silently discarding unparseable + * data is preferable to blocking the UI. + */ +export function loadLibrary(): SavedModifierProfile[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + // Shallow shape validation — anything that fails is dropped. + return parsed.filter(isSavedModifierProfile); + } catch { + return []; + } +} + +/** Write the full library array back to storage. */ +function writeLibrary(entries: SavedModifierProfile[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + /* quota exceeded / storage disabled — best effort */ + } +} + +/** + * Save a new modifier profile or update an existing one (matched by `id`). + * + * Returns `{ ok: true }` on success. Returns `{ ok: false, reason }` + * when the library is full of starred entries — caller surfaces a + * message telling the user to unstar something. + */ +export function saveToLibrary( + entry: SavedModifierProfile, +): { ok: true } | { ok: false; reason: string } { + const library = loadLibrary(); + const existingIdx = library.findIndex((e) => e.id === entry.id); + + if (existingIdx >= 0) { + // Update in place — no capacity check needed. + library[existingIdx] = entry; + writeLibrary(library); + return { ok: true }; + } + + // New entry — enforce capacity. + if (library.length >= MAX_ENTRIES) { + // Find the oldest non-starred entry and evict it. + const evictable = library + .filter((e) => !e.starred) + .sort((a, b) => a.updatedAt - b.updatedAt); + if (evictable.length === 0) { + return { + ok: false, + reason: + "Library full (20 profiles). Unstar one to make room, or delete an entry.", + }; + } + const oldestNonStarred = evictable[0]!; + const pruned = library.filter((e) => e.id !== oldestNonStarred.id); + pruned.push(entry); + writeLibrary(pruned); + return { ok: true }; + } + + library.push(entry); + writeLibrary(library); + return { ok: true }; +} + +/** Remove a modifier profile by id. No-op if the id is unknown. */ +export function deleteFromLibrary(id: string): void { + const library = loadLibrary(); + writeLibrary(library.filter((e) => e.id !== id)); +} + +/** Toggle the starred flag on a modifier profile. */ +export function setStarred(id: string, starred: boolean): void { + const library = loadLibrary(); + const idx = library.findIndex((e) => e.id === id); + if (idx < 0) return; + const updated: SavedModifierProfile = { + ...library[idx]!, + starred, + updatedAt: Date.now(), + }; + library[idx] = updated; + writeLibrary(library); +} + +/** + * Duplicate a library entry. The copy gets a fresh id, "(copy)" + * appended to the name, and starred=false regardless of the + * original's state. Returns the new entry's id so the caller can + * select it. + */ +export function duplicateEntry(id: string): string | undefined { + const library = loadLibrary(); + const entry = library.find((e) => e.id === id); + if (entry === undefined) return undefined; + + const newId = makeId(); + const copy: SavedModifierProfile = { + id: newId, + name: `${entry.name} (copy)`, + profile: entry.profile, + starred: false, + updatedAt: Date.now(), + }; + const result = saveToLibrary(copy); + if (!result.ok) return undefined; + return newId; +} + +/** Generate a local-only id for a library entry. */ +export function makeId(): string { + // crypto.randomUUID is available in every browser we target (and + // in Node 19+). Fall back to a Math.random-based id only on + // ancient runtimes. + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `layout-${Math.random().toString(36).slice(2, 12)}`; +} + +// ── Internal helpers ────────────────────────────────────────────────── + +function isSavedModifierProfile(value: unknown): value is SavedModifierProfile { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if ( + typeof v["id"] !== "string" || + typeof v["name"] !== "string" || + typeof v["starred"] !== "boolean" || + typeof v["updatedAt"] !== "number" + ) { + return false; + } + // Validate the nested profile using the Zod schema. + try { + parseModifierProfile(v["profile"]); + return true; + } catch { + return false; + } +} + +// Exported for tests only. Prefer the high-level helpers above. +export const __test__ = { STORAGE_KEY, MAX_ENTRIES }; From 29a5ecfd3fe388b9b80ae994d129c54d82b4f45d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:33:09 -0600 Subject: [PATCH 15/88] feat(engine): profile legality validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateProfile(profile, layout) checks 4 rules: - E_PROFILE_NO_KING: layout must have ≥1 king per color - E_PROFILE_INVULN_KING: king cannot have CANNOT_BE_CAPTURED flag (per-type and per-instance) - E_PROFILE_ORPHAN_INSTANCE (warning): per-instance entry targeting empty square - E_PROFILE_ATTR_LIMIT: >12 distinct modifier kinds on one piece - E_PROFILE_DEADLOCK: reserved TODO (requires session simulation) - 17 tests covering all codes, both warning/error paths, and valid field integrity --- packages/chess/src/modifiers/validate.test.ts | 261 ++++++++++++++++++ packages/chess/src/modifiers/validate.ts | 197 +++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 packages/chess/src/modifiers/validate.test.ts create mode 100644 packages/chess/src/modifiers/validate.ts diff --git a/packages/chess/src/modifiers/validate.test.ts b/packages/chess/src/modifiers/validate.test.ts new file mode 100644 index 0000000..dfece56 --- /dev/null +++ b/packages/chess/src/modifiers/validate.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { validateProfile } from "./validate.js"; +import { CLASSIC_LAYOUT } from "../layouts/classic.js"; +import { EMPTY_LAYOUT } from "../layouts/empty.js"; +import type { ModifierProfile, TypeModifier } from "./types.js"; +import type { StartingLayout } from "../layouts/types.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function emptyProfile(overrides: Partial = {}): ModifierProfile { + return { + id: "test", + name: "Test Profile", + description: "", + perType: [], + perInstance: [], + version: 1, + source: "custom", + ...overrides, + }; +} + +/** Minimal layout with one king of each color and nothing else. */ +const KINGS_ONLY_LAYOUT: StartingLayout = { + id: "kings-only", + name: "Kings Only", + description: "Two kings for validator tests.", + pieces: [ + { type: "king", color: "white", square: 4 }, // e1 + { type: "king", color: "black", square: 60 }, // e8 + ], + source: "custom", +}; + +/** Layout without any white king. */ +const NO_WHITE_KING_LAYOUT: StartingLayout = { + ...KINGS_ONLY_LAYOUT, + id: "no-white-king", + pieces: [{ type: "king", color: "black", square: 60 }], +}; + +/** Layout without any black king. */ +const NO_BLACK_KING_LAYOUT: StartingLayout = { + ...KINGS_ONLY_LAYOUT, + id: "no-black-king", + pieces: [{ type: "king", color: "white", square: 4 }], +}; + +// ─── valid baseline ─────────────────────────────────────────────────────────── + +describe("validateProfile — valid baseline", () => { + it("returns valid=true and no errors/warnings for an empty profile on CLASSIC_LAYOUT", () => { + const result = validateProfile(emptyProfile(), CLASSIC_LAYOUT); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("returns valid=true for a benign hp-bonus per-type modifier", () => { + const profile = emptyProfile({ + perType: [{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); +}); + +// ─── E_PROFILE_NO_KING ──────────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_NO_KING", () => { + it("errors when layout has no white king", () => { + const result = validateProfile(emptyProfile(), NO_WHITE_KING_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/white king/i); + }); + + it("errors when layout has no black king", () => { + const result = validateProfile(emptyProfile(), NO_BLACK_KING_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/black king/i); + }); + + it("emits two E_PROFILE_NO_KING errors for EMPTY_LAYOUT (no kings of either color)", () => { + const result = validateProfile(emptyProfile(), EMPTY_LAYOUT); + + expect(result.valid).toBe(false); + const kingErrors = result.errors.filter((e) => e.code === "E_PROFILE_NO_KING"); + expect(kingErrors).toHaveLength(2); + }); +}); + +// ─── E_PROFILE_INVULN_KING ─────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_INVULN_KING", () => { + it("errors when per-type modifier grants all kings CANNOT_BE_CAPTURED", () => { + const profile = emptyProfile({ + perType: [ + // value 2 = CANNOT_BE_CAPTURED + { kind: "capture-flags", pieceType: "king", color: "white", value: 2 }, + ], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/CANNOT_BE_CAPTURED/); + }); + + it("errors when per-instance modifier gives a king CANNOT_BE_CAPTURED (white king at e1)", () => { + // White king is at e1 = square 4 in classic layout + const profile = emptyProfile({ + perInstance: [{ kind: "capture-flags", square: "e1", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(err).toBeDefined(); + expect(err?.square).toBe("e1"); + }); + + it("does NOT error when per-instance CANNOT_BE_CAPTURED targets a non-king (rook at a1)", () => { + // White rook is at a1 = square 0 — not a king, so invuln is allowed + const profile = emptyProfile({ + perInstance: [{ kind: "capture-flags", square: "a1", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(invulnError).toBeUndefined(); + }); + + it("does NOT error for CAN_CAPTURE_OWN (flag=1) on a king", () => { + // CAN_CAPTURE_OWN (= 1) on a king is unusual but not a deadlock + const profile = emptyProfile({ + perType: [{ kind: "capture-flags", pieceType: "king", color: "white", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING"); + expect(invulnError).toBeUndefined(); + }); + + it("errors when combined flags include CANNOT_BE_CAPTURED on king (value=3)", () => { + // value 3 = CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED — still has the invuln bit + const profile = emptyProfile({ + perType: [{ kind: "capture-flags", pieceType: "king", color: "both", value: 3 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + expect(result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING")).toBeDefined(); + }); +}); + +// ─── E_PROFILE_ORPHAN_INSTANCE ─────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_ORPHAN_INSTANCE (warning)", () => { + it("warns when per-instance modifier targets an empty square (d4 is empty in classic)", () => { + // d4 = rank 3, file 3 → square 27 — empty in CLASSIC_LAYOUT + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "d4", value: 2 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + // Warning, not error — profile is still valid + expect(result.valid).toBe(true); + const warn = result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE"); + expect(warn).toBeDefined(); + expect(warn?.square).toBe("d4"); + }); + + it("does NOT warn when per-instance modifier targets an occupied square (e2 pawn)", () => { + // e2 = rank 1, file 4 → square 12 — white pawn in CLASSIC_LAYOUT + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "e2", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + expect(result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE")).toBeUndefined(); + }); + + it("emits multiple orphan warnings for multiple empty-square entries", () => { + const profile = emptyProfile({ + perInstance: [ + { kind: "hp-bonus", square: "d4", value: 1 }, + { kind: "range-bonus", square: "e5", value: 1 }, + ], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + + const orphans = result.warnings.filter((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE"); + expect(orphans).toHaveLength(2); + expect(orphans.map((w) => w.square).sort()).toEqual(["d4", "e5"]); + }); +}); + +// ─── E_PROFILE_ATTR_LIMIT ───────────────────────────────────────────────────── + +describe("validateProfile — E_PROFILE_ATTR_LIMIT", () => { + it("does NOT error with all 6 current modifier kinds on one piece (6 < 12)", () => { + // A white pawn gets all 6 T1 modifier kinds — well within the 12-kind limit + const perType: TypeModifier[] = [ + { kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }, + { kind: "range-bonus", pieceType: "pawn", color: "white", value: 1 }, + { kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward"] }, + { kind: "capture-flags", pieceType: "pawn", color: "white", value: 1 }, + { kind: "promotion-override", pieceType: "pawn", color: "white", value: "queen" }, + { kind: "damage-resistance", pieceType: "pawn", color: "white", value: 0.5 }, + ]; + const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT); + + expect(result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT")).toBeUndefined(); + }); + + it("errors when a single piece accumulates more than 12 distinct modifier kinds", () => { + // Inject 13 synthetic modifier kinds via type assertion (for test only). + // In practice this requires future modifier kinds beyond the 6 T1 set. + const perType = Array.from({ length: 13 }, (_, i) => ({ + kind: `synthetic-kind-${i}` as unknown as TypeModifier["kind"], + pieceType: "pawn" as const, + color: "white" as const, + value: i, + })) satisfies TypeModifier[]; + + const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT"); + expect(err).toBeDefined(); + expect(err?.message).toMatch(/13/); + }); +}); + +// ─── valid field integrity ──────────────────────────────────────────────────── + +describe("validateProfile — valid field", () => { + it("valid=false when any error is present", () => { + const result = validateProfile(emptyProfile(), EMPTY_LAYOUT); + expect(result.valid).toBe(false); + }); + + it("valid=true even when warnings are present", () => { + const profile = emptyProfile({ + perInstance: [{ kind: "hp-bonus", square: "d4", value: 1 }], + }); + const result = validateProfile(profile, CLASSIC_LAYOUT); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/chess/src/modifiers/validate.ts b/packages/chess/src/modifiers/validate.ts new file mode 100644 index 0000000..de83747 --- /dev/null +++ b/packages/chess/src/modifiers/validate.ts @@ -0,0 +1,197 @@ +/** + * Modifier profile legality validator. + * + * Validates a ModifierProfile against a StartingLayout for game-rule legality. + * + * Errors BLOCK profile activation (server rejects the profile; editor hides + * the "Apply" CTA). Warnings are surfaced to the user but don't prevent the + * profile from being applied — the user made a deliberate choice. + * + * ## Error rules + * + * 1. E_PROFILE_NO_KING: Layout must have ≥1 king per color. + * (Profiles cannot compensate for a king-less layout.) + * + * 2. E_PROFILE_INVULN_KING: King cannot have the CANNOT_BE_CAPTURED flag. + * An invulnerable king means the game can never end — instant deadlock. + * + * 3. E_PROFILE_ATTR_LIMIT: A single piece cannot accumulate > 12 distinct + * modifier kinds. The EAV ceiling is 16 attributes per entity; 4 are + * reserved for core piece facts (PieceType, Color, Position, HasMoved), + * leaving 12 slots for modifier attributes. + * + * 4. E_PROFILE_DEADLOCK: Reserved. Requires a session simulation to detect + * mutual-invulnerability loops; deferred to a future task. + * + * ## Warning rules + * + * - E_PROFILE_ORPHAN_INSTANCE: A per-instance modifier references a square + * that has no piece in the layout. The modifier will never apply, but it is + * harmless — the user may have changed the layout after crafting the profile. + */ +import type { ModifierProfile } from "./types.js"; +import type { StartingLayout } from "../layouts/types.js"; +import { CaptureFlag } from "../schema.js"; +import { squareToAlgebraic } from "../coord.js"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export type ValidationErrorCode = + | "E_PROFILE_NO_KING" + | "E_PROFILE_INVULN_KING" + | "E_PROFILE_DEADLOCK" + | "E_PROFILE_ATTR_LIMIT"; + +export type ValidationWarningCode = "E_PROFILE_ORPHAN_INSTANCE"; + +export interface ValidationError { + readonly code: ValidationErrorCode; + readonly message: string; + /** Algebraic square, present when the error is tied to a specific square. */ + readonly square?: string; +} + +export interface ValidationWarning { + readonly code: ValidationWarningCode; + readonly message: string; + /** Algebraic square the warning refers to (always present for ORPHAN_INSTANCE). */ + readonly square: string; +} + +export interface ValidationResult { + readonly errors: readonly ValidationError[]; + readonly warnings: readonly ValidationWarning[]; + /** `true` iff `errors` is empty — profile can be activated. */ + readonly valid: boolean; +} + +// ─── Validator ──────────────────────────────────────────────────────────────── + +export function validateProfile( + profile: ModifierProfile, + layout: StartingLayout, +): ValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // ── Check 1: E_PROFILE_NO_KING ───────────────────────────────────────────── + // Each color must have at least one king in the layout; without one, there + // is no royal piece to checkmate/capture and the game has no terminal state. + for (const color of ["white", "black"] as const) { + const hasKing = layout.pieces.some( + (p) => p.type === "king" && p.color === color, + ); + if (!hasKing) { + errors.push({ + code: "E_PROFILE_NO_KING", + message: `Layout has no ${color} king — the game cannot reach a terminal condition.`, + }); + } + } + + // ── Check 2: E_PROFILE_INVULN_KING ──────────────────────────────────────── + // A king with CANNOT_BE_CAPTURED (= 2) can never be taken; the game would + // loop indefinitely with no win condition. + const INVULN = CaptureFlag.CANNOT_BE_CAPTURED; + + // Per-type: modifier applies to ALL kings of the given color. + for (const tm of profile.perType) { + if ( + tm.kind === "capture-flags" && + tm.pieceType === "king" && + typeof tm.value === "number" && + (tm.value & INVULN) !== 0 + ) { + errors.push({ + code: "E_PROFILE_INVULN_KING", + message: + `Per-type modifier grants all ${tm.color} kings CANNOT_BE_CAPTURED ` + + `— the game would have no terminal condition.`, + }); + } + } + + // Per-instance: modifier applies only if the piece at that square is a king. + for (const im of profile.perInstance) { + if (im.kind !== "capture-flags") continue; + if (typeof im.value !== "number") continue; + if ((im.value & INVULN) === 0) continue; + + const piece = layout.pieces.find( + (p) => squareToAlgebraic(p.square) === im.square, + ); + if (piece?.type === "king") { + errors.push({ + code: "E_PROFILE_INVULN_KING", + message: + `Per-instance modifier grants king at ${im.square} CANNOT_BE_CAPTURED ` + + `— the game would have no terminal condition.`, + square: im.square, + }); + } + } + + // ── Check 3: E_PROFILE_ORPHAN_INSTANCE (WARNING) ────────────────────────── + // A per-instance entry that targets an empty square will never fire. + // Warning only — the user may have intentionally left the slot as a draft, + // or changed the layout after building the profile. + for (const im of profile.perInstance) { + const piece = layout.pieces.find( + (p) => squareToAlgebraic(p.square) === im.square, + ); + if (!piece) { + warnings.push({ + code: "E_PROFILE_ORPHAN_INSTANCE", + message: + `Per-instance modifier at "${im.square}" references an empty square — ` + + `the modifier will never apply.`, + square: im.square, + }); + } + } + + // ── Check 4: E_PROFILE_ATTR_LIMIT ───────────────────────────────────────── + // For each piece in the layout, count the distinct modifier kinds that + // target it (from both perType and perInstance sources). More than 12 + // would overflow the EAV attribute ceiling (16 total − 4 core = 12 slots). + for (const piece of layout.pieces) { + const sq = squareToAlgebraic(piece.square); + const kinds = new Set(); + + for (const tm of profile.perType) { + if ( + tm.pieceType === piece.type && + (tm.color === piece.color || tm.color === "both") + ) { + kinds.add(tm.kind); + } + } + + for (const im of profile.perInstance) { + if (im.square === sq) { + kinds.add(im.kind); + } + } + + if (kinds.size > 12) { + errors.push({ + code: "E_PROFILE_ATTR_LIMIT", + message: + `Piece at ${sq} has ${kinds.size} distinct modifier kinds (max 12).`, + square: sq, + }); + } + } + + // ── Check 5: E_PROFILE_DEADLOCK ──────────────────────────────────────────── + // TODO: Detect modifier combinations that create irresolvable game states + // (e.g., both colors have kings with CANNOT_BE_CAPTURED, neither side can + // win). This check requires a session simulation and is deferred; the + // per-type INVULN_KING check above catches the most common case. + + return { + errors, + warnings, + valid: errors.length === 0, + }; +} From e23e69e0d0586ad850ca31e12fd8af446fa4ae04 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:35:06 -0600 Subject: [PATCH 16/88] feat(ui): modifier profile editor shell + rules drawer entry Adds ModifierProfileEditor modal shell with 3 placeholder panels (T21 catalog / T22 board preview / T23 profile list). Esc closes the modal via a window keydown listener active only while isOpen. Wires a 'Modifier Profiles' button into the RulesDrawer footer that opens the editor. Adds e2e/modifier-profiles.spec.ts with 2 tests: open-from-drawer and esc-to-close. --- packages/chess/e2e/modifier-profiles.spec.ts | 55 +++++++++++++ .../chess/src/ui/ModifierProfileEditor.tsx | 80 +++++++++++++++++++ packages/chess/src/ui/RulesDrawer.tsx | 25 +++++- 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 packages/chess/e2e/modifier-profiles.spec.ts create mode 100644 packages/chess/src/ui/ModifierProfileEditor.tsx diff --git a/packages/chess/e2e/modifier-profiles.spec.ts b/packages/chess/e2e/modifier-profiles.spec.ts new file mode 100644 index 0000000..a9c5240 --- /dev/null +++ b/packages/chess/e2e/modifier-profiles.spec.ts @@ -0,0 +1,55 @@ +/** + * E2E — Modifier Profile Editor shell (T18). + * + * Verifies: + * 1. The editor modal opens from the Rules drawer. + * 2. Pressing Escape closes the editor. + * + * Runs against the local dev server (no WS server needed — solo play only). + */ +import { test, expect } from '@playwright/test'; + +test.describe('Modifier Profiles', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Clear any stale autosave so Play Solo starts a fresh game. + await page.evaluate(() => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { + localStorage.removeItem(key); + } + } + localStorage.removeItem('paratype-chess:v1:autosave'); + }); + + // Navigate into the game view. + await page.locator('[data-action="play-solo"]').click(); + await page.waitForURL('**/game'); + + // Open the rules drawer so the modifier editor button is accessible. + await page.locator('[data-action="open-rules-drawer"]').click(); + await expect(page.getByTestId('rules-drawer')).toBeVisible(); + }); + + test('editor opens from rules drawer', async ({ page }) => { + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible({ timeout: 1000 }); + }); + + test('esc closes modifier editor', async ({ page }) => { + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).not.toBeVisible({ timeout: 500 }); + }); +}); diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx new file mode 100644 index 0000000..c431811 --- /dev/null +++ b/packages/chess/src/ui/ModifierProfileEditor.tsx @@ -0,0 +1,80 @@ +/** + * Modifier Profile Editor — modal shell. + * + * Shell only: three placeholder panels filled by later tasks: + * T21 — modifier catalog (left panel) + * T22 — board preview (center panel) + * T23 — profile list (right panel) + * + * Follows the same overlay/close pattern as LayoutEditor.tsx. + */ +import { useEffect } from 'react'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +export function ModifierProfileEditor({ isOpen, onClose }: Props) { + // Register Esc listener only while the modal is visible. + useEffect(() => { + if (!isOpen) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ Modifier Profiles +

+ +
+ + {/* Body — three placeholder panels, filled by T21 / T22 / T23 */} +
+ + +
+

+ Board preview (T22) +

+
+ + +
+
+
+ ); +} diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx index 2a7074b..0d4e9b6 100644 --- a/packages/chess/src/ui/RulesDrawer.tsx +++ b/packages/chess/src/ui/RulesDrawer.tsx @@ -5,6 +5,7 @@ import type { PresetActivation } from '../net/types.js'; import { AnimatePresence, motion } from 'motion/react'; import { Settings2, X } from 'lucide-react'; import { toast } from 'sonner'; +import { ModifierProfileEditor } from './ModifierProfileEditor.js'; /** * A collapsible side-drawer for toggling preset rules mid-game. @@ -65,6 +66,7 @@ export function RulesDrawer({ onRulesChanged, }: RulesDrawerProps) { const [open, setOpen] = useState(false); + const [modifierEditorOpen, setModifierEditorOpen] = useState(false); const presets = useMemo(() => PRESET_REGISTRY.getAll(), []); const activeById = useMemo(() => { const map = new Map(); @@ -484,15 +486,30 @@ export function RulesDrawer({ })} -