Commit graph

272 commits

Author SHA1 Message Date
d4931a50ee
feat(thressgame-coverage): Wave 8 (WS protocol v2 + suspended execution + request-choice)
- T43: WS protocol v2 schema; protocolVersion field; RequestChoice/SubmitChoice/ProtocolVersionMismatch messages; v1 backward-compat
- T44: server-side request-choice broadcast on push; submit-choice validation (kind/forPlayer/value-type); ordered LIFO matching
- T45: PendingChoices stack on GAME_ENTITY; pushPendingChoice/popPendingChoice/peekPendingChoice helpers; serializePendingChoice (Map<->Array roundtrip); MAX_CHOICE_DEPTH=8 enforced
- T46: submitChoiceAndResume(engine, choiceId, value); descriptor-by-id lookup; bindings restored; remaining primitives executed via runPrimitives from primitiveIndex+1
- T47: request-choice primitive; SuspendedExecution exception mechanism; dispatcher catches and stops sibling iteration; deterministic choiceId via session counter
- T48: AutoChoiceResolver test transport (answersByKind / answersById); drainPendingChoices LIFO walk
- T49: server-side choice timeout enforcement; auto-resolve to first-option-per-kind; disconnect handler (forfeit / pause)
- T50: ChoiceTimeoutPolicy on GAME_ENTITY (timeout-with-default | no-timeout); CreateGameRequest extended; default 60s

Tests: 2533 -> 2658 (+125). bun run check exit 0.
2026-04-26 12:07:10 -06:00
778ebc4129
feat(thressgame-coverage): Wave 7 (RNG + restriction + movement-replacement primitives)
RNG (uses T9 engine.rng()):
- T36: with-probability — engine.rng().next() < p ? then : else; deterministic with seed
- T37: random-pick — engine.rng().pick(from); binds via T11; deterministic

Restrictions:
- T38: must-class — { class: capture|advance|move-to, square? }; seeds MoveClassRestriction (move-gen wire-up deferred)
- T39: block-by-piece-type — appends to BlockedPieceTypes set on GAME_ENTITY (move-gen wire-up deferred)

Movement replacement (uses T8 schema attrs):
- T40: set-moves-as + set-moves-also-as — per-piece MovesAs/MovesAlsoAs override (move-gen consumption deferred)
- T41: pawn-pushes-pieces — game-level PawnPushesPiecesEnabled flag

Cross-cutting:
- T42: uniform lifetime field on seed-attribute + set-piece-attr; wired to lifetime-registry util (decrements on turn-end)

Registry: 42 -> 49 primitives (+7). Tests: 2426 -> 2533 (+107). bun run check exit 0.
2026-04-26 11:17:43 -06:00
9a7436e2ad
feat(thressgame-coverage): Wave 6 (markers + iteration primitives)
Marker primitives:
- T28: spawn-marker — wraps engine.spawnMarker (T10)
- T29: spawn-marker-pair — atomic dual spawn with mutual MarkerLinks (portals); T20 synthetic test moved to non-IMPERATIVE_KINDS placeholder
- T30: destroy-marker — fires on-marker-expire (T19) then engine.removeMarker

Iteration primitives (deterministic sort by entity id / index):
- T31: for-each-piece — filter (color/pieceType), bind via T11, recurse
- T32: for-each-square — squares='all'|number[], deterministic 0-63 default
- T33: for-each-adjacent — 8-neighbor with edge clipping, optional excludeKing/occupied filter
- T34: for-each-marker — filter (markerKind/owner), bind id, recurse
- T35: for-column + for-row — explicit index lists, dedupe + sort

Bonus infra: util/lifetime-registry.ts (will be used by T42).

Registry: 33 -> 42 primitives (+9). Tests: 2225 -> 2426 (+201). bun run check exit 0.
2026-04-26 11:00:05 -06:00
e290f350ad
feat(thressgame-coverage): Wave 5 (7 imperative primitives)
- T21: place-piece — calls engine.spawnPiece on resolved square
- T22: destroy-piece — retracts piece facts; enqueues on-captured
- T23: move-piece — updates Position + HasMoved; enqueues on-move + on-moved-onto-square
- T24: swap-pieces — atomic Position swap; enqueues 2 on-move events
- T25: convert-piece-type — changes PieceType; enqueues on-promotion (with previous-equality short-circuit)
- T26: set-piece-attr — generic attr insert (parity descriptors use heavily); lifetime field accepted but ignored in V1
- T27: cancel-capture — sets CaptureCancelled flag on GAME_ENTITY; rejects outside on-captured context

