Commit graph

298 commits

Author SHA1 Message Date
99d9688b01
feat(chess/ui): add BlockList with dnd-kit sortable for visual builder (T18)
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).
2026-04-21 18:14:17 -06:00
9a7916917c
feat(chess/modifiers): wire 7 new triggers into onAfterMove with Metis-locked dispatch order
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.
2026-04-21 18:13:43 -06:00
b2ca2cae23
test(chess): add legacy descriptor backward-compat fixture + round-trip test
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).
2026-04-21 18:02:59 -06:00
d9df4b64ca
feat(chess/modifiers): add 7 fire*Hooks evaluators for new trigger primitives
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.
2026-04-21 18:02:20 -06:00
26e708be14
chore(chess): add @dnd-kit deps for upcoming visual-builder block list
Brings forward from T28: @dnd-kit/core ^6.3.1, @dnd-kit/sortable
^8.0.0, @dnd-kit/utilities ^3.2.2. Wave 3's BlockList (T18) imports
these for accessible drag-and-drop reordering.
2026-04-21 18:01:41 -06:00
19ae096acd
feat(chess/ui): add visual-builder BlockCard + PreviewPane foundations (T16, T17)
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).
2026-04-21 17:47:39 -06:00
99c9d1629c
test(chess/ui): extend ParamField snapshot suite for 7 new trigger primitives
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.
2026-04-21 17:47:02 -06:00
e87d38ae2c
feat(chess/primitives): add 7 new trigger primitives (on-move, on-turn-end, on-promotion, on-check-*, on-moved-onto-square, on-captured)
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).
2026-04-21 17:46:37 -06:00
8f6c666ade
feat(chess/modifiers): add 7 new attr consumers + pre-move check/promotion snapshots
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.
2026-04-21 16:59:32 -06:00
3b6f79ac74
feat(chess/modifiers): extend PrimitiveApplyContext with target resolver + event field
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.
2026-04-21 16:58:54 -06:00
161bc0e78a
test(server): add wire-parity fixtures for 7 new trigger primitive kinds
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.
2026-04-21 16:58:17 -06:00
776c192874
refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change)
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.
2026-04-21 16:57:42 -06:00
d44b433889
feat(chess/ui): add narrate.ts pure module for descriptor → English
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.
2026-04-21 16:57:10 -06:00
e5594b0d3c
fix(ui): hide engine-internal presets from user authoring surface
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.
2026-04-21 15:43:04 -06:00
ef730eefbe
refactor(ui): stack per-instance board + square detail vertically
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.
2026-04-21 15:42:20 -06:00
fe787478b1
refactor(ui): split ModifierProfileEditor header + relocate history/clipboard to footer
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.
2026-04-21 15:41:49 -06:00
699c288a98
feat(modifiers): bundle preset activations on profiles + editor Presets tab
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.
2026-04-21 14:03:52 -06:00
3bdfba38e0
feat(ui): add knight-silhouette favicon, theme-color, and meta description
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.
2026-04-21 13:35:42 -06:00
9289e60beb
feat(layouts): toggle-delete when clicking same brush-piece
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).
2026-04-21 13:32:46 -06:00
4b08b0c71c
feat(modifiers): persist bound layout on profile + drive Lobby layout from profile
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.
2026-04-21 13:28:06 -06:00
8605a22530
feat(ui): AttrCombobox declare vs. consume semantics
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.
2026-04-21 13:07:27 -06:00
934db775f9
feat(ui): custom modifier editor in-modal docs, recipes, and attr combobox
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.
2026-04-21 12:50:14 -06:00
d6bc1ca2cc
feat(ui): Actions menu + royalty-transfer target selection in GameView 2026-04-21 12:04:55 -06:00
2951a2d547
feat(multiplayer): game.action WS message for PlayerActions
Wire up the game.action WebSocket message so multiplayer games can
dispatch PlayerActions (F4b of post-epic-deferrals).

- Export PlayerAction/ActionResult from @paratype/chess barrel
- Add performAction wrapper to GameSession
- Protocol: GameActionMessageSchema + ClientMessage union update
- Server: handleGameAction handler with turn gate + error mapping
- Client: sendAction helper in useMultiplayerGame + net/types update
- Reuse game.state broadcast (no new server→client message type)

