Proves that descriptors using ONLY the 15 pre-existing primitive kinds
continue to parse, validate, apply, and serialize byte-identically even
after Wave 2 extended the PrimitiveKind union from 15 to 22 kinds. This
is the critical backward-compat proof: old saved descriptors in every
user library must keep working indefinitely.
Fixture: packages/chess/src/modifiers/custom/__fixtures__/legacy-descriptor.json
- 15 top-level primitives, one per legacy kind (each represented once)
- 5 nested primitives inside on-turn-start / on-capture / on-damaged /
conditional(then+else) → 20 total nodes, depth 2
- Zero Wave-2 kinds (explicit)
Test: legacy-descriptor.test.ts
- parse: parseCustomModifierDescriptor returns version=1, kind set >= 5,
every kind in legacy set
- validate: validateCustomDescriptor returns {ok: true}
- apply: asserts expected facts on the fixture pawn after
applyCustomDescriptor — HpBonus, DirectionAdditions, CaptureFlags,
ReflectDamagePercent, BlockedMoveTypes, RangeBonus, PromotionOverride,
AuraSpec, and all four hook arrays (OnTurnStartHooks, OnCaptureHooks,
OnDamagedHooks, ConditionalHooks)
- round-trip: JSON.parse(JSON.stringify(descriptor)) deep-equals the
raw fixture JSON
Note: applyCustomDescriptor recursively walks childPrimitives() at
apply time, so nested trigger primitives execute immediately IN
ADDITION to seeding their hook attrs. Expectations reflect this
end-to-end contract (Hp=11 not 10; HpBonus=4 not 2; etc.).
Read approach: fs.readFileSync + import.meta.url (tsconfig lacks
resolveJsonModule).
Threads the 7 new trigger primitives added in Wave 2 into the trigger
dispatcher. T21 wires these into onAfterMove next; for now they're
callable standalone and covered by 13 new targeted tests.
Evaluators added:
- fireOnMoveHooks(engine, movedPieceIds) — iterates moved-piece subset
so the caller in T21 can pass the Position-diff set
- fireOnTurnEndHooks(engine, endedColor) — matches 'white'|'black'|'both'
against the hook's stored color
- fireOnPromotionHooks(engine, pieceId, from, to) — populates
ctx.event={kind:'promotion', promotedFrom, promotedTo}
- fireOnCheckReceivedHooks(engine, preMoveCheckState) — edge-triggered:
fires only when a royal transitions from not-in-check → in-check
relative to the passed pre-move snapshot
- fireOnCheckDeliveredHooks(engine, preMoveCheckState) — for each royal,
finds pieces newly in the attacker set (handles discovered check
correctly — attributes to the revealing piece, not the mover)
- fireOnMovedOntoSquareHooks(engine, pieceId, destSquare) — matches
either {kind:'squares',squares[]} or {kind:'predicate',file?,rank?}
- fireOnCapturedHooks(engine, pieceId, attackerId) — resolves per-hook
target via resolveTargets() with ctx.event={kind:'capture',attackerId,
defenderId=pieceId}, then runs primitives on each resolved entity
Schema change — OnTurnEndHooks shape:
Extended from to
so the evaluator can enforce the
per-hook color filter declared in the primitive's params. Updated:
- schema.ts ChessAttrMap.OnTurnEndHooks
- on-turn-end.ts apply() stores the rich object
- on-turn-end.test.ts two assertions updated
runPrimitives refactor:
Added optional parameter threading through
the recursive walk so nested primitives at any depth see the trigger
metadata that fired the root. Backward compatible — existing 4
callers omit the param, getting ctx.event=undefined.
Circular-import avoidance:
triggers.ts does NOT import from apply.ts (apply.ts already imports
from triggers.ts). Pre-move check state is passed as a parameter
(mirrors the existing fireOnDamagedHooks(engine, preHp) pattern)
rather than imported via getPreMoveCheckState. The post-move check
probe (computeCheckStateForColor) mirrors apply.ts's private
captureCheckStateForColor — documented as an intentional copy to
keep in sync if the royal-detection logic ever moves.
Introduces the presentational primitives of the upcoming visual-mode
authoring surface, scoped so they can be dropped into an existing
editor without touching form-mode logic.
T16 — BlockCard (visual-builder/BlockCard.tsx):
Single primitive-block renderer. Pure controlled component — no own
state, no DnD. Props take node + index + isSelected + isExpanded +
onSelect/onToggleExpand/onRemove + depth. Color-codes by kind:
- blue: state primitives (seed/add/multiply/add-direction/set-capture-flag)
- emerald: mechanic primitives (absorb/reflect/modify-range/block-move/
override-promotion)
- violet: advanced/trigger primitives (auras + all on-* hooks + conditional)
Accessible: role='article', keyboard-focusable, Enter toggles expand,
Space selects, Delete/Backspace removes. Indent visually clamped at
depth 3. Delegates parameter editing to ParamField when expanded.
T17 — PreviewPane (visual-builder/preview/):
Tabbed live preview with role=tablist semantics. Three sub-views:
- NarrativeView — renders narrate(descriptor) in a semantic <pre
role='region'> for assistive tech
- JsonView — pretty-printed JSON with copy-to-clipboard (Sonner toast)
- BoardDiagramView — 8×8 SVG highlighting on-moved-onto-square filter
squares and add-aura radii; shows 'No board effect' placeholder when
the descriptor has no positional primitives
All sub-views memoized on descriptor identity. BoardDiagramView tolerates
in-edit malformed descriptors via structural unknown casts so the
preview doesn't throw during authoring.
Wave 3 will add BlockList (dnd-kit wrap around BlockCard) and
VisualBuilderPane (composition shell + palette + preview).
Adds SAMPLE_PARAMS entries for on-move, on-turn-end, on-promotion,
on-check-received, on-check-delivered, on-moved-onto-square, and
on-captured so the Record<PrimitiveKind, unknown> exhaustive type
remains satisfied after the union grew from 15 to 22 kinds.
Captures golden snapshots for each new kind's rendering via the
existing renderToStaticMarkup harness — each snapshot is one-line HTML
because the ParamField inspector falls back to a plain JSON textarea
for trigger primitives with nested-array params (complex-schema branch).
No ParamField behavior change; this is purely consumer-side wiring
required by the PrimitiveKind union extension.
Each primitive mirrors on-capture.ts structure: Zod paramsSchema with
nested primitives array, apply() that seeds its dedicated hook attr on
ctx.pieceId, childPrimitives() for tree-walker integration, seedsAttrs
declaration for consumer-integrity enforcement, docstring + ≥2 examples.
The kinds, attrs, and firing semantics (verified end-to-end in T21):
- on-move — OnMoveHooks, fires on any Position WME change (including
captures and castling rook)
- on-turn-end — OnTurnEndHooks with color filter ('white'|'black'|'both'),
fires at end of matching-color turn, BEFORE opponent's on-turn-start
- on-promotion — OnPromotionHooks, fires AFTER PieceType flip; evaluator
populates ctx.event.promotedFrom + promotedTo
- on-check-received — OnCheckReceivedHooks, edge-triggered (transition
into check only); royal-piece filter applied by evaluator
- on-check-delivered — OnCheckDeliveredHooks, attributes to revealing
piece for discovered check (computed via pre/post snapshot diff)
- on-moved-onto-square — OnMovedOntoSquareHooks, discriminated-union
filter: {kind:'squares', squares[]} | {kind:'predicate', file?, rank?};
Zod refine rejects empty predicate + empty squares list
- on-captured — OnCapturedHooks with per-hook target redirection
(self|attacker|defender|{squares[]}|{relation, filter?}); fires BEFORE
retraction so nested primitives can still read the defender's attrs.
Narrow-cast through unknown to paper over Zod → TargetResolver variance
under exactOptionalPropertyTypes:true (runtime shapes identical; pure
TS-level optional vs explicit-undefined mismatch)
Extends PrimitiveKind union from 15 → 22 kinds. Barrel imports added
to primitives/index.ts for side-effect registry registration.
Per-primitive tests assert seeding (8-12 cases each): registry keying,
seedsAttrs declaration, attribute-insertion, stacking across multiple
apply calls, Zod validation boundary cases, childPrimitives passthrough.
End-to-end trigger dispatching is wired and tested in T12 (triggers.ts)
and T21 (apply.ts onAfterMove ordering).
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).