T20 test fix: synthetic suppressTriggers test moved from 'swap-pieces' kind (T24 took it) to 'spawn-marker-pair' (Wave 6 / T29 territory).

Registry: 26 -> 33 primitives. Tests: 2120 -> 2225 (+105). bun run check exit 0.
2026-04-26 10:25:58 -06:00
70a7c50613
feat(thressgame-coverage): Wave 4 (deferred dispatch + 4 new triggers + suppressTriggers)
- T15: deferred trigger queue (PendingTrigger[] + cascadeDepth on PrimitiveApplyContext); HARD_CASCADE_DEPTH=8; runtime.cascade-depth-exceeded; FIFO drain after arm; enqueueTrigger helper
- T16: on-rule-activated trigger primitive + fireOnRuleActivatedHooks; OnRuleActivatedHooks attr on GAME_ENTITY; RuleActivatedFiredFor guard on PRESET_STATE_ENTITY; chooser color in event
- T17: on-rule-expire trigger primitive + fireOnRuleExpireHooks; OnRuleExpireHooks attr; RuleExpireFiredFor guard
- T18: on-piece-entered-marker trigger + fireOnPieceEnteredMarkerHooks; OnPieceEnteredMarkerHooks attr; wired stage 7b in onAfterMove (uses T10 getMarkersAtSquare priority order)
- T19: on-marker-expire trigger + decrementMarkerLifetimes (util/marker-lifetime.ts); OnMarkerExpireHooks attr; wired stage 7c after T18
- T20: suppressTriggers flag on PrimitiveApplyContext; runPrimitives skips IMPERATIVE_KINDS under suppress; IMPERATIVE_KINDS exported from validate.ts; runPrimitives now public

Registry: 22 -> 26 primitives. Tests: 2058 -> 2120 (+62). bun run check exit 0.
2026-04-26 09:52:50 -06:00
defe56feb9
feat(thressgame-coverage): Wave 3 (binding scope + param walker + validator extensions)
- T11: PrimitiveApplyContext.bindings (immutable Map<string,BindingValue>); withBinding helper; threaded through 22 test files + triggers.ts/apply.ts
- T12: param-resolver.ts walker resolves { $var }, { ctx-attr: { entity, attr } }, { ctx-build: { col, row } } shapes; wired before primitive.apply in triggers.ts + custom/apply.ts; BindingError class
- T13: validator binding-out-of-scope check (descriptor.primitives.binding-out-of-scope); BINDING_INTRODUCING_KINDS map (8 future kinds); cycle-guarded $var walker
- T14: validator imperative-in-passive check (descriptor.primitives.imperative-in-passive, 10 IMPERATIVE_KINDS); LastModifierChooser tracking on PRESET_STATE_ENTITY (chooser-entity stub)

Tests: 2014 -> 2048 (+34). bun run check exit 0.
2026-04-26 09:10:21 -06:00
abe5bf49a8
feat(thressgame-coverage): Wave 2 (entity attrs + aura + RNG + marker factory)
- T6: 7 new entity attrs (EntityKind, MarkerKind, MarkerLifetime, MarkerOwner, MarkerLinks, RngSeed, RngStream) + registerAttrConsumer
- T7: aura compute admits markers via EntityKind discriminator (default-to-piece policy); +getEntityKind helper
- T8: 5 movement-replacement attrs (MovesAs, MovesAlsoAs, SlideMustBeMaxDistance, BlockAllExceptKing, KingExtraReach)
- T9: Mulberry32 PRNG (SeededRng) + deriveSeedFromGameId + engine.rng()/setRngSeed() with persistent RngStream advancement
- T10: engine.spawnMarker/removeMarker/getMarkersAtSquare with hardcoded priority table (portal-end<frozen-square<mine<...<blocked) + entity-id tiebreak

