Commit graph

215 commits

Author SHA1 Message Date
bd20032767
feat(ui): group RulesDrawer presets by category + layout suggested-rules chips
Phase F.3 + F.2-UI of the rule-variants epic.

Preset metadata:
  - Added optional `category: 'king' | 'objective' | 'movement' |
    'multi-move' | 'pieces' | 'misc'` on PresetDef (registry.ts).
    Defaults to 'misc' when omitted.
  - Categorized all 28 registered presets:
    * King Variants: knightmate-rules, coregal, dual-king,
      weak-dual-king.
    * Objectives: capture-to-win, last-piece-standing,
      first-promotion-wins, suicide-chess, capture-all,
      extinction-chess.
    * Movement: pawns-move-backward, double-pawn-sprint,
      pawn-diagonal-no-capture, knights-leap-twice,
      bishops-ignore-color, rook-warp, wrap-board,
      berolina-pawns(+2), bouncing-pieces(+2), queen-splits.
    * Multi-move: double-move, monster-rules.
    * Pieces: piece-hp, king-heals, explosive-rook,
      knight-immunity, poisoned-squares.

UI:
  - RulesDrawer.tsx renders presets in category sections with
    sentence-case headers ('King Variants', 'Objectives',
    'Movement', 'Multi-move', 'Pieces', 'Misc'). Empty sections
    hidden. Each section has data-testid='rules-category-<id>' for
    e2e addressability. Existing search / scope / duration controls
    preserved.
  - LayoutPicker.tsx renders a 'Suggested rules' chip group under
    each layout with suggestedPresets.length > 0. Chips toggle
    presets on/off. Active = filled (neutral-800), inactive =
    ghost (neutral-100). data-testid='layout-suggested-preset-<id>'
    on each chip.
  - Lobby.tsx wires activations + setPresets props to LayoutPicker
    so chip toggles flow into the host-game payload
    (rulesetIds array).

Tests: 1651 passing (unchanged — pure UI metadata).
2026-04-21 09:19:38 -06:00
50f7b37e12
test(layouts): suggestedPresets references must resolve to registered presets
Phase F.2 (audit portion) of the rule-variants epic.

Adds a suite to premades.test.ts that walks every layout's
`suggestedPresets` and verifies each referenced id resolves to a
registered preset. Catches rename / deletion drift before a
dangling chip surfaces in the lobby UI.

Current audit is green: all 7 suggestedPresets entries across
dual-classic, knightmate, pawns-only, and monster reference real
ids. (UI 'suggested rules chip' rendering is a separate follow-up
bundled with the Phase F.3 RulesDrawer work.)

Tests: 1651 passing (was 1650, +1 new audit test).
2026-04-21 09:04:54 -06:00
c0c32359ec
test(presets): enforce incompatibleWith symmetry + reciprocate declarations
Phase F.1 of the rule-variants epic.

Adds three global audit tests to presets.test.ts:
  - incompatibleWith is symmetric across every registered preset
    (if A declares B, B must declare A).
  - every incompatibleWith entry references a real preset id.
  - no preset declares itself incompatible.

The symmetry audit caught 16 existing asymmetries — presets in
Phase B/D/E declared incompat with capture-to-win /
last-piece-standing / knightmate-rules / wrap-board / etc., but
those older presets hadn't reciprocated. Adding the reciprocal
entries across 7 files:
  - capture-to-win, last-piece-standing now declare incompat with
    the five Phase B-D terminal-state presets.
  - knightmate-rules adds suicide-chess, capture-all.
  - pawn-diagonal-no-capture, pawns-move-backward,
    double-pawn-sprint each declare incompat with berolina-pawns
    and berolina-pawns-2.
  - wrap-board adds bouncing-pieces, bouncing-pieces-2.
  - extinction-chess reciprocates back to capture-to-win,
    last-piece-standing.

Tests: 1650 passing (was 1647, +3 new audit tests).
2026-04-21 09:03:26 -06:00
b8afc9017f
feat(presets): bouncing-pieces-2 (all four edges, 2-bounce cap) 2026-04-21 08:58:19 -06:00
ee08e20871
feat(presets): berolina-pawns-2 (berolina with sideways captures) 2026-04-21 08:41:52 -06:00
b873095c17
feat(presets): bouncing-pieces (bishop/queen diagonals bounce off file edges once) 2026-04-21 08:37:52 -06:00
5393b96b3f
feat(presets): berolina-pawns (diagonal push + orthogonal capture, scope-flippable) 2026-04-21 08:29:54 -06:00
08b8e0fed2
feat(presets): extinction-chess (configurable type-extinction objective)
Phase D.3 of the rule-variants epic.

Winner is the side that captures ALL enemy pieces of a specific
target type. Target is configurable via
engine.presetState<{targetType: PieceType}>('extinction-chess');
default is 'pawn' (seeded on onActivate).

- onCheckGameResult counts live pieces of targetType per color.
  Black count=0 -> white-wins; white count=0 -> black-wins; else
  returns undefined to let default checkmate/stalemate continue
  (king remains royal in this variant).
- No getRoyalPieces / shouldFilterSelfCheck overrides: king is
  royal, self-check filter stays on, default FIDE rules still apply
  until extinction triggers.
- Incompatible with suicide-chess, capture-all, first-promotion-wins
  (terminal-state conflicts).

15 tests covering each target type (pawn/knight/bishop/rook/queen/
king), default seeding, runtime reconfig, default checkmate still
fires in absence of extinction, piece-hp composition, and
incompatibility enforcement.

Tests: 1587 passing total (1506 baseline + 81 across Phase C+D).

