Combines T3 (consumer registrations for the 7 new hook attrs) and T4
(pre-move state snapshots) since both modify apply.ts and gate the
incoming wave of trigger primitives.
T3 — Consumer registrations:
Adds registerAttrConsumer() calls for the 7 trigger hook attrs added
to ChessAttrMap (OnMoveHooks, OnTurnEndHooks, OnPromotionHooks,
OnCheckReceivedHooks, OnCheckDeliveredHooks, OnMovedOntoSquareHooks,
OnCapturedHooks). Without these, the load-time
assertSeedConsumerIntegrity() check would fail loudly when the
upcoming Wave 2 primitives start writing the attrs.
T4 — Pre-move snapshots:
Two new per-engine WeakMaps mirror the existing PRE_MOVE_HP_SNAPSHOTS
lifecycle:
- PRE_MOVE_CHECK_STATE_SNAPSHOTS: per-color royal IDs + their
attacker IDs at onBeforeMove time, used by the upcoming
on-check-received (edge-triggered transition into check) and
on-check-delivered (revealed-attacker discovered check) evaluators.
- PRE_MOVE_PROMOTION_PAWNS: pawn IDs eligible to promote this move
(white pawns on rank 6, black pawns on rank 1) — feeds the upcoming
on-promotion evaluator without re-walking the board post-move.
Snapshots populate in onBeforeMove, expose read-only getters
(getPreMoveCheckState, getPreMovePromotionPawns), and clear in
onAfterMove inside try/finally to prevent leaks even if a trigger
evaluator throws. Reuses the engine's existing attackProbe helper
(via PIECE_TYPE_REGISTRY) and getActiveRoyalEntityIds for preset-aware
royal resolution rather than computing check lines from scratch.
Adds 4 new tests in apply.test.ts: snapshot capture, promotion-pawn
flagging, post-move cleanup, and no-leak across 3 sequential moves.
Adds two new REQUIRED fields to PrimitiveApplyContext that downstream
trigger primitives (on-captured target redirection, on-promotion event
metadata) need:
- target: TargetResolver — 'self' (default) | 'attacker' | 'defender'
| { squares: Square[] } | { relation: 'ally' | 'enemy', filter? } —
with resolveTargets() helper that walks the session and returns the
concrete EntityId list per relation/filter. 'self' returns
[ctx.pieceId] for byte-identical behavior in existing primitives.
- event: PrimitiveEvent | undefined — discriminated by 'kind':
promotion ({promotedFrom, promotedTo}) and capture ({attackerId,
defenderId}). 'attacker'/'defender' targets throw a clear error if
the event is missing rather than silently no-op'ing.
The fields are REQUIRED (not optional) — every construction site must
populate defaults explicitly. Production sites (custom/apply.ts and
triggers.ts:67) populate target='self', event=undefined; trigger
evaluators added later (T12) override per-event. Test fixtures across
the 14 existing primitive .test.ts files were updated via AST-grep to
add the same defaults (15 helper invocations).
Also re-exports TargetResolver from schema.ts so OnCapturedHooks can
reference the canonical type from a single source. Earlier wave-1 work
landed a placeholder type there; this consolidates around context.ts.
Adds context.test.ts with 13 tests covering self, squares, ally/enemy
relations with and without pieceType filter, attacker/defender event
resolution, and the missing-event error path.
The server's EffectPrimitiveNodeWireSchema already uses kind:
z.string().min(1) by design (see comment block in protocol.ts:525-538),
so new primitive kinds do NOT require server schema changes. This
commit adds parity-test fixtures proving that holds for each of the
incoming trigger kinds.
Adds 10 positive fixtures exercising on-move, on-turn-end,
on-promotion, on-check-received, on-check-delivered, both filter
variants of on-moved-onto-square (squares list + file/rank predicate),
both target variants of on-captured ({relation:'ally'} + 'attacker'),
and a nested on-capture→on-move composition.
Plus one 51-primitive negative fixture using new kinds, confirming the
.max(50) cap applies uniformly. Squares throughout use the numeric 0..63
convention (e4 = 28, d5 = 35), matching the engine's Square type.
Documented a spec vs implementation divergence: wire schemas accept
depth-4 because params is z.unknown() — depth enforcement lives in the
client-side validator (validate.ts MAX_RECURSION_DEPTH = 3). A positive
depth-4 fixture now pins this contract explicitly.
Moves the 247-line PrimitiveInspector function out of the 872-line
CustomModifierEditor (now 533 lines) into its own ParamField module so
the upcoming visual builder can reuse the same Zod-introspecting form
renderer without coupling to the form-mode editor's three-pane layout.
Discipline:
- Snapshot test seeded under the original PrimitiveInspector first;
component then extracted; snapshots re-verified byte-identical.
- Renamed export from PrimitiveInspector to ParamField; props,
data-testids, classNames, and Zod-internal access patterns preserved
verbatim — including the existing block-move-type and override-promotion
Zod-v4 enum bug (out of scope to fix here).
- Internal helpers (isAttrFieldName, attrFieldPreferredType,
attrFieldMode, collectSeededAttrs, ATTR_FIELD_NAMES) move with the
component; generateDefaultParams stays in CustomModifierEditor since
the palette button still uses it.
Snapshot harness uses react-dom/server#renderToStaticMarkup for
deterministic SSR — no @testing-library dependency added. vitest.config
include pattern widened to *.test.tsx alongside *.test.ts.
Pure tree-walker that converts CustomModifierDescriptor (or just an
EffectPrimitiveNode array) to a human-readable English narrative.
- Static KIND_NARRATORS map covers all 21 primitives (14 existing + 7
T1-extension trigger kinds: on-move, on-turn-end, on-promotion,
on-check-received, on-check-delivered, on-moved-onto-square,
on-captured) — no PRIMITIVE_REGISTRY lookup so the module stays free
of engine/Session/Rete imports.
- Cycle guard via WeakSet on node identity outputs '…' on revisit;
length cap 4000 chars truncates with ' … and N more primitives'.
- Performance: 0.091ms avg on 50-node descriptor (11x under the 1ms
budget for live preview).
- 34 tests cover every kind, nested combinations, cycle handling,
truncation, and perf microbenchmark.
Used by the live-preview pane (T17) added later in this epic.
The Modifier Profile editor's Presets tab surfaced the plumbing
preset '__modifier-profile-integration__' — an internal hook bundle
that wires modifier-profile facts (CaptureFlags, DirectionAdditions,
DamageResistance, on-damage / on-capture / aura triggers) into the
engine runtime. ChessEngine auto-activates it when a profile is
supplied; toggling it from a profile has no meaningful effect and
confuses users who see 'Modifier Profile Integration' as a bundle-able
rule variant.
Filter any preset id matching the '__x__' double-underscore naming
convention out of:
- PresetPanel's allPresets list (the Presets tab in the editor)
- LayoutPicker's suggestedPresets chip row (in case a layout ever
accidentally lists one)
Generalized so future internal presets following the same convention
are auto-hidden without maintaining an exclusion list. New unit test
(PresetPanel.filter.test.ts) pins the contract: registry contains the
internal preset, filter yields a non-empty user-safe list, known
public preset (extinction-chess) survives.
The Modifier Profile editor rendered four visible columns in practice:
PerType | board | square-detail sidebar | library/presets. The inner
split inside PerInstancePanel (board on the left, w-72 detail sidebar
on the right) was the culprit — it squeezed the 8x8 board into a
cramped square the moment a piece was selected.
Restructure PerInstancePanel so the board stacks ABOVE the detail
strip instead of beside it:
- Board centered in the top region (full column width, max-w-md).
- Detail strip below: horizontal flex with existing-modifiers list on
the left and the Add Modifier form on the right, wrapping to
stacked rows on narrow widths. max-h-[45%] so the board never
loses most of its space when many modifiers are present.
- Empty state gains a short one-line prompt; the Choose Layout button
is kept for backward-compat with existing e2e tests.
Testids unchanged (per-instance-panel, per-instance-board,
instance-modifier-*, instance-modifiers-list, copy/paste-instance-modifiers,
instance-modifier-kind, instance-modifier-value, instance-modifier-add,
per-instance-choose-layout). All Playwright e2e tests continue to pass.
The header previously crammed 9 elements onto one row: title, name input,
layout picker, undo, redo, clipboard status, + Custom Modifier, Save,
Share, Close. With the Bundled Presets tab added recently, visual
hierarchy collapsed and scanning was hard.
Split the chrome into three rows with one job each:
- Row 1 (identity): title 'Profile', name input (widened, flex-grows
up to max-w-md), close button.
- Row 2 (toolbar, subhead-styled): Layout picker on the left; +Custom
Modifier, Share, and Save on the right. Save is now primary-styled
(dark filled) instead of a generic grey button in a line of five.
- Footer: undo/redo icon buttons on the left, clipboard status badge
on the right. Dim when empty, pill-styled with white background
when non-empty to reduce noise.
No testid changes — save-profile, share-profile, open-custom-modifier-editor,
undo-button, redo-button, clipboard-status, profile-name, bound-layout-picker
all remain at stable selectors. All 105 Playwright e2e tests + 1752 unit
tests continue to pass.
Profiles can now carry a list of PresetActivation entries alongside
their per-type / per-instance modifiers. When such a profile is
picked in the Lobby, its bundled presets are unioned with any
layout suggestedPresets (profile config wins on id-collision).
Changes:
- ModifierProfile.presetActivations added (optional readonly array).
Zod schema + server wire schema mirror the field; drift guard
picks up forgotten updates on either side.
- ModifierProfileEditor grows a Presets tab in the right column
sharing space with the Library tab. Each preset row carries a
checkbox, scope radio (both/white/black), and a turns counter
(blank = permanent). Inline diagnostics warn on redundant layout
overlap and on loose-scope incompatibility; hard errors under
overlapping scope disable Save.
- Lobby merges profile.presetActivations into its active preset
set on profile select, on editor close, and on URL deep-link.
- LayoutPicker suggested-preset chips now expose aria-pressed so
the active state is readable by assistive tech + e2e tests.
Tests: library round-trip preserves presetActivations (unit); two
new e2e scenarios (pre-seeded bundled profile auto-activates; full
editor-author loop persists + reflects in the lobby). 1752 unit +
105 playwright all green.
Previously the site had no favicon — browsers showed a generic tab
icon and PWA installs would have used the default. Adds:
- packages/chess/public/favicon.svg — knight on a dark rounded tile,
purpose-built for small sizes (fill-only, high contrast).
- <link rel=icon type=image/svg+xml> + apple-touch-icon wiring in
index.html so the SVG serves as the primary tab icon on every
platform (Chrome/Firefox/Safari desktop + iOS home-screen).
- theme-color (#0f172a) matching the favicon tile so mobile
browser chrome coordinates with the site's dark accent.
- meta description for search-result snippets and link previews.
Clicking a square with the palette's currently-selected piece now
deletes that piece instead of re-placing it. Makes tap-to-delete a
natural single-gesture operation without switching to the Erase
brush. A different brush-piece still replaces (unchanged behaviour).
Previously the ModifierProfileEditor's layout picker was editor-only
UI state — it drove per-instance board preview and live validation,
but was dropped on save. This meant per-instance modifiers (square-
bound to their authoring layout) silently became orphans in the Lobby
if the user picked a different layout at apply time, with no signal
about the mismatch.
Changes:
- ModifierProfileEditor now writes the bound layout through to
profile.layoutId (schema already supported the optional field) and
rehydrates boundLayout from profile.layoutId on open / load /
undo / redo.
- Lobby rearranged so the Modifier Profile picker sits ABOVE the
Layout Picker. Picking a profile with a layoutId binding snaps
selectedLayout to match. Same behavior on URL ?modifierProfile
deep-links and after the editor closes with a new save.
- Mismatch banner (amber) surfaces when the user overrides the
layout after picking a bound profile, with a one-click "Switch
to <LayoutName>" restore.
Unit test added for library round-trip of layoutId; new
profile-layout-binding.spec.ts covers the four scenarios: snap,
manual override + mismatch banner, unbound profile leaves layout
alone, and editor-save persists layoutId.
Attr-string fields in the Custom Modifier Editor now differentiate
between declare-sites (seed-attribute.attr) and consume-sites
(add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr,
absorb-damage-with-attribute.attr):
- Consume-sites hide the illustrative 'User-defined examples' group
(ArmorPlates/BloodStacks/ManaPool) because those names are fiction
unless something seeds them.
- Consume-sites surface a new 'Seeded in this descriptor' group
populated from seed-attribute primitives elsewhere in the current
tree (including inside trigger children via childPrimitives).
- Badge states: emerald 'seeded' when the typed value matches an
in-tree seed; red 'not seeded' when it's a non-schema name with no
backing seed; amber 'user-defined' only in declare-mode for
off-catalog names.
Declare-mode behaviour is unchanged — inventing ShieldCharges there
still works without warnings.
Surfaces the contents of docs/user/custom-modifiers.md directly inside
the Custom Modifier Editor so authors can compose descriptors without
cross-referencing the guide:
- Per-primitive docs panel in the Parameter Inspector with a longer
behaviour explanation + one or more worked examples (collapsible).
- Palette hover tooltips now show the full long description plus the
first example's headline.
- New 'Templates' header button opens a picker with 5 built-in recipes
(Boosted Pawn, 3-Charge Shield, Aura King, Vampire, Low-HP Fortress).
- AttrCombobox replaces plain text inputs for attr / targetAttr fields.
Grouped, free-form autocomplete over 17 curated suggestions with a
'user-defined' badge for out-of-catalog typed names so ShieldCharges-
style recipes still work.
Primitives gain optional longDescription + examples fields on their
EffectPrimitive descriptor; 15 registrations annotated. Recipe
descriptors pass the existing validator, and new unit tests enforce
doc coverage going forward.
Feature 4a of post-epic-deferrals. Introduces the PlayerAction
surface — a turn-consuming event orthogonal to LegalMove — and one
preset that uses it to transfer royalty between friendly pieces
once per game per color. Solo-only for v1 (F4b will add the WS
protocol; F4c will add the UI).
Engine surface:
- New module packages/chess/src/actions.ts exports PlayerAction
(discriminated union; starts with 'transfer-royalty' kind),
PlayerActionKind, and ActionResult {ok,error,reason}.
- ChessEngine.performAction(action): ActionResult runs a parallel
pipeline to applyMove: terminal-state guard → poll every
active preset's performAction hook (first non-undefined wins)
→ handler returns ok=false => no turn consumption → handler
returns ok=true => advanceTurnAfterMutation shared helper
(factored out of applyMove) which handles HalfMovesThisTurn
increment, shouldAdvanceTurn poll, onTurnStart fire, etc.
- ActionResult error codes: NO_HANDLER, REJECTED, INVALID_TARGET,
NOT_YOUR_TURN, GAME_OVER. Stable for future UI / protocol.
PresetDef additions:
- performAction(ctx): ActionResult | undefined — first non-
undefined wins. Handlers validate + mutate state + return.
- transformRoyalPieces(ctx, current): EntityId[] — a POST-union
transform on the accumulated royal set, letting a preset
reassign rather than append. Used by transferable-royalty to
swap transferredFrom -> transferredTo.
Preset: transferable-royalty
- category 'king'; incompat with suicide-chess + capture-all
(both empty the royal set).
- State: transferredFrom/To keyed by color — one-shot per color.
- performAction validates: fromPiece alive, currently royal,
toPiece alive, same color, not already royal, not already
transferred. Returns INVALID_TARGET / REJECTED on failure,
ok:true on success.
- transformRoyalPieces swaps old royal for new in the engine's
royal resolution; defensively drops dead ids so a transferred
royal that later died doesn't linger.
Tests:
- transferable-royalty.test.ts: 20 tests covering registration,
happy path, once-per-game cap, all INVALID_TARGET paths,
turn consumption, composition with knightmate-rules and
piece-hp.
- engine.performAction.test.ts: 7 tests covering NO_HANDLER,
GAME_OVER guard, first-match-wins, shouldAdvanceTurn veto,
performAction + applyMove interleaving.
- presets.test.ts: EXPECTED_IDS bumped; symmetry + dangling-ref
audits still green.
- capture-all.ts: reciprocated incompat with transferable-royalty
(symmetry audit).
Verification: 1699 tests passing (was 1671, +28). Typecheck + lint
clean. No regressions.
Plan: .sisyphus/plans/post-epic-deferrals.md F4a complete.
F4b (WS protocol) and F4c (UI) remain.
(Agent hit 200-tool-cap near the end; orchestrator reconciled
a missing defaultKingRoyals helper + 2 test setups that triggered
insufficient-material draws + symmetric incompat declaration.)
Feature 3 of post-epic-deferrals. Adds en-passant to berolina-pawns
and berolina-pawns-2 following the Parton 1952 variant — the most
common published ruleset. Reflects standard ep semantics through
Berolina's reversed geometry (diagonal push, orthogonal capture).
Engine:
- MoveHookContext extended with pieceId + from + to. Existing
presets (piece-hp, poisoned-squares, etc.) are purely additive
on the new fields and don't need changes. Dispatch site in
applyMove populates them from the resolved move.
berolina-pawns + berolina-pawns-2:
- overridePieceMoves: after emitting normal Berolina moves, read
the preset's ep latch (namespaced preset state). If the mover's
orthogonal-forward square equals the stored skipped square AND
the capturer color matches, emit a capture move onto that
square. Note destination is empty by construction — the engine's
getPieceAt returns null there, so the default capture path is a
no-op; onAfterMove below does the actual retraction.
- onAfterMove: two responsibilities. (1) If the mover just
accepted a latched ep (move.to === skippedSquare) retract the
stored capturedPieceId. (2) Clear the latch. (3) If THIS move
was a Berolina double-diagonal push from the home rank, record
skippedSquare + capturedPieceId (the mover's own pieceId, since
that's what the opponent can remove next turn) + capturer color.
- Scope-flip semantics preserved — scope='white' means only white
pawns emit ep moves; the latch still records for downstream, but
the opposing black pawns follow FIDE rules and don't emit it.
- berolina-pawns-2: sideways captures do NOT trigger or accept
ep (only the orthogonal-forward direction participates).
Tests:
- 5 new berolina-pawns.test.ts cases: (r) latch + ep emission,
(s) accept-ep retracts the double-pushed pawn, (t) one-half-move
window expiry, (u) single-push doesn't latch, (v) scope='white'
latch set on white's double-push.
- 3 new berolina-pawns-2.test.ts cases: (l) ep through the
extended preset, (m) retraction on accept, (n) sideways capture
does NOT set the latch.
- All 31 pre-existing Berolina tests unchanged and still pass.
Docs:
- RULES.md gallery entries: remove 'en-passant deferred' language;
document the Parton 1952 rule and the sideways-ep exclusion.
- PRESET-API.md post-landing-backlog: drop the berolina ep
deferral.
- Preset docblocks: rewrite the en-passant section to describe
the shipped mechanism + plan reference.
Verification: 1671 unit tests (+8 ep). Typecheck + lint clean.
Plan: .sisyphus/plans/post-epic-deferrals.md Feature 3 complete.
Feature 2 of post-epic-deferrals. Exposes extinction-chess's
configurable targetType through the in-game rules drawer so players
don't need to open devtools or call engine.presetState themselves.
UI:
- RulesDrawer.tsx renders a 'Target: <PieceType>' chip inside the
extinction-chess detail card when the preset is active.
Clicking advances through pawn -> knight -> bishop -> rook ->
queen -> king -> pawn (wraps). Plural labels ('Pawns', 'Knights',
...) for prose.
- data-testid='extinction-target-cycler' on the chip for e2e.
- New optional RulesDrawer props extinctionTarget +
onExtinctionTargetChange; omitted props hide the cycler (no
hard dependency on the engine).
Wiring (GameView.tsx):
- Solo-only per plan decision 2a. Multiplayer games use the
target set at room-creation time; the cycler doesn't render in
MP to avoid desync (a future preset-config.update WS message
could lift this; out of v1 scope).
- Local useState syncs with engine.presetState via a useEffect;
setExtinctionTarget writes back through the same state API and
calls refresh() so legal highlights + terminal-state panels
pick up the target flip.
Docs:
- extinction-chess.ts docblock updated to reference the shipped
UI surface + the MP deferral.
- PRESET-API.md post-landing backlog: remove the 'UI cycling'
deferral (now shipped), add the MP-target-sync deferral.
Tests:
- 2 new rule-variants.spec.ts cases: (a) cycler cycles through
all 6 labels when preset active, (b) cycler hidden when preset
inactive.
Verification: 1663 unit + 89/89 e2e (87 + 2 new). Typecheck + lint
clean.
Plan: .sisyphus/plans/post-epic-deferrals.md Feature 2 complete.
Feature 1 of post-epic-deferrals. Lets the room creator pick which
color they play: white, black, or random. Joiner always takes the
remaining color (no joiner-side preference in v1 per plan decision
1b).
Protocol:
- RoomCreatePayloadSchema gains an optional preferredColor field
with the three-value enum. Omitting the field defaults to
'white' on the server — byte-identical to pre-F1 behaviour so
legacy clients are untouched.
- Client type mirror (packages/chess/src/net/types.ts) kept in
sync; exports PreferredColor type alias.
Server:
- RoomRegistry.createRoom gains a preferredColor parameter.
'random' is resolved at room creation via Math.random() and
never leaks beyond this function — the concrete color is stored
on room.creatorColor + room.joinerColor so reconnects surface
the same assignment.
- Room interface adds creatorColor + joinerColor fields.
- JoinResult success variant widens from 'color: "black"' to
'color: Color' to accept either assignment.
- broadcast.ts threads payload.preferredColor through to
createRoom.
UI:
- Lobby.tsx adds a 3-button radiogroup ('Play as: White / Black /
Random') between LayoutPicker and the modifier-profile picker.
Active button is filled (bg-neutral-900), inactive is ghost.
Omits the field from the room.create payload when left at the
default so old servers keep working.
- data-testid='color-preference-{white,black,random}' for e2e.
Tests:
- 7 new protocol.test.ts cases: each enum value accepted,
omitted valid, invalid strings rejected, composition with
existing fields.
- 5 new rooms.test.ts cases: back-compat default, explicit
white, explicit black, random distribution across 40 samples,
random-resolved color stored idempotently.
- 4 new multiplayer.spec.ts cases: host=black flow, host=random
disjoint-color assertion, back-compat no-field flow, UI
buttons render + toggle with correct aria-checked state.
Verification: 1663 unit tests (+12) + 87/87 Playwright (+4).
Typecheck + lint clean across chess + server.
Plan: .sisyphus/plans/post-epic-deferrals.md Feature 1 complete.
Saves the planning doc covering all 4 deferred items from the
rule-variants epic close-out. Momus review: [OKAY].
Sequencing:
1. MP color choice — ~1 session, isolated
2. Extinction-chess target UI — ~1/2 session, solo-only v1
3. Berolina en-passant — ~1/2 session, Parton 1952 variant
4. Tier 3 royalty-transfer — 1-2 sessions, REQUIRES stakeholder
alignment before start (new engine
surface for PlayerAction vs
LegalMove).
Each feature is fully specified with protocol / server / UI / test
surface, commit message, and per-feature verification gate. Plan
includes a pre-implementation decision log (7 open questions) and
early-stop triggers so any of F1/F1+2/F1+2+3 is a valid ship
point.
Path: .sisyphus/plans/post-epic-deferrals.md
Phase F.4 of the rule-variants epic.
Three end-to-end scenarios covering the full lobby -> game flow:
1. knightmate: selecting the knightmate layout surfaces a
'Suggested rules' chip for knightmate-rules in LayoutPicker;
tapping activates the preset; Play Solo loads the game with
no console errors; a pawn push resolves normally.
2. double-move: in-game rules drawer shows a 'Multi-move'
category section (Phase F.3 grouping); toggling double-move
ON makes white play two consecutive half-moves before the
turn flips to black, verified by successful drags of two
white pieces followed by a black piece drag.
3. suicide-chess: after establishing a capture opportunity
(1. e4 d5), toggling suicide-chess on via the in-game rules
drawer. White's next move: a non-capture (a2-a3) is
rejected by filterLegalMoves; the compulsory capture
(e4xd5) succeeds.
Scenarios 2 and 3 toggle the preset via the IN-GAME drawer
(lives in GameView.tsx) rather than the lobby — Lobby.tsx has
no rules drawer; only the layout-picker chip path is available
pre-game. Scenario 1 exercises the chip path. Deferred:
lobby-side drawer is a follow-up if richer pre-game preset
activation UX is desired.
Tests: 1651 unit + 3 new e2e = 4-test file added to e2e suite.
All 3 pass.
Phase F.5 (docs portion) of the rule-variants epic.
- PRESET-API.md gains a 'Rule Variants Gallery' section summarizing
the 14 new presets grouped by category (King Variants, Objectives,
Multi-move, Movement). Flags which are scope-aware. Documents
the reusable scope-flip pattern for future asymmetric presets.
New section on 'ongoing' two-phase protocol for
onCheckGameResult composition.
- RULES.md gains a 'Rule Variants Gallery (v2, 2026)' section
cross-referencing each of the 14 presets with greenchess.net
where applicable. Overview table bumps from 15 rules to 29.
Also documents the new 'dual-classic' starting layout.
Tests: 1651 passing (docs-only; no test delta).
(Full Playwright regression + final commit still to come as part
of F.5 once the F.4 e2e spec lands.)
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).
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).
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.)
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.
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.
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).
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.
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)
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)
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).
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).
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).
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).
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).
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.
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.