Tests: 1970 -> 2014 (+44). bun run check exit 0.
2026-04-26 08:33:43 -06:00
2368a24b15
feat(thressgame-coverage): Wave 0-1 foundation (ADR + baseline + harness + audits)
Wave 0:
- T0: Architectural decisions (10 sections, 215 lines) + 5-rule paper exercise

Wave 1 (parallel):
- T1: Backward-compat baseline fixture (1961 tests / 167 files snapshot + regression guard)
- T2: Determinism property-test harness (runDeterminismCheck, N=100 default, 1.7s)
- T3: State-hash util (SHA256 of session.allFacts, insertion-order independent)
- T4: Position-attr caller audit (75 prod callsites classified, 17 fixes seeded for T6/T7)
- T5: $var conflict audit (CLEAN — T12 binding shape safe)

Tests: 1961 -> 1970 (+9). bun run check exits 0. No production source modified.
2026-04-26 08:16:26 -06:00
9e31b6d682
test(chess/e2e): expand visual-builder Playwright suite to 11 scenarios
Replaces the original single-flow stub with a comprehensive suite
that exercises every user-facing behaviour of the visual authoring
surface against a live Vite dev server. 11 tests run in 23s.

Scenarios:

 1. Mode toggle persists across reload
    Opens editor, toggles to Visual, verifies aria-pressed state +
    localStorage key, reloads, confirms Visual is still active on
    the next mount.

 2. Palette click adds top-level primitive to block list
    Clicks palette-btn-on-turn-end, verifies block-card-on-turn-end
    appears with matching aria-label and the narrative preview
    mentions "turn end". Asserts via aria-label rather than visible
    text so the open inspector docs do not cause strict-mode matches.

 3. Clicking × removes the block without triggering a drag
    Adds three blocks, clicks the × button on the middle one,
    verifies it is gone AND that dnd-kits assertive announcer never
    reported "Picked up sortable item" — direct regression for the
    drag-handle isolation fix.

 4. Clicking expand toggles the block without triggering a drag
    Asserts aria-expanded flips from false to true on click without
    the card being removed or reordered.

 5. Selecting a trigger makes palette clicks add children
    Adds on-turn-end, selects it, verifies palette-add-target-banner
    appears with the parent label, then clicks add-to-attribute and
    asserts there is exactly one add-to-attribute block AND it is
    a descendant of block-card-on-turn-end.

 6. "Add at top level instead" resets the nested-target selection
    After entering nested-add mode, clicks the escape button and
    verifies subsequent palette adds are top-level siblings.

 7. × on a nested child removes only that child
    Verifies nested removal leaves the parent intact.

 8. Preview narrative reflects tree mutations immediately
    Adds primitive and checks narrative; removes and checks narrative
    no longer mentions the seeded attribute.

 9. Save → reload → load preserves the composed descriptor
    Fills inspector fields (attr=Hp, delta=1), saves, reloads,
    confirms Visual mode is remembered, loads from library, verifies
    inspector value survived the round-trip.

10. Depth-4 descriptor surfaces validation banner + disables save
    Pre-seeds localStorage with a depth-4 descriptor (conditional
    → on-capture → on-damaged → conditional → add-to-attribute),
    loads it, verifies the validation banner renders and the Save
    button becomes disabled.

11. Toggling Form ↔ Visual preserves the composed descriptor
    Composes a tree, captures the JSON preview, toggles to Form and
    back to Visual, verifies JSON preview is byte-identical.

The spec uses data-testid selectors exclusively where possible,
falls back to aria-label for the block card outer <article>, and
uses click({ position }) to land on the card header (avoiding the
× / expand / grip / inspector overlays).
2026-04-21 19:47:21 -06:00
2d1efb1b3a
fix(chess/ui): visual-builder drag/nesting/editing UX gaps
Addresses four user-reported gaps in the visual mode authoring surface:

