Wraps the presentational BlockCard (T16) with dnd-kit for accessible
reordering. Nested trigger children render as a recursive BlockList
below their expanded parent, delegating reorder callbacks via
onNestedReorder(parentIndex, from, to) so the VisualBuilderPane (T19)
can update the descriptor tree structurally.
Sensors:
- PointerSensor for mouse/touch drag
- KeyboardSensor with sortableKeyboardCoordinates — Space starts drag,
Arrow keys move, Space drops, Escape cancels
DndContext announcements: custom announcer fires 'Moved {kind} from
position X to position Y' for assistive tech on drag start, over, end,
and cancel.
DragOverlay renders a lightweight ghost of the active block during
drag so the list doesnt reflow mid-gesture.
Tests (BlockList.test.tsx, 4 scenarios, react-dom/server harness):
- empty list renders no blocks
- N blocks render for N nodes, each with data-testid carrying kind
- keyboard sensor surface is registered (dnd-kit role + aria attrs
present on the sortable wrappers)
- nested children render inside expanded parent at depth + 1
Actual PointerEvent / KeyboardEvent drag simulation is deferred to the
Wave 4 Playwright e2e (T25) — happy-dom cannot faithfully simulate
dnd-kit gestures without @testing-library which is not installed.
No @dnd-kit tree-shaking issues — imports only the 6 symbols actually
used (DndContext, DragOverlay, PointerSensor, KeyboardSensor,
useSensor, useSensors from @dnd-kit/core; SortableContext,
sortableKeyboardCoordinates, useSortable, rectSortingStrategy from
@dnd-kit/sortable; CSS from @dnd-kit/utilities).
Completes the DSL-side wiring of the Wave 2 trigger primitives. Each
fire*Hooks evaluator from T12 is now called from the integration
presets onAfterMove hook in a precisely-ordered 12-stage pipeline,
with pre-move state snapshots populated in onBeforeMove and cleared in
a finally block.
Dispatch order (Metis-locked):
1. computeAuraFacts
2. fireOnDamagedHooks (existing)
3. fireOnCaptureHooks (existing)
4. fireOnCapturedHooks — per captured defender id, before fact cleanup could see it
5. fireOnPromotionHooks — per pawn whose post-move PieceType is not pawn
6. fireOnMoveHooks — per piece whose Position WME changed
7. fireOnMovedOntoSquareHooks — per moved piece, using its new Position
8. fireOnCheckReceivedHooks — edge diff vs pre-move check state
9. fireOnCheckDeliveredHooks — newly attacking pieces vs pre-move state
10. fireConditionalHooks (existing)
11. fireOnTurnEndHooks — for mover
12. fireOnTurnStartHooks — for next color
Pre-move snapshots added alongside existing PRE_MOVE_HP_SNAPSHOTS /
PRE_MOVE_CAPTURE_ATTACKERS / PRE_MOVE_CHECK_STATE_SNAPSHOTS /
PRE_MOVE_PROMOTION_PAWNS:
- PRE_MOVE_POSITION_SNAPSHOTS: Map<EntityId, Square> — every pieces
Position at onBeforeMove. Post-move diff yields movedPieceIds for
fireOnMoveHooks + per-piece fireOnMovedOntoSquareHooks.
- PRE_MOVE_CAPTURED_DEFENDERS: EntityId | null — the piece at ctx.to
before the move mutates, so fireOnCapturedHooks has the victims id.
Helpers added:
- snapshotPositions(session)
- diffMovedPieceIds(session, preMap)
- diffPromotedPieces(session, preCandidates) → [{id, promotedTo}]
All new snapshots clear in the finally block so an exception in any
fire*Hooks path cannot leak stale state into the next move.
Ordering test (3 new tests in apply.test.ts): uses vi.spyOn on each
of the 11 fire*Hooks to log call order, then triggers quiet move /
capture / promotion scenarios and asserts first-occurrence ordering
matches the Metis-locked sequence. Conditional dispatchers (on-captured
only fires on capture; on-promotion only on actual promotion) are
correctly excluded from quiet-move expectations.
Known limitation — en-passant: the EP victim sits on a different
square than ctx.to, so PRE_MOVE_CAPTURED_DEFENDERS misses them. EP
pawns wont fire on-captured hooks until a BeforeMoveContext.epVictimSquare
field lands or we switch to a post-move moveLog peek. Documented in
the WeakMaps docstring; not a regression (on-captured is new).
Known limitation — "before retraction" is aspirational: engine fact
cleanup happens inside applyMove before onAfterMove fires. The
dispatcher CALL ORDER guarantees no primitive re-seeds the dying
pieces hook list first, but the defenders facts are already gone by
call time. Inner primitives should read event.defenderId from ctx (the
triggers.ts runPrimitives context supplies it) rather than doing
session reads.
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)