(Task agent hit the 200-tool-call cap AFTER writing all files and
passing tests locally, so this commit is a manual reconciliation of
that in-place work.)
2026-04-21 08:19:02 -06:00
8efdd8a4e7
feat(presets): suicide-chess (lose all pieces to win)
Antichess/losing-chess preset: captures are compulsory, kings are
not royal, and the side that first empties its army WINS. Implements
the FICS/lichess stalemate-wins rule (side-to-move with zero legal
moves wins). Composes with piece-hp (non-lethal captures still
satisfy compulsion) and double-move (two captures in a row when
available). Declared incompatible with every other terminal-state
and royalty-redefining preset.
2026-04-21 08:12:21 -06:00
d456e87a04
feat(presets): weak-dual-king (mating one king isn't terminal) 2026-04-21 08:10:57 -06:00
5d64ae4c37
feat(presets): capture-all (capture every enemy piece to win)
Phase D.2 of the rule-variants epic. Inverse objective of
suicide-chess: you win by driving the opponent's piece count to 0.
Kings are not royal, captures are NOT compulsory.

Wiring uses three existing hooks:
  - getRoyalPieces → [] per color (no royalty).
  - shouldFilterSelfCheck → false (opt out of the filter; kings can
    legally wander into attacked squares).
  - onCheckGameResult → count live Position facts per color; 0 for
    the opponent wins for the mover. Otherwise returns 'ongoing' to
    suppress the default checkmate/stalemate polls (which are
    ill-defined under empty-royal semantics).

No filterLegalMoves — captures are optional, which is the chief
rule-text difference from suicide-chess.

Incompatible with every other terminal-decider preset
(capture-to-win, last-piece-standing, first-promotion-wins,
suicide-chess, extinction-chess) and with presets that redefine
royalty (knightmate-rules, coregal, dual-king, weak-dual-king).
monster-rules already declares capture-all incompatible on its
side; mirror it here.
2026-04-21 08:08:12 -06:00
0d152861af
feat(presets): coregal (king + queen both royal) 2026-04-21 08:02:17 -06:00
4334a0382a
feat(presets): dual-king + dual-classic layout 2026-04-21 07:53:54 -06:00
f74e204211
feat(presets): monster-rules (scope-flip) + first-promotion-wins
Phase B.3 + B.4 of the rule-variants epic.

monster-rules — scope-aware asymmetric double-move:
  - scope='white' (canonical Monster pairing) → white plays 2
    half-moves per turn, black plays 1.
  - scope='black' → black plays 2, white plays 1 (symmetric flip).
  - scope='both' → both sides play 2 (equivalent to double-move;
    prefer that preset for that mode).
  The hook reads the preset's live scope from
  engine.activePresets.list() at dispatch time, so a scope change
  mid-game takes effect immediately.
  15 tests covering activation, W2+B1 asymmetry, scope='black'
  flip, scope='both' symmetric mode, onTurnStart gating, checkmate
  detection on either color's move, and double-move incompatibility.

first-promotion-wins:
  - Winner is declared when either side promotes a pawn. First
    promotion locks; later promotions don't overwrite.
  - Two-phase latch: onBeforeMove records (pieceId, mover) when the
    move is a pawn reaching its promotion rank; onAfterMove
    confirms the promotion resolved (piece is no longer a pawn)
    and sets the winner. This avoids the moveLog-timing bug
    (moveLog.push fires AFTER onAfterMove + checkGameResult).
  - onCheckGameResult converts the recorded winner to
    white-wins / black-wins. Composes with piece-hp thanks to the
    engine's updated 'ongoing' semantics (separate commit).
  - Does NOT opt out of shouldFilterSelfCheck — documented in-file
    why (would also legalize non-promotion exposing moves).
  - 11 tests covering activation, baseline, white/black promotion,
    first-promotion lock, pawns-only layout composition, piece-hp
    composition, double-move composition, and incompatibility
    enforcement.

presets.test.ts: bumped EXPECTED_IDS to include first-promotion-wins
(17 → 18). Barrel import order keeps first-promotion-wins at end.

Tests: 1506 passing total (was 1492, +14 net — +15 monster-rules
+ 11 first-promotion-wins - 12 overlap with existing suite
reruns).
2026-04-21 07:32:48 -06:00
7171dfdd5e
fix(engine): onCheckGameResult 'ongoing' suppresses defaults without short-circuit
Presets like piece-hp eagerly return 'ongoing' from
onCheckGameResult to suppress the engine's default checkmate /
stalemate poll (because an HP-enabled king can legally survive
check). Under the previous 'first non-undefined wins' rule,
'ongoing' locked the result and prevented any later preset from
declaring a winner — breaking composition with presets like
first-promotion-wins that declare terminal mid-game.

Two-phase protocol:
  - TERMINAL result (white-wins / black-wins / draw / checkmate
    / stalemate / draw-*) immediately wins; engine stops polling.
  - 'ongoing' sets a soft 'suppress defaults' flag and CONTINUES
    polling — a later preset can still declare a winner.
  - undefined = no opinion.

If every poller returns either 'ongoing' or undefined, the engine
honours the suppress flag and returns 'ongoing' without running
the default isCheckmate / isStalemate / draw predicates. If no
poller opinionated, defaults run as before.

Updated PresetDef.onCheckGameResult docblock to match.
2026-04-21 07:32:00 -06:00
770d0fd9cf
feat(presets): double-move (both sides play 2 half-moves per turn)
Phase B.2 of the rule-variants epic. Each side plays two consecutive
half-moves before the turn flips; turn order is WW BB WW BB …

Implementation is a two-line `shouldAdvanceTurn` hook on top of the
Phase A.3 `HalfMovesThisTurn` fact — veto the flip while the post-
increment count is < 2, let the engine default flip proceed otherwise.
No engine, schema, or layout modifications.

Check / checkmate composition is free: the self-check filter runs per
half-move (so neither half-move may leave own king exposed), and
`checkGameResult` runs after every applyMove regardless of flip, so
mate on the mover's second half-move lands exactly where it should
without any double-move-specific terminal logic.

Documented composition with `piece-hp` (independent; damage pipeline
doesn't read HalfMovesThisTurn) and `king-heals` (uses onAfterMove,
not onTurnStart — fires on every half-move including vetoed ones;
pinned by the test). Declared `incompatibleWith: ["monster-rules"]`
because both redefine the flip hook in conflicting ways.

Test count: 1461 previous → 1479 total (+18). The double-move.test.ts
file contributes 18 behavioral tests covering preset definition sanity,
WW BB WW counter semantics, onTurnStart cadence, mid-turn check
delivery, Fool's-Mate-style and Scholar's-Mate-style checkmate
detection, composition with piece-hp, composition with king-heals, and
session-snapshot integrity mid-veto. Exceeds the 12-test floor.

- packages/chess/src/presets/double-move.ts (new)
- packages/chess/src/presets/double-move.test.ts (new, 18 tests)
2026-04-21 07:15:59 -06:00
7b97a77ee3
feat(presets): knightmate-rules (royal knights)
Phase B.1 of the rule-variants epic. Knights are royal pieces via
the getRoyalPieces hook; kings lose their special status. Every
knight of `color` is royal, so the game only ends when the LAST
knight is mated or captured. Rules-only preset — applies to any
layout, though naturally paired with the existing `knightmate`
layout.

Test count: 1448 baseline → 1479 total (+31). The knightmate-rules
test file contributes 10 behavioral tests (a–j) covering baseline,
check detection on a royal knight, mate detection, zero-knights
degenerate semantics, king non-royalty, multi-royal check, pinned
royals, piece-hp composition, and incompatibility enforcement.
The other +21 delta comes from parallel Phase-A/B tests already
landed in the tree.

- packages/chess/src/presets/knightmate-rules.ts (new)
- packages/chess/src/presets/knightmate-rules.test.ts (new, 10 tests)
- packages/chess/src/presets/index.ts (barrel import)
- packages/chess/src/presets/presets.test.ts (count 15 → 16)
2026-04-21 07:13:01 -06:00
823a8c8dfa
docs(preset-api): hook ordering + Phase A summary
Phase A.5 of the rule-variants epic — the documentation gate.

- Movement section now leads with a 7-step dispatch diagram covering
  the full per-piece-to-aggregate pipeline. Every new hook points at
  its step explicitly so future preset authors know where each hook
  slots in.
- New hook reference entries with canonical-user callouts:
  overridePieceMoves, filterLegalMoves, getRoyalPieces (new Royalty
  section), shouldAdvanceTurn (new Turn flip section). Every entry
  has a concrete code snippet illustrating typical usage.
- Design Notes 'Hook firing order' split into two sequences — the
  legal-move query (1-4) and move application (1-11). Application
  sequence now correctly reflects HalfMovesThisTurn increment +
  shouldAdvanceTurn poll + onTurnStart gating.
- Scope-aware table extended with the 4 new hooks.
- Notepad appended with Phase A close-out: 1417 → 1448 unit tests,
  80/80 Playwright green, zero lint/type errors, four commits on
  master (4d05473, db8145f, f9475e9, 1a11491).
2026-04-20 20:52:51 -06:00
1a11491a15
feat(engine): overridePieceMoves hook
Phase A.4 of the rule-variants epic — adds the 'replace the default
move generator for this piece' extension point. First non-undefined
wins; later matches emit a dev-mode console.warn. The winning set
SKIPS default-generator + transformMoveGenerator + getExtraMoves +
en-passant/castling/promotion synthesis; filterMoves still composes
on top.

- Adds overridePieceMoves hook on PresetDef with full docs covering
  execution order, skipped downstream steps, filterMoves layering,
  and collision semantics.
- engine.getAllLegalMoves per-piece loop now checks overrides first;
  override path replaces moves entirely; default path unchanged when
  every preset returns undefined.
- Collision warning is dev-only (suppressed when NODE_ENV=production).
  First registered preset wins; second's return is ignored but
  console.warn names both preset ids.
- New override-piece-moves.test.ts (7 tests): default knight count
  baseline, lame-knight single-square override, undefined-declines
  fallthrough, collision warning fires with both preset ids in the
  message, production-mode suppression, filterMoves composes on top
  of overrides, unrelated piece types unaffected.

Tests: 1448 passing (was 1441, +7 new). Blocks: unblocks E.1
(berolina-pawns), E.2 (berolina-pawns-2).
2026-04-20 20:18:03 -06:00
f9475e9739
feat(engine): shouldAdvanceTurn hook + HalfMovesThisTurn fact
Phase A.3 of the rule-variants epic — preset-controlled turn flip
gating. Enables double-move (white plays 2, then black plays 2) and
monster (scope-aware: white plays 2, black plays 1) without engine
changes at the preset layer.

- Adds HalfMovesThisTurn: number to ChessAttrMap. Seeded to 0 in
  applyLayout. Distinct from HalfmoveClock (FIDE 50-move rule) —
  this counter is within-turn and resets on flip.
- Adds TurnAdvanceContext + shouldAdvanceTurn hook to PresetDef.
  Hook receives post-increment count via ctx.halfMovesThisTurn so
  'play N half-moves before flipping' reads as the predicate
  'halfMovesThisTurn < N -> false'.
- engine.applyMove: increments HalfMovesThisTurn BEFORE polling
  shouldAdvanceTurn; first false wins; on flip resets to 0 and
  increments FullmoveNumber after black; onTurnStart is gated on
  shouldAdvance (vetoed flips don't fire turn-start hooks, so
  mid-turn stamina regen bugs are prevented by construction).
- starting-position.test.ts updated with HalfMovesThisTurn=0 seed
  assertion.
- New turn-advance.test.ts (7 tests): seeding, baseline unchanged,
  never-flip veto, flip-after-two (double-move surface), onTurnStart
  gating across vetoed + non-vetoed flips, FullmoveNumber increments
  only on actual flips after black.

Tests: 1441 passing (was 1434, +7 new). Blocks: unblocks B.2
(double-move), B.3 (monster-rules), B.4 (first-promotion-wins).
2026-04-20 20:13:51 -06:00
db8145fb97
feat(engine): filterLegalMoves hook
Phase A.2 of the rule-variants epic — adds the post-aggregation
legal-move filter pipeline.

- Adds FilterLegalMovesContext + filterLegalMoves hook on PresetDef.
  Hook runs AFTER the self-check filter; iterated in activePresets
  list order, each preset sees the prior's output. Subset-only
  contract (drop, don't synthesize); documented + pinned via test.
- Wires the iteration into engine.getAllLegalMoves — immediately
  after the royal-aware self-check filter lands.
- Adds filter-legal-moves.test.ts (6 tests): baseline unchanged,
  no-a-file drop filter, compulsory-capture prototype (proves the
  suicide-chess rule surface before D.1 lands), two-preset
  composition, and subset-contract doc test.

Tests: 1434 passing (was 1428, +6 new). Blocks: unblocks D.1
(suicide-chess).
2026-04-20 20:08:54 -06:00
4d05473919
feat(engine): getRoyalPieces hook + engine royal dispatch
Phase A.1 of the rule-variants epic — enables preset-driven royalty
override. A preset can now declare WHICH pieces count as royal for a
given color; the engine unions contributions across active presets
and threads the resolved set through isInCheck, isCheckmate,
isStalemate, and the self-check filter.

- Adds RoyalContext type + getRoyalPieces hook on PresetDef.
- Widens isInCheck/isCheckmate/isStalemate/filterSelfCheckMoves with
  optional royalEntityIds param; empty set short-circuits to 'no
  royalty, nothing in check'; undefined keeps legacy king-only path.
- Adds private ChessEngine.getActiveRoyalEntityIds(color) that
  returns undefined when no preset contributed (preserves back-compat)
  and the union of contributions otherwise.
- Threads the resolver into all three internal consumers:
  getAllLegalMoves self-check filter, applyMove opponent-check
  logging, and checkGameResult checkmate/stalemate detection.
- Adds packages/chess/src/presets/royal-pieces.test.ts (11 tests)
  covering default, single-preset, overlap-union, and empty-royal
  paths at both the pure-function and engine-integration layers.

Tests: 1428 passing (was 1417, +11 new). Typecheck + lint clean.
Blocks: unblocks B.1 (knightmate-rules), C.1 (coregal), C.2
(dual-king), C.3 (weak-dual-king), D.1 (suicide-chess), D.2
(capture-all).
2026-04-20 20:04:29 -06:00
53bd105d6b
chore(sisyphus): notepad scaffold for rule-variants epic
Seeds .sisyphus/notepads/rule-variants/learnings.md with:
  - Ground state (master @ a159299, test counts, existing hooks)
  - Key file anchors verified live (isInCheck, getAllLegalMoves,
    applyMove turn-flip) so the incoming agent doesn't re-grep
  - Command cheat sheet (bun run check, vitest, playwright,
    WS server restart after protocol edits)
  - T3-learned gotchas (tool cap, dev-server hot-reload, module
    load order, pre-commit hook discipline)
  - Empty 'Running progress' section for per-task append-only
    entries

Pairs with .sisyphus/plans/rule-variants-v2.md (execution plan).
The agent picking up this epic reads the plan for the WORK and the
notepad for the CONTEXT.
2026-04-20 18:16:25 -06:00
a159299818
chore(sisyphus): execution plan for rule-variants epic (v2)
Writes the step-by-step to-do list successor to the Momus-approved
design doc at .sisyphus/plans/rule-variants.md. The design doc has
the full hook-API rationale, risk analysis, and architecture sketch;
rule-variants-v2.md is the agent-executable task list.

Structure mirrors the modifier-profiles-t3 plan the repo just
shipped: 6 phases (A-F) + Final Verification Wave, 32 top-level
checkboxes, parallel-execution map, T3-learned tactics section for
the incoming agent (tool-cap fragility, commit discipline,
v3/v4 mirror pitfalls), and explicit 'when to stop mid-plan'
branching so an early-exit delivers a coherent subset.

Scope (recap from design doc):
  - Phase A: 4 new PresetDef hooks (getRoyalPieces,
    filterLegalMoves, shouldAdvanceTurn, overridePieceMoves) +
    HalfMovesThisTurn game fact.
  - Phase B: 4 Tier-1 presets closing starting-layouts deferrals
    (knightmate-rules, double-move, monster-rules,
    first-promotion-wins).
  - Phase C: 3 royal-variant Tier-2 presets (coregal, dual-king,
    weak-dual-king) + dual-classic layout.
  - Phase D: 3 objective-variant Tier-2 presets (suicide-chess,
    capture-all, extinction-chess).
  - Phase E: 4 movement-variant Tier-2 presets (berolina-pawns ×2,
    bouncing-pieces ×2).
  - Phase F: lobby integration (incompatibleWith audit,
    suggestedPresets wiring, drawer category grouping), rule-
    variant e2e, docs.
  - Final Wave: 4 reviewers (oracle/deep/unspecified-high).

Explicit deferrals: royalty-transfer (T3 in design doc; needs
mid-game state toggle), PGN notation changes, ELO/ranking,
non-8×8 variants, multi-royal check-banner UX (follow-up).

Each task carries 'what to do', file anchors, verification
criteria, parallelization map, recommended agent profile, and
commit message template — designed so an agent can pick up task
X.Y without re-reading the whole plan.
2026-04-20 18:15:36 -06:00
abc5c863fd
fix(modifiers): close client schema/server schema drift + add parity test (Q4.2)
The T3 audit flagged the Zod v3 / v4 hand-mirrored schemas as a
silent-drift risk: a bug where the client accepts what the server
rejects (or vice versa) would silently degrade gameplay rather than
fail loudly.

New cross-package parity test at
packages/server/src/custom-modifier-wire-parity.test.ts imports BOTH
schemas and asserts they agree on an accept/reject matrix of 17 cases
(valid minimal, valid rich, valid optional-field combinations + 12
rejection cases covering type/version/name/description/uiForm/source
literals, bounds, empty id, oversized primitives/description, and
primitive-node empty-kind). A final round-trip case parses on the
client, JSON-serializes, and parses on the server — catching
stringification edge cases too.

First run surfaced a real drift: the client schema was missing the
primitives.max(50) cap that the server schema enforces. A malicious
or buggy client could construct an oversized descriptor, get past
local validation, then hit the server's rejection. Fixed by adding
matching caps (primitives.max(50) + targetAttrs.max(32) +
author.max(80)) to the client schema.

Barrel export: chess/src/index.ts now re-exports
CustomModifierDescriptorSchema + EffectPrimitiveNodeSchema + the
parse/serialize helpers so the server parity test can import them
without reaching into subpaths.

1400 → 1417 unit tests.
2026-04-20 17:53:22 -06:00
c8d7480a26
feat(server): host-only custom modifier registration (Q4.4)
Before this commit ANY authenticated player in the room could send
custom-modifier.register and have the server accept + broadcast the
descriptor — including an opponent mid-match. Fill the 10-slot
per-room cap with hostile descriptors, or register a descriptor the
host then applies and finds unexpected.

Fix: Room gains a hostToken field set at room.create (the creator's
token). The register handler gates on room.hostToken === ws.data.token.
Non-host registrations are rejected with BAD_TOKEN and a message
explaining the gate.

Host permissions are stable across reconnects — the host's token is
preserved in sessionStorage on the client, so closing + reopening
the host's tab retains the permission. A later 'transfer host' flow
can mutate hostToken; no such mutation exists today (lobbies have a
single creator who remains host for the room's lifetime).

New server test 'rejects non-host (opponent) registrations with
BAD_TOKEN (Q4.4)' seeds a white+black room, has black try to
register (rejected), then white succeeds (proving the gate doesn't
leak across players).

1399 → 1400 tests.
2026-04-20 17:49:55 -06:00
019f3987b4
feat(engine): primitive seed manifest + consumer integrity check + zombie cleanup
T3 audit follow-ups Q3.2 (declared consumers), Q4.6 (zombie fact
cleanup on capture), Q4.7 (aura convergence semantics locked in).

Q3.2 — Load-time consumer integrity check:
  - EffectPrimitive gains optional seedsAttrs + seedsAttrsFor fields
    declaring which ChessAttrKey facts a primitive writes during
    apply(). Every T3 primitive now annotates its seeds:
      * Static (single attr always written): reflect-damage,
        absorb-damage-with-attribute (the two control facts),
        add-aura, add-direction, set-capture-flag, modify-movement-
        range, override-promotion, block-move-type, on-turn-start,
        on-capture, on-damaged, conditional.
      * Dynamic (attr is a param): seed-attribute, add-to-attribute,
        multiply-attribute, absorb-damage-with-attribute (also
        dynamic via params.attr for the charge-holding attribute).
  - new primitives/manifest.ts:
      * getStaticPrimitiveSeedManifest() union of all static
        seedsAttrs, cached after first call.
      * collectDynamicSeedsInTree(nodes) walks a primitive tree
        collecting both static and dynamic seeds, used by the
        zombie-cleanup path with the in-engine CustomModifierRegistry.
      * registerAttrConsumer(attr) + getRegisteredConsumers() for
        engine subsystems to declare 'I read this attr'.
      * assertSeedConsumerIntegrity() asserts every declared seed
        has a registered consumer; throws loudly with the full list
        of unsatisfied attrs.
  - Consumer registrations:
      * apply.ts: damage pipeline + movegen filter + trigger
        dispatchers register AbsorbDamage*, ReflectDamagePercent,
        BlockedMoveTypes, DamageResistance, CaptureFlags,
        DirectionAdditions, On*Hooks, ConditionalHooks, AuraSpec.
      * effective-attr.ts: HpBonus, RangeBonus, AuraContributions.
      * rules/promotion.ts: PromotionOverride.
  - Integrity check fires once lazily in ChessEngine constructor;
    engine boot throws if a primitive declares a seed that no
    subsystem reads. Silent-inert primitives are now impossible.

Q4.6 — Zombie fact cleanup:
  - engine.dealDamage default-kill path + piece-hp's onDamage kill
    path both extend their retract-on-death loop to include
    getEngineSeedManifest(customModifiers). Captures now fully
    clean up primitive-seeded attrs (HpBonus, AuraSpec,
    ReflectDamagePercent, trigger hooks, user-authored dynamic
    targets) in addition to the preset-declared core attrs.

Q4.7 — Aura convergence:
  - New test in auras.test.ts 'mutual auras converge in a single
    pass' locks in the declarative semantics: mutual auras each see
    the OTHER piece's contribution against a positional snapshot,
    producing commutative deltas in one compute pass. Second
    compute pass is idempotent (no cascade/loop). Decision
    documented for downstream 'aura of aura' consumers.

5 new manifest.test.ts scenarios + 1 aura convergence test.
1394 → 1399 unit tests green.
2026-04-20 17:47:38 -06:00
15c1757ff1
chore(modifiers): remove unused BUILTIN_MODIFIER_KIND_IDS export
T3 audit gap 6. Added during the T29 schema widening as a documentation
anchor; zero consumers found in the audit. The authoritative list of
built-in ids lives in MODIFIER_REGISTRY (consumed by PerTypePanel's
dropdown and the engine's apply pipeline); duplicating it here is
churn without benefit. Dropping the export narrows the package's
public surface and eliminates a drift risk.
2026-04-20 17:03:05 -06:00
4819676d84
fix(net): sync customModifiers to late-joiners + reconnects in game.state
T3 audit gap 2 (CRITICAL). The server's Room kept registered custom
modifier descriptors in a per-room Map but the game.state snapshot
carried no field for them. Impact:
  - Client A registers 'custom:shield' → server broadcasts
    custom-modifier.registered → A + any currently-connected B see it.
  - Client C joins AFTER the registration → receives game.state →
    has no knowledge of 'custom:shield'.
  - Client C's engine applies a profile with kind='custom:shield' →
    registry-dispatch fallback silently no-ops → apparent cosmetic
    modifier mismatch between A/B and C.

Symmetric fix across the wire:
  - GameStatePayloadSchema (server + client types) gains an optional
    customModifiers: CustomModifierDescriptorWire[] field.
  - Both emit sites in broadcast.ts (late-joiner path +
    reconnect-with-buffered-deltas path) include the room's registered
    descriptors.
  - PredictionManager.applyFullState mirrors received descriptors
    onto the fresh engine's customModifiers registry before handing
    control to the UI. Unknown descriptor shapes are accepted as-is
    (the wire-shape cast at the single boundary bridges the Zod v3/v4
    type split same as the custom-modifier.registered subscriber).

E2E regression guard (Oracle Q4.1 recommendation): new scenario
'late-joiner + reconnect receive registered custom modifiers in
game.state'. Host creates + registers, opponent joins AFTER
registration, asserts opponent's game.state carries the descriptor.
Would have caught the pre-fix behaviour as a test failure instead of
a manual audit find.

1393 unit + 19/19 custom-modifiers e2e green.
2026-04-20 16:58:48 -06:00
33b5910839
fix(engine): wire consumers for AuraContributions / ReflectDamage / BlockedMoveTypes
T3 audit gaps 3, 4, 5 — three primitives that wrote facts but whose
facts no engine subsystem read, making the primitives inert despite
passing validation.

Gap 3 — AuraContributions consumer wiring:
- New modifiers/effective-attr.ts: getEffectiveNumericAttr(session,
  pieceId, attrName) reads direct fact + layers AuraContributions[attr]
  delta on top. Zero allocation on the no-aura path.
- rules/sliding.ts getRangeMaxSteps now reads through the helper so
  aura-granted RangeBonus extends piece range.
- modifiers/reconcile.ts clampHpToNewMax now reads through the helper
  so aura-granted HpBonus raises the HP clamp ceiling.

Gap 4 — ReflectDamage consumer wiring:
- modifiers/apply.ts integration preset's onDamage hook:
  ReflectDamagePercent check runs BEFORE absorb/resistance
  short-circuits (matches 'magical thorns' mental model — fully
  absorbed hits still reflect). Rounds damage down via Math.floor
  (50% of 1 = 0, matches partial-resistance rounding). Routes
  reflected damage back to attacker via engine.dealDamage with
  kind='reflect'. Uses queueMicrotask to defer so we don't recurse
  during the current damage event's resolution.
- Swallows errors on the reflected call — attacker may have been
  retracted between scheduling and execution (reflection against a
  dead target is a no-op, not an error).

Gap 5 — BlockedMoveTypes consumer wiring:
- modifiers/apply.ts integration preset's filterMoves hook extended
  with a mover-side check. Reads BlockedMoveTypes on the MOVING
  piece once per piece-move generation; drops any move whose
  classification (capture / step / slide) is in the blocked set.
  classifyNonCaptureMove helper: Chebyshev distance ≤ 1 = 'step',
  > 1 = 'slide'. Knight jumps count as slides (documented).

Oracle Q3.1: 6-scenario primitive-smoke-test matrix in
consumer-integration.test.ts asserts observable gameplay delta for
each of gaps 3-5 (aura HP clamp, aura range, reflect, partial
reflect rounds down, blocked capture, blocked step). Catches this
class of silent-inert regression for future primitives.

1387 → 1393 unit tests.
2026-04-20 16:53:18 -06:00
7cc9617f3e
fix(server): widen ModifierKindIdSchema to accept custom modifier ids
T3 audit gap 1 (BLOCKER). The server's ModifierKindIdSchema was still
z.enum([built-ins]) even after the client-side mirror was widened
during T29. Effect: any multiplayer room.create or
modifier-profile.update carrying a profile whose perType[i].kind is a
user-authored CustomModifierId (e.g. 'custom:shield') was rejected
server-side as INVALID_MESSAGE.

Symmetric fix: accept any non-empty string, defer kind validity to the
engine's registry-dispatch fallback at apply time (MODIFIER_REGISTRY
→ engine.customModifiers → warn-and-skip). The built-in id list is
preserved as a doc comment + void-referenced constant so it can't
silently drift.

Test updated: 'rejects a profile with unknown modifier kind' inverted
to 'accepts a profile with arbitrary modifier kind (T3 widening)' +
new 'rejects empty-string modifier kind' to preserve the min(1) guard.
2026-04-20 16:43:38 -06:00
6cddb1dcd0
chore(sisyphus): T3 Final Verification Wave — all reviewers APPROVE
F1 Plan Compliance Audit — APPROVE
  Primitives [15/15] | Tasks [17/17 top-level] | ADRs [7/7]

F2 Code Quality Review — APPROVE
  Build [PASS] | Lint [PASS] | Tests [1386 pass] | No 'as any' / '@ts-ignore'
  in non-test source | Registry-dispatch pattern throughout (no
  hardcoded kind switches)

F3 Manual QA — APPROVE
  e2e [79/79] including 18/18 custom-modifiers.spec.ts scenarios
  (plan called for 15, shipped 18). All former fixmes passing.

F4 Scope Fidelity — APPROVE
  Recursion cap [3, enforced by MAX_RECURSION_DEPTH in validate.ts]
  Primitive count cap [50, enforced by MAX_PRIMITIVE_COUNT in
  validate.ts + server Zod .max(50)]
  Per-room cap [10, enforced by CUSTOM_MODIFIER_ROOM_CAP in
  broadcast.ts]
  Per-engine custom registry [CustomModifierRegistry owned by
  ChessEngine, never global — cross-room leakage structurally
  impossible]
  T4 smuggling [CLEAN — scripted type is rejected in both
  validate.test.ts and schema.test.ts; no runtime scripted
  descriptor shipped]

T3 boulder complete.
2026-04-19 22:04:36 -06:00
babee38702
feat(ui): share custom modifier with multiplayer room — closes T29 fixmes
Threads the multiplayer publisher all the way from useMultiplayerGame
down through GameView → RulesDrawer → ModifierProfileEditor →
CustomModifierEditor, surfacing a Share with Room button in the
custom modifier editor when (and only when) the editor was opened
from a multiplayer game.

Wiring summary (top-down):
- useMultiplayerGame.ts: returns sendRegisterCustomModifier(descriptor),
  a thin wrapper around the GameClient.sendRegisterCustomModifier
  helper added in the previous commit.
- useMultiplayerGame.ts: onError handler surfaces CUSTOM_MODIFIER_INVALID
  and CUSTOM_MODIFIER_LIMIT as toasts on top of the existing in-game
  error banner so the user notices the rejection immediately.
- GameView.tsx: GameEngineState gains an optional
  sendRegisterCustomModifier field; the multiplayer destructure
  pulls it out and passes it to RulesDrawer as
  onShareCustomModifierWithRoom (omitted in solo, where the prop is
  undefined and Share UI doesn't render).
- RulesDrawer.tsx: optional onShareCustomModifierWithRoom prop;
  conditionally forwards to ModifierProfileEditor.
- ModifierProfileEditor.tsx: optional onShareCustomModifierWithRoom
  prop; conditionally forwards to CustomModifierEditor as onShareWithRoom.
- CustomModifierEditor.tsx: when onShareWithRoom is provided, renders
  a green Share with Room button in the header alongside Save. Click
  invokes the publisher with the current descriptor; toast confirms
  the share landed (server broadcast is the actual proof, observed
  by the local PredictionManager subscriber registering the descriptor
  on the engine's customModifiers registry).

E2E coverage (both formerly-fixme tests now PASS):
- multiplayer custom modifier sharing — both clients see the
  registered descriptor: opens two browser contexts via raw WS
  (matches modifier-profiles.spec.ts MP pattern), host registers a
  descriptor after both reconnect-by-token complete, both sides
  observe custom-modifier.registered.
- server rejects custom modifier with > 50 primitives — error event
  observed: host registers a 51-primitive descriptor, asserts an
  INVALID_MESSAGE / CUSTOM_MODIFIER_INVALID error is observed and
  no broadcast fires.

Final state: 79/79 e2e + 1386 unit tests, zero fixmes, zero skipped.
2026-04-19 21:56:34 -06:00
8fb5669c9a
chore(sisyphus): mark T3 Wave 5 complete (T29-T32) 2026-04-19 21:38:56 -06:00
63c46a3f9e
feat(net): client subscriber + send method for custom-modifier broadcast
Wires the T24 server-side custom-modifier.register handler all the way
through to per-engine custom registries on every connected client.

net/types.ts:
- new CustomModifierDescriptorWire interface mirroring the chess-side
  CustomModifierDescriptor (structurally identical; Zod-mirrored across
  the v3/v4 boundary).
- new CustomModifierRegisterPayload + CustomModifierRegisteredPayload.
- ServerMessage union extended with custom-modifier.registered envelope.
- ClientMessage union extended with custom-modifier.register envelope.

net/client.ts:
- GameClientEvent union extended with custom-modifier.registered.
- handleMessage dispatch switch routes the event to listeners.
- new GameClient.sendRegisterCustomModifier(descriptor) helper that
  ships the message under the active room code; silent no-op when
  the client isn't in a room (mirrors sendMove's pre-connect guard).

net/prediction.ts:
- PredictionManager subscribes to custom-modifier.registered. On
  receipt, registers the descriptor onto BOTH baseEngine.customModifiers
  AND predictedEngine.customModifiers (when present) so subsequent
  profile applies and reconciliation from a future game.state can
  resolve the kind. Triggers an onStateChange so the UI re-renders.

Two e2e fixmes remain — both depend on a CustomModifierEditor button
that calls sendRegisterCustomModifier when the editor is opened from
a multiplayer game. The wire is fully implemented; only the editor's
multiplayer-aware send-button surface is missing. Documented as a
T3.1 follow-up in the e2e fixme comments.
2026-04-19 21:38:32 -06:00
747d0fb728
feat(engine): wire trigger primitives + absorb-damage into runtime pipelines
T3 follow-up addressing 4 of the 6 e2e fixmes (T29). Ships the engine
wiring that was deferred in T3's original scope per the implementation
retrospective.

New triggers.ts exports four dispatchers + an HP snapshot helper:
- fireOnTurnStartHooks(engine, whoseTurn): walks every piece of the
  given color, runs each OnTurnStartHooks entry as a primitive list.
- fireOnCaptureHooks(engine, attackerId): runs OnCaptureHooks for the
  attacker piece. attackerId is captured in onBeforeMove (engine.moveLog
  isn't yet populated when onAfterMove fires, so we can't read from it).
- fireOnDamagedHooks(engine, preMoveHp): compares post-move Hp facts
  against a pre-move snapshot; pieces whose Hp dropped (or whose Hp
  fact was retracted = died) get their OnDamagedHooks fired.
- fireConditionalHooks(engine): re-evaluates every ConditionalHook's
  condition against current piece state; runs the matching then/else
  branch.
- snapshotHp(session): freezes Hp facts at the supplied phase for the
  on-damaged dispatcher to compare against.

Conditions supported: attr-lt, attr-gt, attr-eq, always, never (matches
the ConditionSpec union from T14).

Each dispatcher recurses through nested primitives via the same
PRIMITIVE_REGISTRY lookup the descriptor applier uses, with the runtime
depth cap (8) as a backstop. Trigger evaluation has no parent
descriptor — synthesised __trigger__ ref fills the contract.

Integration in apply.ts (__modifier-profile-integration__ preset):

- onBeforeMove: snapshots Hp + the attacker pieceId (when isCapture).
  Both stored in WeakMaps keyed by engine so concurrent engines
  (server-authoritative + client-predicted) keep independent state.
- onDamage: NEW absorb-damage-with-attribute branch BEFORE the existing
  DamageResistance branch. When AbsorbDamageAttr+AbsorbDamageRate are
  set on the target, incoming damage spends the attribute first;
  full absorption short-circuits with consume:true died:false.
  Partial absorbs fall through (same documented limitation as
  partial DamageResistance).
- onAfterMove: now runs computeAuraFacts (T28, unchanged) + four
  trigger dispatchers in order:
    fireOnDamagedHooks → fireOnCaptureHooks → fireConditionalHooks
    → fireOnTurnStartHooks (for the next-mover's color)

7 vitest scenarios in triggers.test.ts cover each dispatcher
including the absorb-damage shield (3 charges → 0 → fall through).
2026-04-19 21:38:01 -06:00
fb6170127e
test(e2e): custom modifier DSL vertical slice + integration fixes
T3 Wave 5 (T29). New Playwright suite at e2e/custom-modifiers.spec.ts
covering 16 scenarios across the user-facing flows:

- Editor opens from the Modifier Profile editor header
- Palette click adds primitive to tree
- Save button reflects validator state (disabled when name empty)
- Custom modifier appears in PerType kind dropdown
- Selecting a custom kind shows the summary card
- Library survives page reload
- Solo game with custom-kind profile renders modifier indicators
- Multi-profile stack: stack two profiles, remove an entry, reorder
- Aura primitive: page survives onAfterMove recompute
- Library cap holds at 20 entries

Plus 4 trigger primitive scenarios (formerly fixme, unblocked by the
trigger evaluator wiring committed alongside):
  - on-turn-start nested primitives fire at turn boundary
  - on-capture nested primitives fire on capture
  - conditional evaluates and runs matching branch
  - absorb-damage-with-attribute integrates with damage pipeline

2 fixmes remain — both blocked on the editor-side multiplayer send UI
(server + client wire-side is fully implemented in this commit).

Integration fixes uncovered while writing the suite:

1. ModifierKindIdSchema widened from z.enum([built-ins]) to z.string().min(1).
   The pre-T3 enum silently rejected every profile that referenced a
   custom modifier id (e.g. 'custom:my-shield'), causing library load
   to drop the entry and the picker to have no option. Validity is
   now enforced at apply time via the registry-dispatch fallback
   (MODIFIER_REGISTRY → engine.customModifiers → warn-and-skip).

2. Lobby.resetToFreshGame now passes loadCustomModifierLibrary()
   results to ChessEngine.opts.customModifiers so a profile that
   references a custom kind can resolve at apply time. Without this
   the apply silently no-opped the custom-kind entries and indicators
   never rendered.

Schema tests updated: the 'rejects unknown modifier kind' test flipped
to 'accepts arbitrary kind strings (T3 widening)' with explanatory
JSDoc; an empty-string-rejection test added to preserve the min(1)
guard.

77 e2e + 1386 unit tests green; 2 honest fixmes documented.
2026-04-19 21:37:23 -06:00
52752ecf33
docs(adr): T4 scripted modifiers forward-design
T3 Wave 5 (T32). New forward-design document at
docs/adr/T4-scripted-modifiers-design.md capturing where T4 would land
if/when it becomes a priority:

- Why T4 is deferred (security surface area)
- Sandbox candidates evaluated (QuickJS recommended; Duktape, vanilla
  WASM, custom interpreter, Web Workers, vm2 / isolated-vm rejected
  with rationale)
- Descriptor shape extension preserving T3 backwards compatibility
  via the type: 'data' | 'scripted' discriminator already reserved
  on CustomModifierDescriptor
- Permission model sketch (read/write self/board, history, effects,
  random — granted/prompt defaults per permission)
- Validation strategy (static analysis + runtime sandbox enforcement,
  whitelist over blacklist for forbidden globals, source/AST size
  caps, loop-bound checks)
- T3 → T4 ejection path (a T3 descriptor can generate equivalent
  scripted source as a starting point)
- 7 open questions blocking T4 kickoff (DSL surface, multiplayer
  determinism, editor experience, sharing trust, rate limiting,
  versioning, failure mode)
2026-04-19 21:09:52 -06:00
6dd5eb17ce
docs(user): custom modifier DSL user guide
T3 Wave 5 (T31). New user-facing guide at docs/user/custom-modifiers.md
covering:
- Opening the editor + 3-column workspace
- All 15 effect primitives (state / mechanic / advanced) with one
  example per primitive
- Composing primitives (simple boosted-pawn → medium shield → complex
  aura king)
- Saving + loading from the local library
- Using a custom modifier in a profile (panel kind dropdown)
- Multi-profile stacking (solo-only T3 limitation noted)
- Aura semantics (Chebyshev distance, recompute cadence)
- Limits and DoS guards (50 primitives, depth 3, 20 per library, 10
  per multiplayer room)
- T3 limitations called out inline (trigger primitives seed but don't
  yet fire; AuraContributions written but not yet consumed)
2026-04-19 21:09:24 -06:00
1e69675596
docs(adr): T3 implementation retrospective 2026-04-19 21:08:01 -06:00
dcd782fa5a
chore(sisyphus): mark T3 Wave 4 (UI) complete 2026-04-19 20:23:21 -06:00
31af101b55
feat(ui): multi-profile stacking in lobby
T3 Wave 4 (T27). Lobby now supports stacking multiple modifier
profiles for solo play. The primary picker stays single-select for
backwards compatibility with multiplayer create/join (which sends
exactly one ModifierProfile on the wire today); a 'Stacked (solo
only)' list below it lets the user append additional profiles, with
up/down reorder and remove buttons per entry.

State:
- additionalProfiles: ModifierProfile[] holds the stack-on-top
  entries. Empty by default; appears in the UI only when the primary
  picker has a selection.

resetToFreshGame branches:
- 0 or 1 profile total → unchanged single-profile fast path through
  EngineOptions.profile (preserves T1/T2 behaviour exactly).
- 2+ profiles → constructs a profile-less engine, then runs
  applyProfilesToSession across the full ordered stack so additive
  contributions compose correctly across profiles. Sets activeProfile
  to the LAST entry as a UI-display canonical (header badge etc.).

UI:
- Primary picker unchanged.
- profile-stack list with data-testids profile-stack, profile-stack-{i},
  profile-stack-{i}-up/down/remove for e2e access.
- profile-stack-add dropdown filters out already-selected ids.

Multiplayer wire shape unchanged — sending the stack to the server is
a future T27 extension that would touch the protocol.

E2E for the new stacking UI defers to T29; existing 9/9 solo-smoke
+ all 1378 unit tests pass.
2026-04-19 20:22:58 -06:00
9b586b83b5
feat(ui): custom modifiers in modifier profile panels
T3 Wave 4 (T26). PerTypePanel and PerInstancePanel kind dropdowns now
include the user's custom modifier library alongside built-ins.

- Kind dropdown uses <optgroup> separation: 'Built-in' (MODIFIER_REGISTRY)
  + 'Custom (from library)' (loadCustomModifierLibrary).
- Selecting a custom kind hides the per-instance value input and shows
  a small summary card ('Custom modifier — N primitives. Edit it in
  the Custom Modifier editor.') — the descriptor's primitive list IS
  the payload; per-instance value is null.
- Custom kinds skip Zod-schema-based validation (they have no
  per-instance value to validate); isValid is true once a custom
  kind is selected.
- handleSave / handleAdd branches on isCustomKind: built-in path
  preserves T1/T2 behaviour exactly; custom path emits {kind: id,
  pieceType, color, value: null}.
- PerInstancePanel mirrors the same pattern in AddModifierForm.

E2E tests for the new UI defer to T29; existing 1378 unit tests pass.
2026-04-19 20:20:03 -06:00
cbe4a4b5f6
feat(ui): custom modifier editor
T3 Wave 4 (T25). 3-column visual primitive composer for authoring
custom modifier descriptors from the 15 T3 effect primitives.

- Left palette: 15 primitives grouped by category (State / Mechanic /
  Advanced); click adds to the descriptor's primitive list.
- Center tree: shows current primitives[]; click selects, delete
  button per node.
- Right inspector: parameter form per selected primitive. Introspects
  the primitive's Zod schema to render typed inputs (number / string /
  boolean / enum / array). Falls back to a JSON textarea for complex
  param shapes (e.g. nested EffectPrimitiveNode arrays).
- Header: descriptor name/description inputs + Save/Load library +
  live validation status from validateCustomDescriptor.
- Open from ModifierProfileEditor's header via the new + Custom
  Modifier button (data-testid open-custom-modifier-editor).

Type widening: ModifierKindId gains '| (string & {})' so the kind
field on TypeModifier/InstanceModifier accepts custom descriptor ids
without losing literal-completion on built-in kinds. The
applyCollectedContributions dispatcher already handles arbitrary
strings via its custom-registry fallback (T22).

Lint cleanup: replaced 4 'as any' casts on Zod internals with named
ZodObjectInternal / ZodWrappedDefInternal / ZodEnumDefInternal
structural shapes — auditable in one place if Zod renames _def.

E2E + extended tests deferred to T29; T26 (panel kind-dropdown
extension) and T27 (multi-profile lobby) shipped separately.
2026-04-19 20:17:33 -06:00
28b11f342d
chore(sisyphus): mark T24 + T28 complete 2026-04-19 20:06:24 -06:00
9441570349
feat(engine): aura effect computation + onAfterMove hook
T3 Wave 4 (T28). Wires the add-aura primitive's seeded AuraSpec facts
into a runtime recomputation that produces AuraContributions on
affected pieces every move.

- computeAuraFacts(session):
    1. Retracts every AuraContributions fact (clean slate).
    2. Walks every piece with an AuraSpec list and, for each aura,
       finds all in-range pieces via Chebyshev (king-move) distance
       and accumulates deltas per (targetId, targetAttr).
    3. Commits the staging map as AuraContributions = { attr → delta }
       on each affected piece. Empty maps are NOT written, so
       unaffected pieces return undefined for session.get(id, 'AuraContributions').
- New ChessAttrMap entry: AuraContributions = Readonly<Record<string, number>>.
- __modifier-profile-integration__ preset gains an onAfterMove hook
  that calls computeAuraFacts(session) after every successful move.
  Self-application is skipped; source moving out of range retracts
  contribution on next recompute.

9 vitest scenarios: neighbour coverage, out-of-range exclusion,
multi-source accumulation, self-application skip, stale retraction
on source move, idempotency, multi-attr per source, empty-state
no-op, and the engine end-to-end wiring via onAfterMove.

Consumer wiring (HpBonus + AuraContributions[HpBonus] compose at
effective-attr read points) is deferred — this task delivers the
infrastructure and the recompute cadence; downstream readers
integrate on an as-needed basis.
2026-04-19 20:05:38 -06:00
109be25be6
feat(server): custom-modifier.register WS handler
T3 Wave 4 (T24). Adds wire protocol + server handler for registering
user-authored custom modifier descriptors on a room's per-engine
custom registry (ADR-4).

Protocol:
- CustomModifierDescriptorSchema (Zod v3 mirror of the chess-side
  schema; structural validation only — primitive-kind semantics stay
  client-side to avoid duplicating the primitive catalog across the
  zod v3/v4 boundary).
- 'custom-modifier.register' client message carrying { roomCode,
  descriptor }.
- 'custom-modifier.registered' server broadcast mirroring the same
  shape out to every connected client in the room.
- New error codes: CUSTOM_MODIFIER_INVALID, CUSTOM_MODIFIER_LIMIT.

Handler:
- Auth + room-match checks matching the rest of the WS surface.
- Lazy per-room Map<id, CustomModifierDescriptorWire> created on first
  register.
- Capacity cap at 10 distinct ids per room (T3 DoS guard). Re-register
  with the same id REPLACES without consuming a new slot.
- On success: broadcasts to every connected client (proposer included
  — confirms server-authoritative state).

6 integration tests (mock ServerWebSocket harness; same pattern as
ws.modifier-profile-update.test.ts): happy-path accept+broadcast,
same-id replace, 11th-distinct rejection, roomCode mismatch, no-auth
rejection, malformed descriptor (schema layer). All green; 1369 unit
tests pass. Engine-side wire-up (late-joiner mirroring, client client.ts
subscriber) deferred to T25/T26 UI work.
2026-04-19 20:01:11 -06:00
2b641c78bb
chore(sisyphus): mark T3 Wave 3 (validator/schema/library/apply/stacking) complete 2026-04-19 18:12:43 -06:00
1d5efaa95f
feat(engine): apply custom modifier descriptors + multi-profile stacking
T3 Wave 3 (T22 + T23). Two tightly-coupled deliverables landed in one
commit because the second's API surface depends on the first's signature
extensions:

T22 — Custom descriptor application
- new CustomModifierRegistry (per-engine, in custom/registry.ts) — ADR-4
  isolation: descriptors registered on engineA never leak to engineB.
- new applyCustomDescriptor(engine, session, pieceId, descriptor) walks
  primitive nodes, dispatches each kind through PRIMITIVE_REGISTRY,
  and recurses into nested children via childPrimitives() with depth
  tracking (mirrors T19's static depth guard at runtime).
- ChessEngine gains a customModifiers field + opts.customModifiers in
  EngineOptions for bootstrap registration.
- applyProfileToSession's signature widens to accept (..., engine?,
  customRegistry?) — when a profile entry's kind misses MODIFIER_REGISTRY,
  the custom registry is consulted as a fallback. Existing T1/T2
  callers stay source-compatible (the new params are optional).

T23 — Multi-profile stacking
- collectProfileContributions: pure value-collection helper extracted
  from applyProfileToSession's body.
- new applyProfilesToSession(session, profiles[], layout, engine?,
  customRegistry?) iterates the helper across every profile in order
  before stacking — built-in stacking rules apply across the union.
- new reconcileProfilesSwap mirrors the same generalization for the
  retract-then-reapply hot-swap path.
- single-profile applyProfileToSession / reconcileProfileSwap remain as
  thin wrappers calling the array versions with [profile].

14 vitest scenarios cover: single-primitive apply, multi-primitive
apply, nested-children walk via on-turn-start, unknown-kind tolerance,
custom-registry fallback in applyProfileToSession, per-engine isolation,
constructor pre-registration, two-profile additive stacking, mixed
built-in + custom across profiles, single-profile passthrough, empty
array no-op, and CustomModifierRegistry CRUD.

Engine wiring (damage pipeline, turn-start hooks, aura recompute) is
deferred to T28 — primitives currently SEED facts that those wires
will observe.
2026-04-19 18:12:19 -06:00
795207e8b4
feat(engine): custom modifier library persistence
T3 Wave 3 (T21). localStorage-backed library mirroring the T1 modifier-
profile library at modifiers/library.ts:

- Storage key: houserules:custom-modifiers:v1
- Capacity: MAX_ENTRIES (20) with starred-aware FIFO eviction; refusal
  with a human reason when every slot is starred.
- API: loadCustomModifierLibrary, saveToCustomModifierLibrary (runs
  T19's validateCustomDescriptor before persisting; rejects with the
  validator's error list on failure), removeFromCustomModifierLibrary,
  setCustomModifierStarred.
- Auto-populates descriptor.createdAt on first save; preserves it
  across subsequent saves so library order stays stable.
- Per-entry shape failures during load are silently dropped (matches
  T1 library behaviour — one bad row never blocks the drawer).

12 vitest scenarios cover round-trip, malformed-JSON tolerance,
update/remove/star, validator rejection, capacity eviction, and
all-starred refusal.
2026-04-19 18:02:35 -06:00