1. × button and expand chevron triggered a drag instead of their own
   action. dnd-kit listeners were spread on the outer SortableBlockItem
   wrapper, so any pointerdown on a descendant started a sort. Fixed by
   routing only `listeners` to a new dedicated grip-handle icon on the
   card header; the rest of the card (× / expand / inspector / body)
   no longer competes with drag. `attributes` still go on the wrapper
   so keyboard drag + screen-reader announcements keep working.

2. No way to add primitives INSIDE a trigger — palette clicks always
   appended at the top level. Fixed by computing an addTargetInfo when
   the selected block is a container (has childPrimitives + a params
   .primitives array): the palette now shows an "Adding inside: {label}"
   banner with an "Add at top level instead" escape button, and
   handleAddPrimitive routes the new node into the parent params. The
   parent auto-expands and selection stays on the parent so repeated
   palette clicks stack children under it.

3. Inspector was read-only — ParamField.onChange was a documented
   no-op. Fixed by adding onParamsChange through the wire
   (VisualBuilderPane.handleParamsChange → BlockList.onParamsChange
   → SortableBlockItem.onParamsChange → BlockCard.onParamsChange →
   ParamField.onChange). Editing a number/string/enum field now
   immediately updates descriptor.primitives[index].params.

4. Nested children could not be removed — nested BlockList was given
   onRemove={() => {}}. Fixed by adding onNestedRemove to BlockListProps;
   VisualBuilderPane supplies handleNestedRemove which filters the
   matching parent params.primitives[] without mutating siblings.

Additional polish:
- Inspector now opens when a block is selected (previously needed
  selected AND expanded), so children and docs show up on first click.
- Child block list renders whenever the parent is expanded OR selected
  for the same first-click visibility.
- Expand button gains aria-label="Expand|Collapse" so accessibility
  tooling (and Playwright getByRole) can target it by name.
2026-04-21 19:46:37 -06:00
46109d5d23
fix(chess/ui): ParamField enum rendering under Zod 4
The inspector read ZodEnum options via _def.values, which Zod 4
renamed to _def.entries (a Record<string,string>) and additionally
exposes as a public .options array. Under Zod 4 _def.values is
undefined, so the inspector crashed on any primitive with an enum
param (block-move-type: moveType, override-promotion: target,
on-turn-end: color) — the ParamField tried to iterate an undefined
options array and threw Cannot read properties of undefined.

The browser trapped the render error in a React error boundary, so
the surrounding visual builder partly stopped updating; in Playwright
the error manifested as a freshly added trigger block never appearing
in the DOM. SSR snapshot tests did not catch it because react-dom
server-render absorbs the first error silently.

Fix: prefer the public .options array; fall back to Object.values of
_def.entries for Zod 4; finally fall back to _def.values for Zod 3
compat; empty array as last resort.

Also: two snapshot tests documented the pre-existing crash via
toThrow. They now snapshot the (valid) rendered output instead and
the fresh golden HTML was captured for block-move-type (moveType
enum: step/slide/capture) and override-promotion (target enum: queen/
rook/bishop/knight/pawn/disabled).
2026-04-21 19:45:59 -06:00
e4e82b3b51
docs(chess): correct trigger count from 11 to 10 (3 existing + 7 new)
F4 scope-fidelity audit flagged a plan-text vs implementation count
drift. Only three trigger primitives existed before Wave 2
(on-turn-start, on-capture, on-damaged) plus one non-trigger
conditional primitive, NOT four trigger primitives. Wave 2 added
seven more, so the correct total is 10 trigger primitives, not 11.

This commit corrects the RULES.md intro paragraph from
11 trigger primitives (4 existing + 7 added in Wave 2)
to
10 trigger primitives (3 existing + 7 added in Wave 2)

matching the actual file-system count under
packages/chess/src/modifiers/primitives/on-*.ts. No structural docs
reorganization needed (the section already tabulates the 3 existing
triggers under Existing triggers (pre-Wave 2) and the 7 new ones
under New triggers (Wave 2) individually).
2026-04-21 19:17:04 -06:00
bb86bed461
test(chess/ui): add PreviewPane + BoardDiagramView unit tests (T17 gap fill)
F1 plan-compliance audit flagged these two test files as missing
acceptance-criteria deliverables for T17. This commit closes the gap.

