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.
3.1 KiB
3.1 KiB
Architectural Decisions — Visual Modifier Builder
DSL Extensions
PrimitiveApplyContext extension (T1)
- Add
target?: TargetResolver(default'self'if omitted) - Add
event?: PrimitiveEvent(carries trigger-specific metadata) - TargetResolver =
'self' | 'attacker' | 'defender' | { squares: Square[] } | { relation: 'ally' | 'enemy', filter?: { pieceType?: PieceType } } - Centralized resolver:
resolveTargets(ctx, target): readonly EntityId[]— returns[ctx.pieceId]for default 'self' - Existing 14 primitives: keep using
ctx.pieceIddirectly when target is 'self' (default) — byte-identical behavior
Trigger semantic decisions (locked)
on-promotion: fires AFTER PieceType flip. event ={promotedFrom: 'pawn', promotedTo: PieceType}on-check-received: edge-triggered (transition only); royal pieces onlyon-check-delivered: attributes to revealing piece in discovered checkon-move: fires whenever Position WME changes (captures fire BOTH on-capture and on-move)on-moved-onto-square: discriminated union{kind:'squares', squares[]} | {kind:'predicate', file?, rank?}on-captured: fires BEFORE retraction; nested primitives have access to defender attrs viactx.event = {attackerId, defenderId}on-turn-end: fires for mover at end of their turn, BEFORE opponent's on-turn-start
UI Decisions
Mode toggle (T22)
- localStorage key:
houserules:custom-modifier-editor-mode:v1 - Default: 'form' (preserves existing behavior on fresh install)
- Toggle in editor header next to Templates/Load/Save
- Visual mode replaces center+right panels (palette stays in left sidebar)
dnd-kit choice (T18)
- Use
@dnd-kit/core + sortable + utilities(3 packages) PointerSensor+KeyboardSensor(a11y mandatory)DragOverlayfor smooth nested drag preview- Each trigger's children list = nested SortableContext
- Depth-4 drop attempt = no-op + toast (use existing Sonner)
Preview pane (T17)
- 3 tabs: Narrative / JSON / Board
- All 3 views memoized on descriptor identity
- BoardDiagramView shows squares from on-moved-onto-square filters, aura radius, poisoned squares
- Empty descriptor = "No board effect" placeholder
- Plain pre/JSON formatting (no react-syntax-highlighter — bundle bloat)
narrate.ts (T15) — pure module
- Zero session/engine access
- Static kind→narrator function map (no PRIMITIVE_REGISTRY lookups)
- Cycle guard via visited Set
- Length cap 4000 chars with truncation suffix
- Perf budget: <1ms on 50-node descriptor
Test Strategy
TDD per primitive (T5–T11)
- RED: write test first, runs but fails
- GREEN: minimal implementation to pass
- One commit per primitive (atomic)
Pure refactor (T14 — ParamField)
- Snapshot test BEFORE extraction (golden DOM)
- Extract verbatim from CustomModifierEditor.tsx:625-872
- Snapshot test AFTER extraction must match byte-equal
- Existing e2e tests must pass unchanged
Backward compat (T20)
- Fixture: descriptor using ONLY 15 legacy kinds, includes 2 nested triggers
- 4 round-trip assertions: parse, validate, apply, serialize → byte-equal