Unit tests: 1709 (baseline 1699 + 10 new: 5 protocol, 3 game-session, 2 net)
Playwright: 91 (baseline 89 + 2 new F4b multiplayer scenarios)
2026-04-21 11:56:35 -06:00
8220f1507e
feat(engine): PlayerAction + transferable-royalty preset (solo)
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.)
2026-04-21 11:30:24 -06:00
dfdc8aba63
feat(presets): berolina-pawns en-passant (Parton 1952, both variants)
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.
2026-04-21 11:06:53 -06:00
0ce500906c
feat(ui): extinction-chess target cycler in rules drawer
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.
2026-04-21 10:57:46 -06:00
f2dff7e530
feat(multiplayer): host color preference (white/black/random)
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.
2026-04-21 10:51:45 -06:00
4789a479ad
plan(sisyphus): lock 7 design decisions for post-epic deferrals
All 7 decisions locked 2026-04-21:

  Feature 1 (MP color):
    - Joiner-side choice: NO (creator picks, joiner takes rest)
    - Re-pick mid-session: NO (fixed at room creation)

  Feature 2 (Extinction UI):
    - Multiplayer target-sync: NO in v1 (solo-only; MP fixed at
      preset-activation time)

  Feature 3 (Berolina ep):
    - Variant: Parton 1952 (standard ep through Berolina geometry)

  Feature 4 (Royalty transfer) — cleared to execute after 1-3:
    - Turn-consumption: YES (prevents 'abdicate out of mate')
    - UI: dedicated 'Actions' button in GameView
    - Stakeholder demand: YES, validated
2026-04-21 10:33:14 -06:00
773bf53fab
plan(sisyphus): post-epic deferrals (MP color + extinction UI + berolina ep + royalty transfer)
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
2026-04-21 10:26:08 -06:00
3cef8f5324
chore(sisyphus): Rule Variants Final Verification Wave — all reviewers APPROVE
Phase F Final Wave of the rule-variants epic.

  F1 (Plan Compliance, oracle):          APPROVE — bg_f2508b15
  F2 (Code Quality, unspecified-high):   APPROVE — bg_1101ecb3
  F3 (Manual QA, orchestrator-direct):   APPROVE — after agent hit
                                         tool cap; 1651/1651 unit +
                                         83/83 Playwright + 2/2
                                         visual spot-checks
  F4 (Scope Fidelity, deep):             APPROVE — bg_77460b0f

Summary:
- 1651 unit tests (1417 baseline → +234 across 14 presets + 4 hooks
  + audits).
- 83/83 Playwright (80 → +3 rule-variants scenarios in F.4).
- 0 typecheck / lint errors.
- 4 new preset hooks shipped: getRoyalPieces, filterLegalMoves,
  shouldAdvanceTurn (+ HalfMovesThisTurn fact), overridePieceMoves.
- 14 rule-variant presets shipped:
  * Tier 1: knightmate-rules, double-move, monster-rules (scope-
    flippable), first-promotion-wins.
  * Tier 2 royal: coregal, dual-king, weak-dual-king.
  * Tier 2 objective: suicide-chess, capture-all, extinction-chess.
  * Tier 2 movement: berolina-pawns (scope-flippable),
    berolina-pawns-2 (scope-flippable), bouncing-pieces,
    bouncing-pieces-2.
- 1 new layout: dual-classic.
- Engine composition fix: onCheckGameResult 'ongoing' suppresses
  defaults without short-circuiting later presets.
- UI: RulesDrawer category grouping + LayoutPicker suggested-rules
  chips.
- Docs: PRESET-API.md + RULES.md galleries updated.
- Audits: incompatibleWith symmetry test + suggestedPresets
  dangling-reference test — both green and runtime-enforced.

Deferred per-plan: Tier 3 royalty-transfer (requires mid-game state
toggle UX outside 'one move at a time'), berolina en-passant,
extinction-chess UI target cycling.

Plan: .sisyphus/plans/rule-variants-v2.md (all 31 tasks checked).
2026-04-21 10:15:18 -06:00
cd7baeae0d
test(e2e): rule-variant vertical slice
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.
2026-04-21 09:49:55 -06:00
ed2f0cc660
docs(preset-api): rule variants gallery + RULES.md cross-refs
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.)
2026-04-21 09:23:10 -06:00
bd20032767
feat(ui): group RulesDrawer presets by category + layout suggested-rules chips
Phase F.3 + F.2-UI of the rule-variants epic.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- packages/chess/src/presets/knightmate-rules.ts (new)
- packages/chess/src/presets/knightmate-rules.test.ts (new, 10 tests)
- packages/chess/src/presets/index.ts (barrel import)
- packages/chess/src/presets/presets.test.ts (count 15 → 16)
2026-04-21 07:13:01 -06:00