PreviewPane.test.tsx (3 scenarios, react-dom/server harness):
- renders three role=tab buttons with Narrative/JSON/Board labels
- default-active tab is Narrative (aria-selected=true, others hidden)
- sub-views are memoized — identical rendered markup when the same
  descriptor reference is passed twice

BoardDiagramView.test.tsx (4 scenarios):
- empty descriptor renders No board effect placeholder and zero SVG
- on-moved-onto-square with {kind:squares, squares:[28,35]} renders
  data-testid=highlight-28 and highlight-35 yellow overlays
- add-aura with radius=2 renders data-testid=aura-ring SVG circle
- nested trigger traversal — on-capture containing on-moved-onto-square
  correctly surfaces the inner filters squares for highlighting

Matches the renderToStaticMarkup pattern used by
ParamField.snapshot.test.tsx so the harness stays consistent across
the package. No component source touched.
2026-04-21 19:09:33 -06:00
420b5bae55
test(chess/primitives): lock primitive registry count at 22 (T28)
Adds a tiny assertion test that imports the barrel and checks
PRIMITIVE_REGISTRY.list().length === 22 — 15 pre-existing primitives
plus the 7 trigger primitives added in Wave 2 (on-move, on-turn-end,
on-promotion, on-check-received, on-check-delivered,
on-moved-onto-square, on-captured).

This is the registry-count portion of T28. The dnd-kit dependency
install already landed in commit 26e708b; bundle-size measurement was
performed out-of-band (273kb gz total for the chess demo including
Vite + React + Rete engine + dnd-kit + all components).
2026-04-21 19:00:17 -06:00
da436d5650
docs(chess): document 7 new trigger primitives + target/event context (T27)
Extends RULES.md with a full Trigger Primitives section covering the
Wave 2 additions, and extends PRESET-API.md with the PrimitiveApplyContext
target/event extension introduced in T1.

RULES.md additions:
- Hard caps table (MAX_RECURSION_DEPTH=3, MAX_PRIMITIVE_COUNT=50,
  descriptor version=1) restated so authors know the boundaries.
- Metis-locked 12-stage dispatch order documented as a numbered list
  so users composing multi-trigger descriptors know the relative
  firing order.
- Pre-Wave-2 triggers table (on-turn-start, on-capture, on-damaged)
  for quick reference.
- Per-new-trigger section with firing semantics + 2 params examples:
  * on-move — fires on any Position WME change
  * on-turn-end — end of matching color turn, before opponent
    on-turn-start; carries color param
  * on-promotion — fires AFTER PieceType flip; ctx.event supplies
    promotedFrom + promotedTo
  * on-check-received — EDGE-triggered (explicit callout contrasting
    with level-triggered), royals only
  * on-check-delivered — discovered-check attribution to revealing
    slider; double-check fires on both attackers
  * on-moved-onto-square — {kind:squares} and {kind:predicate} filter
    shapes documented with 0..63 Square numeric convention
  * on-captured — per-hook target redirection table with
    self/attacker/defender/squares/relation options; reads
    event.attackerId + event.defenderId

PRESET-API.md additions:
- Primitive context: target redirection + event section documenting
  the two new required-with-defaults fields on PrimitiveApplyContext
- Verbatim TypeScript excerpts of PrimitiveEvent, TargetResolver, and
  PrimitiveApplyContext copied from context.ts/types.ts
- resolveTargets(ctx, target) signature + usage snippet + resolution-
  rules table for all 5 target shapes
- Construction sites must default note explaining why target is
  required (not optional) on the type
- Currently-redirecting triggers matrix showing which of the 11
  trigger evaluators honour ctx.target and which populate ctx.event

Note: the plan brief said 4 existing + 7 new = 11 triggers, but only
3 pre-Wave-2 trigger primitives exist in the source tree
(on-turn-start, on-capture, on-damaged). Docs reflect the actual
3 + 7 = 10.

