diff --git a/.sisyphus/plans/modifier-profiles-t2.md b/.sisyphus/plans/modifier-profiles-t2.md new file mode 100644 index 0000000..c6aac00 --- /dev/null +++ b/.sisyphus/plans/modifier-profiles-t2.md @@ -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([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" diff --git a/.sisyphus/plans/modifier-profiles-t3.md b/.sisyphus/plans/modifier-profiles-t3.md new file mode 100644 index 0000000..61ba63c --- /dev/null +++ b/.sisyphus/plans/modifier-profiles-t3.md @@ -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`. `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` +- `.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` + - 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" diff --git a/packages/chess/e2e/solo-smoke.spec.ts b/packages/chess/e2e/solo-smoke.spec.ts new file mode 100644 index 0000000..0149205 --- /dev/null +++ b/packages/chess/e2e/solo-smoke.spec.ts @@ -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(' | ')}`); + } + }); +}); diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx index 0124f1c..9f129df 100644 --- a/packages/chess/src/ui/ModifierProfileEditor.tsx +++ b/packages/chess/src/ui/ModifierProfileEditor.tsx @@ -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; diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx index 0d4e9b6..3473fd1 100644 --- a/packages/chess/src/ui/RulesDrawer.tsx +++ b/packages/chess/src/ui/RulesDrawer.tsx @@ -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