User reported docker compose dev stack failing on a fresh clone with:
COPY package.json bun.lock ./
target server: failed to compute cache key: '/bun.lock': not found
Root cause: bun.lock was listed in .gitignore (line 12), so the lockfile
existed only on machines where bun install had been run locally. On a
fresh clone, the file is missing entirely, and Dockerfile.dev's
'COPY package.json bun.lock ./' fails because the build context has no
lockfile.
Fix: remove the gitignore entry and commit the existing 987-line lockfile.
This matches the standard practice for every JS package manager (bun,
npm, yarn, pnpm) \u2014 commit the lockfile so installs are reproducible
across machines. The Dockerfile already uses 'bun install --frozen-lockfile'
which depends on this file being present.
Note: the 'pull access denied for paratype/dev' line in the user's error
output is benign cosmetic noise from compose's pull-then-build fallback
flow when image: paratype/dev:local is set (compose tries the registry
first, gets a 403 since the image name is local-only, then falls back
to the local build context). Not an error \u2014 ignore.
After this commit, on a fresh clone:
git clone <repo>
docker compose -f docker-compose.dev.yml up
should work without any prior bun install on the host.
Fix two bugs in the Custom Modifier Editor's live preview pane that
surfaced when loading thressgame-100 epic recipes:
BUG 1 \u2014 'unknown primitive: <kind>'
The narrator dispatcher (KIND_NARRATORS) was missing entries for the 9
new primitives shipped across the thressgame-100 epic. Loading any recipe
using them showed 'unknown primitive: add-resource' (etc.) in the preview.
Added 9 narrators (alphabetically inserted into KIND_NARRATORS):
- on-attr-expire (W2) \u2014 'When the <attr> attr on <target> expires: <children>'
- decrement-attr-each-turn (W2) \u2014 'each turn-end, subtract 1 from <attr> on <target>'
- set-board-topology (W4) \u2014 'set board topology to <value>'
- link-pieces (W4) \u2014 'link the pieces at <a> and <b>'
- unlink-pieces (W4) \u2014 'unlink the pieces at <a> and <b>'
- on-piece-pair-link-broken (W4) \u2014 'When a linked partner is destroyed: <children>'
- add-resource (W5) \u2014 'give <player> +<amount> score' (or 'subtract from')
- spend-resource (W5) \u2014 'If <player> has <amount> score, spend it, then: <then>; otherwise: <else>'
- on-resource-changed (W5) \u2014 'When <player>'s score crosses <threshold> <direction>: <children>'
BUG 2 \u2014 '[object Object]' for V3 resolver shapes
fmtPieceTarget (line 125) and inline square handling fell through to
String(t) when the field carried a V3 resolver shape ({$var}, {ctx-self-id},
{ctx-self-marker-id}, {ctx-attr}, {ctx-build}, {add/sub/mul/mod}).
User-reported case: tpl-treasure-chest's destroy-marker arm rendered as
'destroy [object Object]'.
Added fmtResolverShape(v) helper rendering the 9 V3 shapes as plain English:
- {$var: 'p'} \u2192 'p'
- {ctx-self-id: null} \u2192 'this piece'
- {ctx-self-marker-id: null} \u2192 'this marker'
- {ctx-attr: {entity, attr}} \u2192 "<entity>'s <attr>" (recursive)
- {ctx-build: {col, row}} \u2192 'e4' (algebraic if both literal) or
'the square at column <c>, row <r>'
- {add: [a, b]} \u2192 '<a> + <b>' (recursive)
- {sub/mul/mod: [a, b]} \u2192 same pattern, '-' / '\u00d7' / 'mod'
Added fmtSquareValue(v) helper for square-typed fields. fmtPieceTarget
extended to call fmtResolverShape before falling back to String.
Updated existing narrators to use fmtSquareValue for square fields:
- spawn-marker.square, spawn-marker-pair.{squareA,squareB}
- move-piece.to, place-piece.square, must-class.square
- destroy-marker.target now routes through fmtResolverShape
Test surface:
- narrate.test.ts: 69 \u2192 90 tests (+21)
- V3 resolver shapes describe block: 8 tests
- thressgame-100 primitives describe block: 12 tests
- tpl-treasure-chest end-to-end regression test: 1 test
asserts the user-reported failure is fixed (no '[object Object]',
no 'unknown primitive', expected English phrases present)
bun run check: 3291 tests pass (was 3270, +21). 0 regressions.
USER-REPORTED CASE NOW RENDERS:
Before: 'When this piece captures: spawn a treasure marker on <square>
(permanent). When a piece enters a treasure marker: unknown
primitive: add-resource; destroy [object Object].'
After: 'When this piece captures: spawn a treasure marker on this
piece's Position (permanent). When a piece enters a treasure
marker: give white +5 score; destroy this marker.'
Spot-check on 5 recipes (all render sensible English):
- tpl-treasure-chest \u2713 (see above)
- tpl-time-bomb \u2713
- tpl-down-with-the-ship \u2713
- tpl-summoning-ritual \u2713
- tpl-bouncing-ricochet \u2713
CALLOUT FOR FUTURE EPICS (recorded in learnings.md):
narrate.ts has zero engine/registry imports by design. When a new primitive
or resolver shape lands, the narrator must be updated MANUALLY \u2014 there
is no static link to detect drift. Add a registry-count-style sentinel
test in a follow-up to catch this earlier (e.g. compare KIND_NARRATORS
keys against PRIMITIVE_REGISTRY.list()).
Wave 5 of thressgame-100 epic complete \u2014 paradigm-break wave per oracle.
User-authorized despite the cost. Coverage: 47/51 \u2192 50/51 = 98 %.
PARADIGM-BREAK CONTEXT (oracle pre-flagged):
Oracle classified Wave 5 as 'paradigm break \u2014 game-level mutable state'. User
explicitly authorized via 'no time/cost limit, no backward-compat constraint,
just fucking get it all done'. Design choice: scores live alongside RngStream
on GAME_ENTITY \u2014 same pattern as existing global state (RngStream,
ChoiceTimeoutPolicy, BoardTopology, BlockAllExceptKing), not new architecture.
ENGINE WORK (W5.0\u2013W5.4):
Score subsystem (locked decision F):
- WhiteScore + BlackScore attrs on GAME_ENTITY \u2014 type 'number', default 0
- Initialized at engine construction (engine.ts:632\u2013634) so fact-log inclusion
is mechanical \u2014 state-hash auto-includes scores via existing infrastructure
- N=100 byte-identical replay determinism verified
3 NEW PRIMITIVES:
- add-resource(player, amount) imperative; synchronous fire of
on-resource-changed hooks
- spend-resource(player, amount, imperative; selfRecurse:true \u2014 only the
then, else) chosen arm runs (walker doesn't auto-recurse
into both); cascade-depth-limited (8)
- on-resource-changed(player, trigger; fires on threshold crossing in
threshold, direction up/down/any; snapshot-before-iterate
direction, so hook arm registering more hooks doesn't
primitives) fire on the same crossing
WS PROTOCOL (W5.5) \u2014 Scenario A chosen: ZERO protocol changes:
- broadcast.ts uses session.allFacts() which iterates ALL facts on ALL entities
including GAME_ENTITY
- WhiteScore/BlackScore mutations land in game.state and game.delta frames
automatically; client PredictionManager receives them out-of-the-box
- Zod FactSchema has untyped .unknown() value field on the wire \u2014 number values
serialize implicitly
UI WORK (W5.6) \u2014 GameView.tsx score chips:
- Two chips: data-testid='score-white' (\u26aa W) and data-testid='score-black'
(\u26ab B), bordered chip styling matching ActionMenu sibling vocabulary
- HIDE-WHEN-ZERO rule: chips hidden unless either score !== 0 OR
OnResourceChangedHooks contains entries (pure-chess games visually unchanged)
- Real-time updates via existing useMultiplayerGame hook \u2014 broadcasts trigger
re-render, scores update without page reload
3 NEW RECIPES (W5.7):
Batch M \u2014 economy:
- tpl-treasure-chest \u2014 capture spawns treasure marker; entering treasure
awards white +5 score and consumes marker
(SIMPLIFIED: arm 2 hardcodes player='white' \u2014
no chooser-color resolver; LastModifierChooser
semantics = rule applier not current mover)
- tpl-cash-grab \u2014 every turn-end, white+1 + black+1 (passive income)
(SIMPLIFIED: V3 has no eq shape \u2014 random-pick result
can't be compared against piece Position; ship the
dual-add-resource pattern instead of canonical
random-square-rewards-piece-owner semantics)
- tpl-summoning-ritual \u2014 spend 5 score to summon white knight at e4
(SIMPLIFIED: single-shot at activation; repeatable
summoning needs request-choice loop \u2014 W6+ scope)
TEST SURFACE:
- add-resource.test.ts: ~10 unit tests
- spend-resource.test.ts: ~12 unit tests
- on-resource-changed.test.ts: ~15 unit tests
- economy-integration.test.ts: 8 integration tests
(incl. N=100 determinism)
- wave5-recipes-real.test.ts: 14 runtime tests
- recipes.test.ts: 5 \u00d7 62 = 513 expect calls
- wave5-economy.spec.ts (Playwright): 5 e2e tests
(3 load + summoning-ritual
success-path runtime +
cash-grab turn-end driven)
bun run check: 3270 tests pass (was 3192, +78). 0 regressions.
e2e: 5/5 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
KEY FINDINGS (recorded in learnings.md):
- spend-resource selfRecurse:true correctly gates walker auto-recursion \u2014
place-piece inside 'then' only runs when spend succeeds
- ctx-attr.entity:'self' resolves cleanly to ctx.pieceId in resolver
- fireOnTurnEndHooks (and other per-piece dispatchers) iterate pieces \u2014 hooks
on GAME_ENTITY are dead-seeded; apply-descriptor needs targetSquare for any
recipe rooted at a per-piece trigger
- Snapshot-before-iterate in fireOnResourceChangedHooks prevents reentrant
cascade fires on the same crossing
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 56 \u2192 59 (3 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 3 new kinds
- GameView snapshot: regenerated for new score-chip section
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave5.txt (gitignored, 1074 lines)
Wave 4 of thressgame-100 epic complete \u2014 highest-risk wave (move-gen
topology) survived with 0 regression. Coverage: 44/51 \u2192 47/51 = 92 %.
ENGINE WORK (W4.0\u2013W4.7):
Topology subsystem (locked decision G \u2014 opt-in via BoardTopology attr):
- New attr: BoardTopology on GAME_ENTITY \u2014 'standard' | 'wrap-files' | 'wrap-all'
- New helper: util/topology.ts \u2014 wrapSquare(rawCol, rawRow, topology) +
readBoardTopology(session)
- coord.ts threads optional topology through slidingSquares / knightSquares /
kingSquares (default 'standard' preserves all pre-W4 output bit-identical)
- rules/primitives.ts threads topology through rookCandidates, bishopCandidates,
queenCandidates, knightCandidates, kingCandidates, pawn{Single,Double}Advance,
pawnCaptureSqares
- rules/{sliding,knight,king,pawn}.ts read topology via readBoardTopology and
thread through candidate generators
- presets/core-piece-types.ts pawn attackProbe reads topology
- New imperative: set-board-topology(value) \u2014 modifies the GAME_ENTITY attr
Pairing subsystem (locked decision E):
- New attrs: PieceLink (per-piece, EntityId[]), OnPiecePairLinkBrokenHooks
(game-level)
- New imperative: link-pieces(a, b) \u2014 symmetric (adds B to A's links AND
A to B's links); self-link no-op
- New imperative: unlink-pieces(a, b) \u2014 symmetric removal
- New trigger: on-piece-pair-link-broken(target, primitives) \u2014 fires when
a linked partner is destroyed; introduces 'linkedPieceId' binding
- Cascade integration: engine.ts:dealDamage and destroy-piece.ts both fire
the on-piece-pair-link-broken cascade when a linked piece is destroyed.
PieceLink + the partner's link entry are auto-unlinked BEFORE the trigger
fires (prevents reentrant infinite loop \u2014 the cascade-loop fix).
Two critical bugs found and fixed (full walkthrough in evidence file):
1. CASCADE LOOP: original draft fired hooks BEFORE auto-unlinking; reentrant
destroy-piece(survivor) re-fired the hook on original \u2192 infinite loop.
Fix: clear dying piece's PieceLink AND prune partners' lists FIRST, then
fire hooks. Pinned by cascade test in on-piece-pair-link-broken.test.ts.
2. TOROIDAL CYCLE: wrap-all sliding rays cycle every 8 squares (queen on a1
loops back to a1 going horizontally). Added 7-cell cycle guard in
slidingSquares matching BASE_RANGE; king's extended-reach mode uses a
Set<Square> per ray to detect re-visits.
5 NEW RECIPES (W4.8\u2013W4.9):
Batch K \u2014 topology:
- tpl-pacman-style-cross-ref \u2014 modifier-shaped equivalent of wrap-board preset;
cross-references RULES.md#wrap-board in summary
- tpl-bouncing-ricochet \u2014 first multi-armed recipe in codebase: activation
seeds wrap-files, expire arm reverts to standard
Batch L \u2014 pairing:
- tpl-down-with-the-ship \u2014 white king linked to all white rooks; killing
the king cascade-destroys both rooks
- tpl-soul-link \u2014 cross-color knight linking; either dies, both
die (SIMPLIFIED: link ALL white knights to ALL
black knights since recipe library has no
per-piece pick mechanism in activation arms)
- tpl-hot-drop \u2014 spawn 2 white queens at e4+d4, link them;
killing one cascade-destroys the other
(SIMPLIFIED: hardcoded queens at d4/e4 \u2014
place-piece pieceType/color/square strict)
TEST SURFACE:
- util/topology.test.ts: 14 unit tests
- rules/topology.test.ts: 30 boundary tests across 6 piece
types \u00d7 3 topologies
- set-board-topology.test.ts: 9 unit tests
- link-pieces.test.ts: 9 unit tests
- unlink-pieces.test.ts: 8 unit tests
- on-piece-pair-link-broken.test.ts: 11 unit tests
- wave4-recipes-real.test.ts: 22 runtime tests
- wave4-topology-pairing.spec.ts e2e: 7 tests (5 load + 2 runtime,
including PredictionManager attr
probe + DOM piece visibility for
the linked-queens spawn)
bun run check: 3192 tests pass (was 3081, +111). 0 regressions.
e2e: 7/7 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 52 \u2192 56 (4 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 4 new kinds
- wave3-recipes-real.test.ts: count assertion widened toBe \u2192 toBeGreaterThanOrEqual
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave4.txt (gitignored, 1076 lines)
Wave 3 of thressgame-100 epic complete. 85 % MILESTONE HIT.
Coverage: 37/51 \u2192 44-45/51 = 86-88 % (depending on overlap accounting; both
clear the 85 % gate).
W3.0 AUDIT \u2014 request-choice capabilities verified:
- 6 supported kinds (Zod schema): rps | piece | square | column | row | coin-flip
- (NOT yes-no, NOT number \u2014 plan brief was inaccurate; updated learnings.md)
- All 6 wired through schema \u2192 apply() \u2192 RequestChoiceModal.tsx \u2192 unit tests
- No gaps to fix.
8 NEW RECIPES (W3.2\u2013W3.5):
Batch G \u2014 choice-driven spawn (kind: square):
- tpl-bottomless-pit \u2014 pick a square; permanent pit there
- tpl-call-down-lightning \u2014 pick a square; death-square spawns there
(SIMPLIFIED: lethality moved to consumer arm
\u2014 destroy-piece needs entity-id not square)
- tpl-portal-storm \u2014 pick 2 squares; spawn a linked portal pair
Batch H \u2014 choice-driven swap:
- tpl-anti-camping-choice \u2014 pick victim + swapper; swap them
(SIMPLIFIED: random swap not expressible \u2014
with-probability gates per iteration not
picks one)
- tpl-two-kids-trenchcoat \u2014 sacrifice 2 pieces; bishop@e4
(SIMPLIFIED: place-piece pieceType/color/square
hardcoded \u2014 strict literal enums)
Batch I \u2014 choice-driven self-modification:
- tpl-blood-sacrifice \u2014 sacrifice one piece; +5 Hp to another
(uses W1.6's add-to-attribute.target redirect)
- tpl-summoning-ritual-light \u2014 sacrifice + 50/50 knight-or-bishop@e4
(SIMPLIFIED: hardcoded type/color/square +
no resource cost \u2014 W5 territory)
Batch J \u2014 sophie's-choice:
- tpl-sophies-choice \u2014 both players pick own piece; both die
(forPlayer:'both' verified working as in
tpl-mr-freeze)
FOUR DOCUMENTED SIMPLIFICATIONS (full rationale in evidence file Section 4):
- tpl-call-down-lightning: lethality dropped (no Position-comparison primitive)
- tpl-anti-camping-choice: random-swap dropped (with-probability per-iteration
semantics)
- tpl-two-kids-trenchcoat: place-piece hardcoded (strict literal enums)
- tpl-summoning-ritual-light: hardcoded place + RNG branch (no resource yet)
KEY RUNTIME DISCOVERY (documented in learnings.md):
- runPrimitives catches SuspendedExecution INTERNALLY and returns; does NOT
re-throw. Test pattern is to read PendingChoices off GAME_ENTITY after the
call rather than asserting throw.
- For multi-step request-choice e2e: poll on data-choice-id flip rather than
visibility (modal close+reopen is sub-frame). Canonical idiom for future waves.
TEST SURFACE:
- wave3-recipes-real.test.ts: 26 unit tests (60 expect calls)
- recipes.test.ts: 5 \u00d7 54 = 444 expect calls
- wave3-choices.spec.ts (Playwright): 11 e2e tests (8 load + 3 runtime,
including FIRST multi-step request-choice
runtime test \u2014 portal-storm 2-step
square picker with poll-on-data-choice-id
assertion idiom)
bun run check: 3081 tests pass (was 3055, +26). 0 regressions.
e2e: 11/11 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
ANTI-CAMPING OVERLAP NOTE:
Both tpl-anti-camping (W2 dormant variant) and tpl-anti-camping-choice (W3
choice variant) map to the single upstream ThressGame rule `anti_camping`.
This is intentional \u2014 two different mechanical interpretations of the same
rule name. Documented in evidence file with dual coverage accounting:
- 45/51 = 88 % (recipe-vs-denominator convention, matches plan target)
- 44/51 = 86 % (strict unique-rule convention)
Both clear the 85 % milestone.
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave3.txt (gitignored, 832 lines)
- docker-compose.dev.yml: hot-reload dev stack (rete-watch + server :7357 + web :5173)
- docker-compose.yml: production stack
- packages/chess/Dockerfile + nginx.conf: chess web image
- packages/server/Dockerfile: server image
- Dockerfile.dev: shared dev base image (Bun + workspace deps preinstalled)
- .dockerignore: build context exclusions
Used by Playwright e2e tests (run via .sisyphus/scripts/run-pw.sh which connects to the running dev stack instead of spawning its own server).
The helper previously set CI=true, which flipped Playwright's
`reuseExistingServer` to false. That caused Playwright to spawn its own
Vite dev server on :5173, colliding with the docker compose stack
(paratype-web-dev container).
Removing CI=true makes Playwright connect to the running dev server in
docker. Verified: 146/146 e2e tests pass against docker-compose.dev.yml.
0 fixmes. 0 skips. EXIT_CODE=0.
T85: Wired Wave 12 move-gen attrs into engine.ts:getAllLegalMoves (the path the drag UI actually uses):
- BlockAllExceptKing (game-level early-return)
- BlockedPieceTypes (game-level early-return)
- MovesAs (per-piece substitution via lookupMoveGenerator)
- MovesAlsoAs (additive; deduped via dedupeMoves helper)
- MoveClassRestriction (post-filter on the entire move set)
Previously these attrs only filtered rules/turn.ts:getLegalMovesForPiece, but the production drag path goes through engine.ts. Now both paths apply identical filters.
T86: engine.ts:applyMove now honors isPawnPush:
- pushedPieceId moved to pushedTo (defender shoved forward)
- pawn moves to diagonal target square (no capture retract)
- HasMoved set on pawn
- Hook firing + turn advancement preserved
5 fixmes lifted in move-gen-attrs.spec.ts (MovesAs, MovesAlsoAs, BlockedPieceTypes, MoveClassRestriction, PawnPushesPiecesEnabled).
1 fixme lifted in orphan-primitives.spec.ts (must-class consumer now active).
E2E status: 30/30 thressgame-coverage tests PASS. 0 fixmes. 0 skips.
Unit tests: 2866 -> 2868 (+2 from new applyMove unit tests). bun run check exit 0.
Closes critical gaps surfaced by oracle gap audit:
- T70 (C1): server WS submit-choice handler now calls submitChoiceAndResume(engine, choiceId, value); both submit and timeout-default paths thread the value through the resume helper. Game state delta broadcast after resume.
- T71 (C3): fireOnRuleExpireHooks dispatcher implemented (mirrors fireOnRuleActivatedHooks pattern); engine.detachCustomDescriptor(descriptorId) method; WS custom-modifier.remove action with full integration; on-rule-expire hooks now actually fire on detach (was a public stub).
- T72 (C4): real cancel-capture via snapshot+restore. applyMove now snapshots defender's facts as LastCaptureSnapshot on GAME_ENTITY before retract; integration preset checks CaptureCancelled flag after fireOnCapturedHooks; if true, re-inserts defender facts and reverts mover's Position. Parry rule now actually undoes a capture in real play.
- T73: client-side request-choice plumbing. GameClient.dispatchServerMessage routes request-choice WS frames to a new GameClientEvent; sendSubmitChoice convenience method; useMultiplayerGame hook exposes pendingChoice + submitChoice; GameView renders RequestChoiceModal when pendingChoice is set.
Tests: 2744 -> 2774 (+30). bun run check exit 0.
Final Verification Wave found two real blockers:
1. T58 RequestChoiceModal.tsx was marked complete but did NOT exist on disk.
2. request-choice locked 6-kind enum was shipped as 5 (missing 'coin-flip').
Remediation:
- Build RequestChoiceModal.tsx with role=dialog, aria-modal=true, ESC/backdrop close, kind-specific input UI for all 6 kinds (rps / coin-flip / piece / square / column / row); 4 tests
- Add 'coin-flip' to:
- request-choice primitive paramsSchema enum
- PendingChoice.kind union (schema.ts + util/pending-choices.ts)
- WS protocol ChoiceKindSchema (server/protocol.ts)
- choice-timeout.firstDefaultForKind (defaults to 'heads')
- broadcast.isValidChoiceValue (accepts 'heads' | 'tails')
- AutoChoiceResolver: deterministic alternating heads/tails for coin-flip
T68 e2e tests remain .skip()'d pending UI integration (modal-into-GameView wiring + activate-descriptor UI) — a follow-up task. The sentinel test asserts the gap exists so when integration lands, skips lift in the documented order.
Tests: 2740 -> 2744 (+4). bun run check exit 0.
Bundles work that wasn't included in T54's standalone commit:
- T51: palette taxonomy 6 categories
- T52: narrate.ts entries for 31 new kinds (35 new tests)
- T53: ParamSquarePicker
- T55: ParamMarkerKindEnum
- T56: ParamLifetimeConfig
- T57: Board marker overlays
- T58: RequestChoiceModal (already committed elsewhere or part of this)
(T54 ParamPiecePicker was committed in 90942bc)
Tests: 2658 -> 2703 (+45). bun run check exit 0.
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).
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.
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).
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).
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.
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).
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.
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.
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.
- 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
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.