Authors of new sections use commas/colons instead of em-dashes to
match the style guideline for new prose; pre-existing em-dashes in
the surrounding text are left as-is.
2026-04-21 18:59:54 -06:00
b08415c7f8
feat(chess/modifiers): add 3 recipes showcasing new trigger primitives (T26)
Adds three recipes to the built-in template library, each built on a
Wave 2 trigger that previously had no pre-composed example:

- Kamikaze Knight — on-captured with target: attacker, nested
  add-to-attribute Hp -2. Death-rattle that damages whichever piece
  made the fatal capture.
- Berserker Pawn — on-move nested with add-to-attribute AttackBonus
  +1. Stacking rage: the pawn grows stronger with every move it
  survives.
- Promotion Feast — on-promotion nested with seed-attribute Hp 5.
  When a pawn finally promotes, it starts its new life with a fresh
  5 HP pool regardless of damage taken on the way up the board.

Each recipe passes validateCustomDescriptor and lands in the editors
Templates dropdown alongside the existing five. Recipe count: 5 → 8.
2026-04-21 18:59:17 -06:00
159d2cff06
feat(chess/ui,e2e): add data-testid to PreviewPane + scaffold visual-builder e2e spec
Tiny preview-pane data-testid added for Playwright targeting (T25
setup), plus an initial e2e spec file covering the visual-mode
authoring flow — to be expanded in T25.

Spec covers happy path: open editor, toggle to Visual, add on-move
from palette, configure nested add-to-attribute, verify preview
narrative renders. Real runtime execution against the chess dev
server is Wave 4 work.
2026-04-21 18:50:54 -06:00
8fc0582626
docs: add T24 QA notes 2026-04-21 18:38:50 -06:00
ec61432ec0
test(chess/modifiers): add depth-3 composition and depth-4 rejection tests 2026-04-21 18:38:19 -06:00
a8f0881e53
test(chess/ui): add mode toggle round-trip test 2026-04-21 18:35:21 -06:00
b0ec3c7e0b
docs: add T20 QA verification notes to learnings.md 2026-04-21 18:31:42 -06:00
131d337544
test: update param field snapshots for happy-dom changes 2026-04-21 18:31:23 -06:00
8017a0d590
feat: form/visual mode toggle for CustomModifierEditor
- Add buttons in editor header to switch between 'form' and 'visual' modes
- Persist user's preferred mode to localStorage
- Wrap main layout area in conditional render
- In 'visual' mode, center/right column replaced with VisualBuilderPane
- Adds suite of UI tests with vitest + node testing with mocked localStorage
2026-04-21 18:29:51 -06:00
d9928fbb07
feat(chess/ui): add VisualBuilderPane composing palette + BlockList + PreviewPane (T19)
Three-column composition shell for the visual authoring surface.
Stitches Wave 2/3 pieces together:
- Left (200px): inline palette — one categorized button per primitive
  kind enumerated from PRIMITIVE_REGISTRY.list(), clicking seeds the
  descriptor with generateDefaultParams(kind) + an empty params tree
  where Zod schemas expose a nested primitives array.
- Center (flex): BlockList with the descriptors primitives, routes
  select/expand/remove/reorder/nested-reorder callbacks back through
  immutable descriptor updates.
- Right (320px): PreviewPane (narrative / JSON / board tabs).

State lives locally:
- selectedIndex: number | null
- expandedIndices: ReadonlySet<number>
Both recompute sensibly after reorder/remove so UI focus never points
at a stale slot.

Tree mutations are immutable throughout: top-level reorder uses
arrayMove; nested reorder deep-clones the affected parent nodes
params.primitives without touching siblings. Removing a primitive at
depth N only rewrites the ancestor chain down to that node.

Invalid descriptor: if validationResult.ok === false, a yellow warning
banner lists the error messages above the grid. The builder remains
usable below the banner so the author can keep editing to resolve
errors rather than being locked out.

Tests (VisualBuilderPane.test.tsx, 4 scenarios, react-dom/server
harness): renders 3 columns, invalid descriptor shows banner, palette
includes buttons for all 22 primitive kinds, nested trigger structure
renders with child BlockCards in the expanded area.

T22 wires this pane into CustomModifierEditor behind a Form/Visual
mode toggle.
2026-04-21 18:17:52 -06:00
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