fix(ui): rules drawer Esc handling — unblock board after drawer close

The T1 ModifierProfileEditor installed a window-level Esc handler that
closed the modal but the RulesDrawer had no Esc handler of its own.
Users hitting Esc with the drawer open (no modal) saw nothing happen;
worse, with both open+modal, closing the modal left the drawer's
pointer-events-blocking backdrop in place, silently breaking all
board drag-interaction afterward.

Fix:
- Add useEffect-based Esc handler to RulesDrawer that closes it when
  no nested modal is active.
- ModifierProfileEditor now uses capture-phase + stopImmediatePropagation
  so the drawer's Esc handler does NOT also fire on the same keystroke,
  preventing double-close.

Add packages/chess/e2e/solo-smoke.spec.ts — 5 regression scenarios
that would have caught this at T1 CI time. Test 4 specifically
reproduces the original bug (drawer open → Esc → drag board pieces).

Also queue 2 additional scenarios in the T2 plan since T2 work extends
both drawer + editor further.

All tests green: 1217 unit tests (94 files), 48 Playwright e2e in 1.3m.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 08:38:05 -06:00
commit 728ad76a5e
No known key found for this signature in database
5 changed files with 1262 additions and 3 deletions

View file

@ -0,0 +1,547 @@
# Modifier Profiles — T2 Polish
## TL;DR
> **Quick Summary**: UX and robustness polish on top of shipped T1 modifier profiles. Adds copy/paste between pieces, a visual "this piece is modified" indicator on the live board, inline conflict-resolution in the editor, undo/redo in the editor, proper turn-boundary queuing server-side (replaces T1's immediate-apply simplification), two-player consent model for mid-game swaps (replaces host-only), and richer source-chain attribution in the pinned inspection panel.
>
> **Deliverables**:
> - Editor: copy/paste modifiers, undo/redo stack, inline conflict resolution UI
> - Board: subtle modifier-indicator badge on modified pieces (no hover required)
> - Server: turn-boundary queue (ADR-3 done properly), both-player consent for hot-swap
> - Inspection: enhanced source-chain breakdown in pinned panel
> - Full e2e vertical slice
> - Updated ADR + user docs
>
> **Estimated Effort**: Medium (15 tasks)
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: T1 (turn-queue server) → T3 (consent) → T13 (e2e)
---
## Context
### Original Request
> "can we do t2 and t3? T2 first, then T3 (Recommended)"
### T1 Recap (shipped)
T1 (completed) delivered the foundation: 6 built-in modifier descriptors, per-type + per-instance scope, editor UI, library persistence, URL sharing, hover tooltip, pinned panel, server-side validation, immediate-apply hot-swap, host-only authority. Full vertical slice working with 18/18 Playwright tests.
### T2 Rationale
Two categories of work:
1. **UX polish**: features users will notice immediately — copy/paste, visual indicators, undo/redo, better conflict messages.
2. **Robustness fixes**: close the documented simplifications from T1's Implementation Retrospective — turn-boundary queue (not immediate) + two-player consent (not host-only).
### Architectural Decisions (inherited from T1 ADRs)
Most T2 work doesn't need new ADR decisions. Three small extensions:
**T2-ADR-1: Turn-boundary queue**
- Client sends `modifier-profile.update` at any time.
- Server enqueues to `room.pendingProfile`. Apply runs in the server's `onAfterMove` hook after next move is validated.
- If pending profile is invalid when apply fires → NACK to sender, clear pending, no broadcast.
- Multiple updates before apply: last-write-wins (replace pending).
- Replaces T1 simplification where swap applied immediately on receipt.
**T2-ADR-2: Two-player consent**
- Host sends `modifier-profile.propose` with candidate profile.
- Server broadcasts `modifier-profile.proposal-pending` to opponent with profile contents.
- Opponent sends `modifier-profile.consent` with approve/reject.
- If approve → apply at next turn boundary (T2-ADR-1). If reject → clear proposal, broadcast `modifier-profile.rejected`.
- Timeout: 60s → auto-reject.
- Replaces T1's unilateral host authority.
**T2-ADR-3: Undo/redo in editor**
- Editor maintains a snapshot stack of working-profile states.
- Every meaningful user action (add/delete/edit a modifier) pushes a snapshot.
- Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z redoes.
- Stack capped at 50 snapshots.
- Cleared on save or cancel.
---
## Work Objectives
### Core Objective
Polish the T1 modifier-profile system with features users expect from a mature editor + close the documented T1 simplifications (immediate-apply, host-only) to ship production-grade semantics.
### Concrete Deliverables
**Editor UX** (`packages/chess/src/ui/`):
- `ModifierProfileEditor.tsx` — add undo/redo stack, toolbar with history controls
- `PerTypePanel.tsx` — add "Copy" + "Paste" buttons on each modifier row
- `PerInstancePanel.tsx` — add copy/paste between pieces ("copy from b1" → "paste to g1")
- `ConflictResolutionPanel.tsx` — NEW: shows validation errors inline with suggested fixes + auto-resolve options
**Board UX** (`packages/chess/src/ui/`):
- `ModifiedPieceIndicator.tsx` — NEW: small badge/glow on modified pieces
- `GameView.tsx` + `Board.tsx` — wire indicator into piece rendering
**Server** (`packages/server/src/`):
- `broadcast.ts` — `handleModifierProfileUpdate` now enqueues instead of applying
- `game-session.ts` — new `applyPendingProfile()` method called after `applyMove()`
- New handlers: `handleModifierProfilePropose`, `handleModifierProfileConsent`
- `rooms.ts` — `Room.pendingProfile`, `Room.proposalState`, `Room.proposalTimeoutHandle`
- `protocol.ts` — new messages: `modifier-profile.propose`, `modifier-profile.proposal-pending`, `modifier-profile.consent`, `modifier-profile.rejected`
**Inspection** (`packages/chess/src/ui/`):
- `ModifierPinnedPanel.tsx` — enhanced source breakdown:
- Per-instance entries labeled "(from layout square X)"
- Per-type entries labeled "(applies to all {color} {type}s)"
- Preset entries labeled "(from {preset name})"
- Base values labeled "(default)"
**Docs**:
- Update `docs/adr/modifier-profiles.md` — add T2-ADR-1, T2-ADR-2, T2-ADR-3 sections
- Update `docs/user/modifier-profiles.md` — document new features
**E2E** (`packages/chess/e2e/`):
- Extend `modifier-profiles.spec.ts` — 8 new scenarios
### Definition of Done
- [ ] `bun run check` green (typecheck + lint + vitest)
- [ ] Copy/paste between pieces: adding a per-instance modifier to b1, then copying to g1, produces identical entries at both squares
- [ ] Undo/redo: 5 actions then undo 3 times then redo 2 times produces state equal to 4-action state
- [ ] Visual indicator visible on modified pieces without requiring hover
- [ ] Turn-boundary queue: profile update during mid-move doesn't apply until after opponent's move resolves
- [ ] Two-player consent: black must approve before profile change broadcasts
- [ ] Proposal timeout: 60s no-response → auto-reject
- [ ] Inline conflict resolution shows specific error + suggested fix
- [ ] Source chain in pinned panel distinguishes all 4 sources
- [ ] Playwright e2e: 18 existing + 8 new = 26 scenarios all pass
- [ ] ADR + user docs updated
### Must Have
- All 8 UX/polish deliverables working end-to-end
- Turn-boundary queue replaces immediate-apply (no regression — hot-swap still works)
- Two-player consent with 60s timeout
- Editor undo/redo with 50-snapshot cap
- Shared schema between client/server (no drift)
### Must NOT Have (Guardrails)
- ❌ Custom modifier authoring (T3)
- ❌ Cross-piece aura effects (T3)
- ❌ Multi-profile stacking (post-T3)
- ❌ Editor persistence across sessions (reload = fresh editor, library is separate)
- ❌ Server-side undo (client-side only — no WS messages for undo state)
- ❌ Breaking changes to T1 WS messages (all additions are NEW messages; `modifier-profile.update` becomes an alias for `propose` + auto-approve when single-player)
- ❌ Mid-turn swap (turn-boundary gate is enforced)
- ❌ Client computing effective modifiers differently from server
### Must NOT Have (AI Slop Patterns)
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
- ❌ Generic names (`data`, `result`, `item`, `temp`)
- ❌ Empty catch blocks
- ❌ `console.log` in production code
- ❌ Premature abstraction
---
## Verification Strategy
### Test Decision
- **Infrastructure**: vitest + Playwright, same as T1.
- **Approach**: TDD for server + engine; Playwright scenarios for UX features.
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t2/task-{N}-{slug}.{ext}`.
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Server robustness foundation — PARALLEL):
├── T1: T2-ADR documentation (append to existing ADR)
├── T2: Turn-boundary queue server-side
└── T3: Two-player consent protocol
Wave 2 (Editor features — PARALLEL):
├── T4: Undo/redo snapshot stack in editor
├── T5: Copy/paste modifiers between pieces
└── T6: Inline conflict resolution panel
Wave 3 (Board UX + Inspection polish — PARALLEL):
├── T7: Modified-piece indicator on live board
├── T8: Enhanced source-chain in pinned panel
└── T9: Consent UI (proposal notification + approve/reject buttons)
Wave 4 (E2E + Docs — PARALLEL):
├── T10: Playwright e2e additions (8 scenarios)
├── T11: ADR updates
└── T12: User docs updates
Wave FINAL (4 parallel reviewers):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Manual QA
└── F4: Scope fidelity check
```
### Dependency Matrix
| Task | Depends On | Blocks |
|------|------------|--------|
| 1 | — | 2-12 |
| 2 | 1 | 3, 10, 11 |
| 3 | 2 | 9, 10, 11 |
| 4 | 1 | 10 |
| 5 | 1 | 10 |
| 6 | 1 | 10 |
| 7 | 1 | 10 |
| 8 | 1 | 10 |
| 9 | 3 | 10 |
| 10 | 2-9 | F1-F4 |
| 11 | 1, 2, 3 | F1 |
| 12 | 4-9 | F1 |
---
## TODOs
- [ ] 1. **T2-ADR documentation**
**What to do**:
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
- `## T2-ADR-1: Turn-boundary queue` — full semantics, last-write-wins, NACK on invalid at apply time
- `## T2-ADR-2: Two-player consent` — proposal/consent flow, 60s timeout, auto-reject
- `## T2-ADR-3: Editor undo/redo` — snapshot stack, 50-item cap, cleared on save/cancel
**Must NOT do**: Don't write any code in this task.
**Recommended Agent Profile**: `writing` — no skills
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
**Acceptance Criteria**:
- [ ] File has 3 new T2-ADR sections
- [ ] `grep -c "^## T2-ADR-" docs/adr/modifier-profiles.md` → 3
**Commit**: `docs(adr): T2 polish architecture decisions`
- [ ] 2. **Turn-boundary queue server-side**
**What to do**:
- Replace `handleModifierProfileUpdate`'s immediate-apply with queue semantics:
- Add `Room.pendingProfile?: ModifierProfile` field (may already exist from T1 — re-use or add)
- On receive: validate shape, store in `room.pendingProfile`, ack with `modifier-profile.queued`
- On `applyMove()` success: check pendingProfile, validate against post-move session, apply via `reconcileProfileSwap`, broadcast `modifier-profile.updated`, clear pending
- If pending profile invalid at apply time: NACK to original sender with specific error code, clear pending
- Last-write-wins: new pending replaces old pending before apply fires
**Must NOT do**: Don't break T1 e2e tests. Don't apply mid-move.
**Recommended Agent Profile**: `deep` — concurrency-sensitive
**Parallelization**: Wave 1 (PARALLEL with T1, T3). Blocks: T3, T10, T11. Blocked By: T1.
**References**:
- `packages/server/src/broadcast.ts` — current `handleModifierProfileUpdate`
- `packages/server/src/game-session.ts` — `applyMove`, post-move hook location
- `docs/adr/modifier-profiles.md` § T2-ADR-1
**Acceptance Criteria**:
- [ ] Update sent during move — profile does NOT apply until opponent completes their move
- [ ] 2 rapid updates → only the last is applied
- [ ] Invalid pending at apply time → NACK to sender, no broadcast
- [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` — all pass (updated)
**Commit**: `feat(server): turn-boundary queue for modifier profile updates`
- [ ] 3. **Two-player consent protocol**
**What to do**:
- New WS messages in `protocol.ts`:
- `modifier-profile.propose` (client→server) — host sends candidate profile
- `modifier-profile.proposal-pending` (server→opponent) — notify with profile contents
- `modifier-profile.consent` (client→server) — opponent sends approve/reject
- `modifier-profile.rejected` (server→host) — proposal rejected or timed out
- `broadcast.ts`:
- `handleModifierProfilePropose` — store proposal + start 60s timeout
- `handleModifierProfileConsent` — on approve: promote to pendingProfile (feeds into T2 queue); on reject: clear + broadcast rejected
- On timeout: auto-reject
- `rooms.ts`:
- `Room.proposalState?: { profile, proposedBy, timeoutHandle, proposedAt }`
- Solo mode / host-only mode: `modifier-profile.update` alias auto-approves
**Must NOT do**: Don't change `modifier-profile.update` semantics in solo mode.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 1. Blocks: T9, T10, T11. Blocked By: T2.
**References**:
- `docs/adr/modifier-profiles.md` § T2-ADR-2
**Acceptance Criteria**:
- [ ] Propose → opponent sees pending → approve → broadcast to both
- [ ] Propose → opponent rejects → only host sees rejected, no broadcast to board
- [ ] Propose → 60s timeout → auto-reject
- [ ] Solo mode: propose = immediate apply (no consent step)
**Commit**: `feat(server): two-player consent for modifier profile swaps`
- [ ] 4. **Editor undo/redo snapshot stack**
**What to do**:
- In `ModifierProfileEditor.tsx`:
- `const [history, setHistory] = useState<ModifierProfile[]>([initialProfile])`
- `const [historyIndex, setHistoryIndex] = useState(0)`
- `const currentProfile = history[historyIndex]`
- Every mutation path goes through `pushSnapshot(newProfile)` which truncates forward history and caps at 50
- `undo()` / `redo()` adjust historyIndex
- Keyboard handlers for Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z
- Header toolbar with Undo/Redo buttons (disabled when at ends of stack)
- `data-testid="undo-button"`, `data-testid="redo-button"`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add 3 modifiers → undo 2 → profile has 1 modifier
- [ ] Undo then redo → same state
- [ ] Cap at 50 snapshots (add 51st → oldest dropped)
- [ ] Save or cancel clears history
**Commit**: `feat(ui): modifier editor undo/redo`
- [ ] 5. **Copy/paste modifiers between pieces**
**What to do**:
- In `PerInstancePanel.tsx`:
- When a square is selected: "Copy modifiers from this square" button
- When a different square is selected after copy: "Paste modifiers here" button (disabled when clipboard empty)
- Clipboard is component-local state (not OS clipboard)
- Paste appends all clipboard entries to the target square, with the kind preserved
- In `PerTypePanel.tsx`:
- "Copy to clipboard" on each row
- "Paste" button appends from clipboard
- `data-testid="copy-modifier-b1"` etc.
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add HP+2 to b1 → copy → paste on g1 → both have HP+2
- [ ] Paste when clipboard empty: button disabled
- [ ] Copy doesn't delete source
**Commit**: `feat(ui): copy/paste modifiers between pieces`
- [ ] 6. **Inline conflict resolution panel**
**What to do**:
- New component `ConflictResolutionPanel.tsx`:
- Shows validation errors from `validateProfile()` inline
- Each error has a "Fix" button with a specific auto-resolve action:
- `E_PROFILE_NO_KING` → "Add king to e1" button (or suggest switching layout)
- `E_PROFILE_INVULN_KING` → "Remove invuln from king" button
- `E_PROFILE_ORPHAN_INSTANCE` (warning) → "Remove orphan entry" button
- `E_PROFILE_ATTR_LIMIT` → "Show affected pieces" + manual resolution
- Panel visible whenever profile has errors/warnings; hidden when clean
- Wire into `ModifierProfileEditor.tsx` — show panel at the top when dirty
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Create profile with invuln on king → panel shows error + Fix button
- [ ] Click Fix → error resolved
- [ ] Panel hidden when profile validates clean
**Commit**: `feat(ui): inline conflict resolution panel`
- [ ] 7. **Modified-piece indicator on live board**
**What to do**:
- New component `ModifiedPieceIndicator.tsx`:
- Small colored dot (or glow/border) shown on pieces that have any active modifier
- Reads `MODIFIER_REGISTRY.list()` and checks `session.get(pieceId, attr)` for each
- If any defined → indicator visible
- CSS: absolute-positioned top-right corner of piece square, 8px dot
- Wire into `Board.tsx` — render indicator alongside piece
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Piece with modifier: indicator visible
- [ ] Piece without modifier: no indicator
- [ ] Indicator updates reactively on profile swap
**Commit**: `feat(ui): modified-piece indicator on board`
- [ ] 8. **Enhanced source-chain in pinned panel**
**What to do**:
- Update `ModifierPinnedPanel.tsx`:
- For each modifier row, add source label:
- If piece's attr value matches a profile.perInstance entry at its square → "(per-instance: {square})"
- Else if matches a perType entry for this pieceType+color → "(per-type: all {color} {type}s)"
- Else if some active preset declared this attr → "(preset: {preset.name})"
- Else → "(default)"
- Reads the engine's active profile + presets via a new `getModifierSource(engine, pieceId, kind)` helper in `packages/chess/src/modifiers/source.ts`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Per-instance modifier → source labels show "per-instance: b1"
- [ ] Per-type modifier → source labels show "per-type: all white knights"
- [ ] piece-hp preset → source labels show "preset: Hit Points"
**Commit**: `feat(ui): enhanced modifier source chain in pinned panel`
- [ ] 9. **Consent UI (proposal notification + buttons)**
**What to do**:
- New component `ModifierProposalDialog.tsx`:
- Shows when opponent has pending proposal (`modifier-profile.proposal-pending` received)
- Displays summary of proposed profile changes
- Approve / Reject buttons
- 60s countdown timer
- Wire into `GameView.tsx` via WS message subscription
- Host sees a "Proposal sent — waiting for opponent" state after proposing
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T3.
**Acceptance Criteria**:
- [ ] Host proposes → opponent sees dialog within 500ms
- [ ] Approve → profile applies at next turn boundary
- [ ] Reject → dialog closes on both sides, host sees "Proposal rejected"
- [ ] Timeout → auto-reject
**Commit**: `feat(ui): consent dialog for modifier profile proposals`
- [ ] 10. **Playwright e2e additions (8 feature scenarios + solo regression guards)**
**What to do**:
- Add 8 new T2-feature tests to `packages/chess/e2e/modifier-profiles.spec.ts`:
1. Editor undo/redo: add 3 modifiers, undo 2, redo 1
2. Copy modifier between squares (per-instance)
3. Paste button disabled when clipboard empty
4. Conflict panel shows error + Fix works
5. Modified-piece indicator visible without hover
6. Source chain distinguishes per-instance vs per-type
7. (multiplayer) Proposal → approve → both clients see update
8. (multiplayer) Proposal → reject → no update
- **Keep and expand `packages/chess/e2e/solo-smoke.spec.ts`** (already exists from T1 post-audit fix). Adds ongoing regression coverage for solo play — these tests caught the T1 Rules-Drawer-Esc bug. Ensure the following scenarios remain and are extended:
- play-solo button navigates to /game with fresh board
- drag pawn e2→e4 resolves
- 4-ply sequence (e4 e5 Nf3 Nc6) completes
- **Rules drawer opens, closes via Esc, board remains interactive** (the T1 regression guard)
- No console errors on solo game start
- Add 2 more solo-regression scenarios in T2 since T2 touches drawer/editor further:
- Rules drawer: backdrop click closes drawer (workaround path still works)
- Modifier editor: Esc closes editor without dismissing drawer underneath (nested modal ordering)
**Why both?** `solo-smoke.spec.ts` is the canary — it exercises the baseline game flow without any modifier-profile setup. `modifier-profiles.spec.ts` covers feature paths. Keeping both prevents T2/T3 work from silently regressing solo play.
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
**Parallelization**: Wave 4. Blocks: F1-F4. Blocked By: T2-T9.
**Acceptance Criteria**:
- [ ] 26/26 modifier-profile tests pass (18 from T1 + 8 new)
- [ ] 7+ solo-smoke tests pass (5 original + 2 T2 additions)
- [ ] Multiplayer suite unchanged, still passes
- [ ] Total Playwright runtime < 120s
**Commit**: `test(e2e): T2 polish vertical slice + solo regression guards`
- [ ] 11. **ADR updates — Implementation Retrospective T2 addendum**
**What to do**:
- Append to `docs/adr/modifier-profiles.md`:
- `## T2 Implementation Retrospective` section noting:
- T1's immediate-apply simplification resolved (T2-ADR-1)
- T1's host-only simplification resolved (T2-ADR-2)
- Editor UX parity with modern standards (undo/redo, copy/paste)
- Any deviations found during implementation
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T1, T2, T3.
**Commit**: `docs(adr): T2 implementation retrospective`
- [ ] 12. **User docs updates**
**What to do**:
- Update `docs/user/modifier-profiles.md`:
- Update "Hot-Swap" section: describe proposal/consent flow (remove T1's "host-only" note)
- Add "Editor Features" section: undo/redo (keyboard shortcuts), copy/paste, conflict resolution
- Add "Board Indicators" section: modified-piece glow
- Update "In-Play Inspection" section: mention enhanced source chain
- Update "Known Limitations" section: remove T1 items, add T3 preview
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T4-T9.
**Commit**: `docs(user): T2 modifier profile features`
---
## Final Verification Wave
- [ ] F1. **Plan Compliance Audit** — `oracle`
Verify all 12 tasks' deliverables exist. Verify T1 simplifications resolved. Check evidence files.
Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [3/3] | VERDICT: APPROVE/REJECT`
- [ ] F2. **Code Quality Review** — `unspecified-high`
`bun run check`. Scan for slop patterns. Verify server-shared schemas.
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass] | VERDICT`
- [ ] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
Execute all 26 Playwright scenarios. Multiplayer proposal/consent roundtrip. Undo/redo combos.
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
- [ ] F4. **Scope Fidelity** — `deep`
No T3 features smuggled (no custom authoring, no auras, no multi-profile stacking). No breaking WS changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN] | VERDICT`
---
## Commit Strategy
1. `docs(adr): T2 polish architecture decisions`
2. `feat(server): turn-boundary queue for modifier profile updates`
3. `feat(server): two-player consent for modifier profile swaps`
4. `feat(ui): modifier editor undo/redo`
5. `feat(ui): copy/paste modifiers between pieces`
6. `feat(ui): inline conflict resolution panel`
7. `feat(ui): modified-piece indicator on board`
8. `feat(ui): enhanced modifier source chain in pinned panel`
9. `feat(ui): consent dialog for modifier profile proposals`
10. `test(e2e): T2 polish vertical slice`
11. `docs(adr): T2 implementation retrospective`
12. `docs(user): T2 modifier profile features`
## Success Criteria
```bash
bun run check # all green
bun run test packages/server/ # all pass (including updated ws tests)
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass
```
### Final Checklist
- [ ] All 9 feature tasks shipped end-to-end
- [ ] T1 simplifications (immediate-apply, host-only) fully resolved
- [ ] 26 Playwright e2e scenarios green
- [ ] ADR + user docs updated
- [ ] `bun run check` green
- [ ] F1-F4 all APPROVE
- [ ] User explicit "okay"

View file

@ -0,0 +1,601 @@
# Modifier Profiles — T3 User-Authored Custom Modifiers (DSL)
## TL;DR
> **Quick Summary**: Users can author their own modifier categories at runtime via a constrained config DSL. A custom modifier is a named, versioned, data-only descriptor composed from atomic effect primitives ("add N to attribute X", "add direction Y", "reduce damage by N%"). No code execution — purely structured data. Custom modifiers register into a per-game registry (alongside built-ins), persist in a separate library, and travel through the network via `ModifierProfileSchema` with full validation. Forward-designed for a T4 scripted-modifier extension.
>
> **Deliverables**:
> - Effect primitives catalog + registry for user-composable atoms
> - `CustomModifierDescriptor` type — data-only, validated, sandboxed
> - `CustomModifierEditor.tsx` — visual composer (no code typing)
> - Per-room custom modifier registration via WS protocol
> - Server-side validation of custom descriptors (anti-DoS, legality)
> - Modifier Profile editor: custom modifiers appear in kind dropdown alongside built-ins
> - Per-user library for custom descriptors + sharing
> - Cross-piece aura effects (built on the same primitive model)
> - Multi-profile stacking (ordered composition)
> - Full e2e vertical slice
> - T4 design note: how scripted modifiers would plug in
>
> **Estimated Effort**: Large (25 tasks, ~3-4 execution waves like T1)
> **Parallel Execution**: YES — 5 waves
> **Critical Path**: T1 (primitives ADR) → T3 (primitive registry) → T5 (custom descriptor type) → T13 (engine apply) → T19 (e2e)
---
## Context
### Original Request
> "Start DSL, design for script upgrade" (T3 approach)
> "T2 first, then T3 (Recommended)" (execution order)
### Pre-requisites
- T1 (shipped): built-in modifier descriptors + registry + editor + server integration
- T2 (must ship first): turn-boundary queue, two-player consent, undo/redo, copy/paste, conflict resolution
### T3 Rationale
Extend the T1/T2 foundation so users can define new modifier CATEGORIES (not just configure existing ones).
Example user-authored modifier: "Shield — piece has 3 shield charges. Each incoming damage spends one charge instead of reducing HP. No charges → damage falls through." Users compose this from primitives:
- `consume-attribute-on-damage` primitive with attr="ShieldCharges", consume=1, absorb=true
- `seed-attribute` primitive with attr="ShieldCharges", value=3
### Architecture (new ADRs)
**T3-ADR-1: DSL is structured data, not code**
Custom modifiers are composed from a fixed catalog of ~15 effect primitives. Each primitive is a TypeScript function (shipped in the engine) that takes parameters. Users compose primitives in the UI; the resulting JSON object IS the modifier. No eval, no sandbox, no script parsing — structured validation only.
**Rejected alternatives**:
- Sandboxed script runtime (QuickJS, etc.) — defers to T4. Security surface too large for T3.
- AST-based mini-language — same complexity as sandboxed script.
- String template interpolation — too limited.
**T3-ADR-2: Effect Primitive catalog (T3 v1)**
Primitives are atomic, composable, pure. Full catalog:
| Primitive | Parameters | Effect |
|-----------|------------|--------|
| `seed-attribute` | attr, value | Seeds a fact on the piece at profile apply time |
| `add-to-attribute` | attr, delta | Adds delta to existing attr value (additive stacking) |
| `multiply-attribute` | attr, factor | Multiplies existing attr value |
| `add-direction` | directions[] | Adds to DirectionAdditions |
| `set-capture-flag` | flag | ORs into CaptureFlags |
| `absorb-damage-with-attribute` | attr, rate | Each damage point consumes `rate` of attr instead of HP |
| `reflect-damage` | percentage | Damage sends back to attacker at percentage |
| `block-move-type` | moveType (capture / step / slide) | Filter out moves matching criteria |
| `add-aura` | radius, targetAttr, delta | For each piece within radius, add delta to attr |
| `on-turn-start` | primitive[] | Runs contained primitives at turn start |
| `on-capture` | primitive[] | Runs contained primitives when this piece captures |
| `on-damaged` | primitive[] | Runs contained primitives when this piece takes damage |
| `conditional` | condition, then-primitive[], else-primitive[] | Condition-branch (e.g., "if HP < 2, do X") |
| `modify-movement-range` | delta | RangeBonus integration |
| `override-promotion` | target | PromotionOverride integration |
Users compose these in a visual editor. Each primitive has a known shape, validation, UI form, and engine integration. Adding a new primitive = one file (same pattern as descriptors in T1).
**T3-ADR-3: Custom modifier authoring scope**
- **Per-room**: custom modifiers are registered on a room-by-room basis. Registration = send full descriptor via WS at room.create or via `custom-modifier.register` message.
- **Per-user library**: users save their custom descriptors locally (like profiles). Separate library key `houserules:custom-modifiers:v1`.
- **Sharing**: custom descriptors travel with profiles that reference them. A profile using a custom modifier "shield-v1" embeds the full `shield-v1` descriptor.
- **Versioning**: custom descriptors have a `version: 1` field. v2+ is a new modifier id.
- **Validation**: server-side validator checks primitive catalog membership, parameter bounds, recursion depth ≤ 3 (for on-turn-start / conditional nesting), total primitive count ≤ 50 per descriptor.
**T3-ADR-4: Registry extension — per-engine not per-process**
Built-in descriptors use module-level `MODIFIER_REGISTRY`. Custom descriptors use a per-engine `customModifiers: Map<string, CustomModifierDescriptor>`. `MODIFIER_REGISTRY.get(id)` transparently consults per-engine custom registry when global misses. This prevents user modifiers from leaking across rooms.
**T3-ADR-5: T4 forward-design (scripted modifiers)**
Future scripted modifiers would plug in as a new `ModifierDescriptor` type:
```typescript
interface ScriptedModifierDescriptor {
type: "scripted";
id: string;
script: string; // QuickJS or similar
permissions: Permission[];
// ... other fields
}
```
The engine's integration point (same `apply` signature) doesn't know the difference. T3's validator gets a `validateCustomDescriptor` branch-point that's currently `type: "data"` only; T4 adds `type: "scripted"` with separate validation + sandboxed execution.
**T3-ADR-6: Multi-profile stacking (bundled with T3 since primitives enable it)**
Profiles can be stacked. Engine maintains `activeProfiles: readonly ModifierProfile[]` instead of a single profile. Stacking rules per modifier kind (from ADR-4 of T1) apply across profiles. Conflict resolution: explicit priority order set by user. Default: profiles applied in registration order.
**T3-ADR-7: Aura effects (primitive `add-aura`)**
Auras are effect primitives that apply to OTHER pieces within a radius. At profile-apply time, auras create derived facts on affected pieces. Derived facts are re-computed on every move (affected pieces may change). Implementation: `effectivePieceAttrs` engine integration for aura-derived attrs.
---
## Work Objectives
### Core Objective
Ship user-authored custom modifier descriptors composed from an effect primitive catalog. Design the whole system so T4's scripted modifiers can plug in without redesigning.
### Concrete Deliverables
**Primitive catalog** (`packages/chess/src/modifiers/primitives/`):
- `types.ts` — `EffectPrimitive`, `PrimitiveKind`, primitive-specific parameter types
- `registry.ts` — `PRIMITIVE_REGISTRY`
- Individual primitive files (15 files):
- `seed-attribute.ts`, `add-to-attribute.ts`, `multiply-attribute.ts`
- `add-direction.ts`, `set-capture-flag.ts`
- `absorb-damage-with-attribute.ts`, `reflect-damage.ts`
- `block-move-type.ts`, `modify-movement-range.ts`, `override-promotion.ts`
- `add-aura.ts`
- `on-turn-start.ts`, `on-capture.ts`, `on-damaged.ts`, `conditional.ts`
- `index.ts` — barrel + side-effect registration
- `validate.ts` — descriptor-level validator (recursion depth, primitive count, parameter validation per primitive)
**Custom modifier descriptor** (`packages/chess/src/modifiers/custom/`):
- `types.ts` — `CustomModifierDescriptor`, `CustomModifierId`
- `apply.ts` — executes primitive list at profile apply time (replaces built-in `apply` for custom modifiers)
- `library.ts` — persistence at `houserules:custom-modifiers:v1`
- `schema.ts` — Zod schema for full descriptor serialization
**Registry extension** (`packages/chess/src/modifiers/registry.ts`):
- Extend `ModifierRegistryClass` with `customDescriptors: Map<string, CustomModifierDescriptor>`
- `.registerCustom(engine, descriptor)` and `.getCustom(engine, id)` methods
- `get(id)` falls back to customDescriptors if not in built-ins
**Aura engine support** (`packages/chess/src/modifiers/auras.ts`):
- `computeAuraFacts(session)` — runs all active auras, computes derived attrs, updates session
- Hooks: invoked after every move via pseudo-preset
**Multi-profile stacking** (`packages/chess/src/modifiers/apply.ts` + `reconcile.ts`):
- `applyProfilesToSession(session, profiles: readonly ModifierProfile[], layout)` — stack in order
- `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`
**Server protocol** (`packages/server/src/protocol.ts`):
- New message: `custom-modifier.register` — attach custom descriptor to room
- Extend `ModifierProfile` to include `customModifiers: readonly CustomModifierDescriptor[]` (embedded, not by-ref)
- Validator: `validateCustomDescriptor(descriptor)` runs before allowing registration
**UI** (`packages/chess/src/ui/`):
- `CustomModifierEditor.tsx` — visual primitive composer
- `PrimitivePalettePanel.tsx` — catalog of 15 primitives, drag/drop or click-to-add
- `PrimitiveInspectorPanel.tsx` — parameter editor for selected primitive
- `CustomModifierLibrary.tsx` — library drawer for saved custom modifiers
- Extend `PerTypePanel.tsx` + `PerInstancePanel.tsx` — "kind" dropdown shows custom modifiers alongside built-ins
**Profile stacking UI** (`packages/chess/src/ui/Lobby.tsx`):
- Allow selecting multiple profiles (stacked, ordered)
- Reorder via drag/drop
- Show stacked effect preview
**Docs**:
- Update `docs/adr/modifier-profiles.md` — add T3-ADR-1 through T3-ADR-7 sections
- New `docs/user/custom-modifiers.md` — user guide for authoring custom modifiers
- New `docs/adr/T4-scripted-modifiers-design.md` — forward-looking design doc
**E2E** (`packages/chess/e2e/`):
- New `custom-modifiers.spec.ts` — ~15 scenarios
### Definition of Done
- [ ] `bun run check` green
- [ ] All 15 primitives registered and individually tested
- [ ] A user can create a "Shield" custom modifier in the UI using `seed-attribute` + `absorb-damage-with-attribute` primitives
- [ ] Custom modifier saves to library, reloads after page refresh, applies to a game
- [ ] Multi-profile stacking: 2 profiles stacked → HP bonuses add, direction additions union
- [ ] Aura: a piece with `add-aura` radius=2 targetAttr=HpBonus delta=+1 gives +1 HP to all pieces within 2 squares
- [ ] Server rejects malformed custom descriptors (recursion too deep, unknown primitive, parameter out of range)
- [ ] Playwright e2e: 15 new scenarios pass in `custom-modifiers.spec.ts`
- [ ] Existing 26 T1+T2 e2e tests still pass (no regression)
- [ ] ADR + user docs + T4 design note published
### Must Have
- Full DSL with 15 T3 primitives covering the 6 built-in modifier behaviors + new capabilities (auras, conditionals, event hooks)
- Custom modifiers compose in the editor alongside built-ins
- Per-engine custom registry (no cross-room leakage)
- Multi-profile stacking with explicit priority
- Aura effects via `add-aura` primitive
- Server-side validation of custom descriptors (anti-DoS)
- T4 forward-design document
### Must NOT Have (Guardrails)
- ❌ Scripted modifiers (T4 — only the forward-design note lives here)
- ❌ Sandboxed script runtime in T3
- ❌ User-defined primitives (primitives are engine-shipped)
- ❌ Cross-room custom modifier sharing (explicit re-registration per room)
- ❌ Breaking changes to T1/T2 built-in modifiers
- ❌ Breaking WS protocol changes (custom-modifier messages are additive)
- ❌ Recursion depth > 3 in primitive nesting (DoS guard)
- ❌ More than 50 primitives per custom descriptor
- ❌ More than 10 custom modifiers per room (DoS guard)
### Must NOT Have (AI Slop Patterns)
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
- ❌ Generic names (`data`, `result`, `item`, `temp`)
- ❌ Empty catch blocks
- ❌ `console.log` in production code
- ❌ Switch statements on primitive kinds (use registry dispatch)
---
## Verification Strategy
### Test Decision
- **Infrastructure**: vitest + Playwright, same as T1/T2
- **Approach**: TDD per primitive, Playwright for UX
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t3/task-{N}-{slug}.{ext}`.
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Foundation — PARALLEL):
├── T1: T3-ADR documentation
├── T2: Primitive types + registry class
└── T3: Custom modifier descriptor types
Wave 2 (Primitive implementations — HIGHLY PARALLEL, 15 in parallel if caps allow):
├── T4-T8: 5 state primitives (seed-attribute, add-to-attribute, multiply-attribute, add-direction, set-capture-flag)
├── T9-T11: 3 damage primitives (absorb-damage-with-attribute, reflect-damage, modify-movement-range)
├── T12-T14: 3 control primitives (block-move-type, override-promotion, add-aura)
└── T15-T18: 4 event primitives (on-turn-start, on-capture, on-damaged, conditional)
Wave 3 (Integration — PARALLEL):
├── T19: Custom descriptor validator (depth cap, parameter check)
├── T20: Custom descriptor Zod schema
├── T21: Custom descriptor library persistence
├── T22: Engine integration — applyCustomDescriptor
└── T23: Multi-profile stacking in apply/reconcile
Wave 4 (Server + UI — PARALLEL):
├── T24: Server custom-modifier.register handler + validation
├── T25: CustomModifierEditor UI (primitive composer)
├── T26: Extend PerTypePanel/PerInstancePanel for custom kinds
├── T27: Multi-profile picker in Lobby
└── T28: Aura engine integration (computeAuraFacts hook)
Wave 5 (E2E + Docs — PARALLEL):
├── T29: Playwright e2e suite (15 scenarios)
├── T30: ADR updates
├── T31: User docs (docs/user/custom-modifiers.md)
└── T32: T4 forward-design document
Wave FINAL (4 parallel reviewers):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Manual QA
└── F4: Scope fidelity check
```
### Dependency Matrix (Abbreviated)
| Group | Depends On | Blocks |
|-------|------------|--------|
| T1 (ADR) | — | ALL |
| T2 (primitives registry) | T1 | T4-T18 |
| T3 (custom desc types) | T1 | T19-T28 |
| T4-T18 (primitives) | T2 | T19, T22, T25 |
| T19 (validator) | T2, T4-T18 | T24 |
| T20 (schema) | T3 | T24 |
| T21 (library) | T3, T20 | T27 |
| T22 (engine apply) | T4-T18 | T23, T28 |
| T23 (stacking) | T22 | T27, T29 |
| T24 (server) | T19, T20 | T29 |
| T25 (editor) | T4-T18 | T26, T29 |
| T26 (panel ext) | T25 | T29 |
| T27 (lobby stack) | T21, T23 | T29 |
| T28 (aura hook) | T22 | T29 |
| T29 (e2e) | T23-T28 | F1-F4 |
| T30-T32 (docs) | T1, T29 | F1 |
---
## TODOs
*(Abbreviated — each task follows the same 7-section format as T1/T2. Full details TBD when executing.)*
- [ ] 1. **T3-ADR documentation**
**What to do**: Append T3-ADR-1 through T3-ADR-7 to `docs/adr/modifier-profiles.md`.
**Recommended Agent Profile**: `writing`
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
**Commit**: `docs(adr): T3 custom modifier DSL architecture decisions`
- [ ] 2. **Primitive types + registry class**
**What to do**: Create `packages/chess/src/modifiers/primitives/types.ts` (EffectPrimitive, PrimitiveKind, parameter type families). Create `primitives/registry.ts` with `PRIMITIVE_REGISTRY` singleton.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 1. Blocks: T4-T18. Blocked By: T1.
**Commit**: `feat(engine): primitive types and registry`
- [ ] 3. **Custom modifier descriptor types**
**What to do**: Create `packages/chess/src/modifiers/custom/types.ts` — `CustomModifierDescriptor` shape with { id, name, description, version, primitives[], targetAttrs[] }.
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 1. Blocks: T19-T28. Blocked By: T1.
**Commit**: `feat(engine): custom modifier descriptor types`
- [ ] 4-18. **15 primitive implementations** (one per primitive)
**Pattern** (one task per primitive):
- Create `packages/chess/src/modifiers/primitives/{kind}.ts`:
- Export descriptor implementing `EffectPrimitive<Params>`
- Zod schema for params
- Apply function (session mutation or engine hook registration)
- Side-effect register in `primitives/index.ts`
- Create `.test.ts` with 3+ scenarios
Primitives to implement:
- T4: seed-attribute
- T5: add-to-attribute
- T6: multiply-attribute
- T7: add-direction
- T8: set-capture-flag
- T9: absorb-damage-with-attribute
- T10: reflect-damage
- T11: modify-movement-range
- T12: block-move-type
- T13: override-promotion
- T14: add-aura
- T15: on-turn-start
- T16: on-capture
- T17: on-damaged
- T18: conditional
**Recommended Agent Profile**: `unspecified-high` (all)
**Parallelization**: Wave 2 (PARALLEL). Blocks: T19, T22, T25. Blocked By: T2.
**Commits**: `feat(engine): {kind} effect primitive` (×15)
- [ ] 19. **Custom descriptor validator**
**What to do**: `packages/chess/src/modifiers/custom/validate.ts` — validates a CustomModifierDescriptor:
- Every primitive kind is in PRIMITIVE_REGISTRY
- Primitive params satisfy primitive's Zod schema
- Recursion depth ≤ 3 (count nesting of on-turn-start / on-capture / on-damaged / conditional)
- Total primitive count ≤ 50
- No circular references (descriptor referencing itself is banned)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T2, T4-T18.
**Commit**: `feat(engine): custom modifier descriptor validator`
- [ ] 20. **Zod schema for custom descriptor serialization**
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T3.
**Commit**: `feat(engine): custom modifier Zod schema`
- [ ] 21. **Custom modifier library persistence**
**What to do**: `packages/chess/src/modifiers/custom/library.ts` — localStorage at `houserules:custom-modifiers:v1`. Mirror T1 library API.
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 3. Blocks: T27. Blocked By: T3, T20.
**Commit**: `feat(engine): custom modifier library persistence`
- [ ] 22. **Engine integration — applyCustomDescriptor**
**What to do**: `packages/chess/src/modifiers/custom/apply.ts` — when a profile's perType/perInstance entry references a custom kind, resolve the descriptor from per-engine registry, execute its primitive list on the target piece.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T23, T28. Blocked By: T4-T18.
**Commit**: `feat(engine): apply custom modifier descriptors`
- [ ] 23. **Multi-profile stacking in apply/reconcile**
**What to do**: Extend `applyProfileToSession` → `applyProfilesToSession(session, profiles, layout)`. Extend `reconcileProfileSwap` → `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`. Stacking per ADR-4 rules apply across multiple profiles.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T27, T29. Blocked By: T22.
**Commit**: `feat(engine): multi-profile stacking`
- [ ] 24. **Server custom-modifier.register handler**
**What to do**:
- New WS message `custom-modifier.register` with full descriptor payload
- Server validates via T19's validator
- Registers per-room (per-engine registry)
- Rejects if > 10 custom modifiers per room
- Broadcasts `custom-modifier.registered` to opponent
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T19, T20.
**Commit**: `feat(server): custom-modifier.register WS handler`
- [ ] 25. **CustomModifierEditor.tsx — primitive composer UI**
**What to do**: Visual editor with:
- PrimitivePalettePanel — 15 primitives grouped by category
- Primitive tree view — shows nesting for on-turn-start / conditional
- PrimitiveInspectorPanel — parameter form per primitive, driven by primitive's Zod schema
- Add/delete/reorder primitives
- Save/load from library
- Inline validation (run T19 validator live)
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T26, T29. Blocked By: T4-T18.
**Commit**: `feat(ui): custom modifier editor`
- [ ] 26. **Extend PerTypePanel/PerInstancePanel for custom kinds**
**What to do**: Kind dropdown iterates `MODIFIER_REGISTRY.list() + customRegistry.list()`. Custom kinds use the primitive composer for value input (or show a simplified parameter form).
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T25.
**Commit**: `feat(ui): custom modifiers in modifier profile panels`
- [ ] 27. **Multi-profile picker in Lobby**
**What to do**: Lobby's profile picker becomes multi-select with ordering. Drag to reorder. Selected profiles stack in UI-visible order.
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T21, T23.
**Commit**: `feat(ui): multi-profile stacking in lobby`
- [ ] 28. **Aura engine integration**
**What to do**: `packages/chess/src/modifiers/auras.ts` — `computeAuraFacts(session)` runs after every move via pseudo-preset. Walks all aura-primitive-declared modifiers, finds affected pieces within radius, updates derived facts.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T22.
**Commit**: `feat(engine): aura effect computation`
- [ ] 29. **Playwright e2e suite (15 scenarios)**
**What to do**: Create `packages/chess/e2e/custom-modifiers.spec.ts`:
1. Open custom modifier editor from modifier profile editor
2. Create "Shield" custom modifier via primitive composer
3. Save custom modifier to library
4. Reload page → custom modifier still in library
5. Use custom modifier in a profile (per-type entry)
6. Start solo game with profile using custom modifier — verify behavior
7. Multi-profile stacking (2 profiles) — HP bonuses add
8. Aura effect — piece within radius gets derived attr
9. Aura updates after move (target moves out of radius → fact retracted)
10. Server rejects custom modifier with > 50 primitives
11. Server rejects custom modifier with > 3 recursion depth
12. Custom modifier sharing via multiplayer — both clients see same behavior
13. Conditional primitive works (if HP < 2, do X)
14. on-turn-start primitive fires
15. absorb-damage-with-attribute works (shield absorbs before HP)
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
**Parallelization**: Wave 5. Blocks: F1-F4. Blocked By: T22-T28.
**Commit**: `test(e2e): custom modifier DSL vertical slice`
- [ ] 30. **ADR updates — T3 Implementation Retrospective**
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 5. Blocks: F1. Blocked By: T1, T22-T28.
**Commit**: `docs(adr): T3 implementation retrospective`
- [ ] 31. **User docs: docs/user/custom-modifiers.md**
**What to do**: New user guide:
- What are custom modifiers?
- Opening the editor
- The 15 effect primitives (one subsection each with 1 example)
- Composing primitives (simple to complex)
- Saving & sharing
- Multi-profile stacking
- Aura effects (radius, affected pieces)
- Limitations (50 primitive cap, depth cap, 10 per room)
**Recommended Agent Profile**: `unspecified-low`
**Commit**: `docs(user): custom modifier DSL user guide`
- [ ] 32. **T4 forward-design document**
**What to do**: Create `docs/adr/T4-scripted-modifiers-design.md`:
- Why T4 is deferred (security complexity)
- Sandbox candidates evaluated (QuickJS, Duktape, custom mini-interpreter)
- Descriptor shape extension (`type: "data" | "scripted"`)
- Permission system sketch
- Validation strategy (static analysis of script before execution)
- How T3 primitives can be migrated (scripted modifier that wraps a primitive sequence)
- Open questions
**Recommended Agent Profile**: `writing`
**Commit**: `docs(adr): T4 scripted modifiers forward-design`
---
## Final Verification Wave
- [ ] F1. **Plan Compliance Audit** — `oracle`
Verify all 15 primitives, all 32 tasks' deliverables, T3-ADR decisions reflected. No T4 scripted modifiers shipped (only design doc).
Output: `Primitives [15/15] | Tasks [N/N] | ADRs [7/7] | VERDICT`
- [ ] F2. **Code Quality Review** — `unspecified-high`
`bun run check`. Scan for slop. Verify registry-dispatch pattern (no hardcoded kind switches).
Output: `Build [PASS] | Lint [PASS] | Tests [N pass] | VERDICT`
- [ ] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
Execute all 15 new e2e scenarios. Author a Shield custom modifier via UI, play a game with it, verify full behavior end-to-end.
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
- [ ] F4. **Scope Fidelity** — `deep`
No scripted modifiers (only forward-design doc). No cross-room leakage. Recursion cap enforced. Primitive count cap enforced.
Output: `Tasks [N/N compliant] | T4 smuggling [CLEAN] | VERDICT`
---
## Commit Strategy
1. `docs(adr): T3 custom modifier DSL architecture decisions`
2. `feat(engine): primitive types and registry`
3. `feat(engine): custom modifier descriptor types`
4-18. `feat(engine): {kind} effect primitive` (×15)
19. `feat(engine): custom modifier descriptor validator`
20. `feat(engine): custom modifier Zod schema`
21. `feat(engine): custom modifier library persistence`
22. `feat(engine): apply custom modifier descriptors`
23. `feat(engine): multi-profile stacking`
24. `feat(server): custom-modifier.register WS handler`
25. `feat(ui): custom modifier editor`
26. `feat(ui): custom modifiers in modifier profile panels`
27. `feat(ui): multi-profile stacking in lobby`
28. `feat(engine): aura effect computation`
29. `test(e2e): custom modifier DSL vertical slice`
30. `docs(adr): T3 implementation retrospective`
31. `docs(user): custom modifier DSL user guide`
32. `docs(adr): T4 scripted modifiers forward-design`
## Success Criteria
```bash
bun run check # all green
bun run test packages/chess/src/modifiers/primitives/ # all 15 primitive tests pass
bun run test packages/chess/src/modifiers/custom/ # apply + validate + library pass
bunx playwright test e2e/custom-modifiers.spec.ts # 15/15 pass
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass (no regression)
```
### Final Checklist
- [ ] All 15 primitives registered and individually tested
- [ ] Custom modifier editor UI functional
- [ ] Server validates + registers custom descriptors per-room
- [ ] Multi-profile stacking works
- [ ] Auras work (derived facts update on move)
- [ ] 15 Playwright e2e + 26 existing pass (41 total)
- [ ] ADR + user docs + T4 design note published
- [ ] `bun run check` green
- [ ] F1-F4 all APPROVE
- [ ] User explicit "okay"

View file

@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test';
test.describe('Solo-play smoke (T2 preview tests)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
}
});
});
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game', { timeout: 5000 });
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
if (errors.length > 0) throw new Error(errors.join('; '));
});
test('drag pawn e2 to e4 resolves', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page
.locator('[data-square="e2"] [data-piece]')
.dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
});
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
const drag = async (from: string, to: string) => {
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
// small wait for animation / state update
await page.waitForTimeout(100);
};
await drag('e2', 'e4');
await drag('e7', 'e5');
await drag('g1', 'f3');
await drag('b8', 'c6');
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
});
test('rules drawer opens without breaking board interaction', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(page.getByTestId('rules-drawer')).toBeVisible();
// Close drawer (esc or similar) and verify board still works
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Drag a move
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
});
test('no console errors on solo game start', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.waitForTimeout(1000); // let any async init complete
await expect(page.locator('[data-square="e2"]')).toBeVisible();
if (errors.length > 0) {
console.log('Errors:', errors);
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
}
});
});

View file

@ -57,13 +57,22 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
}, [isOpen]);
// Esc closes the modal regardless of focus.
//
// We use capture phase + stopImmediatePropagation so that other
// window-level Esc handlers (e.g. the RulesDrawer behind us) do not
// ALSO fire on the same keystroke. Without this, pressing Esc with
// both editor+drawer open would close both, leaving neither visible
// but the drawer's pointer-events-blocking backdrop briefly lingers.
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') {
e.stopImmediatePropagation();
onClose();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen, onClose]);
if (!isOpen) return null;

View file

@ -105,6 +105,18 @@ export function RulesDrawer({
};
}, [open]);
// Esc closes the drawer. Nested modals (ModifierProfileEditor) attach
// their own capture-phase Esc listeners with stopImmediatePropagation,
// so this only fires when no nested modal is open.
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
const toggle = (id: string, name: string) => {
const currently = activeById.get(id);
const next: PresetActivation[] = currently