# Piece Modifier Profiles ## TL;DR > **Quick Summary**: Users can author Modifier Profiles — reusable sets of rules that attach to pieces by type ("all knights +1 HP") OR by layout slot ("the b1 knight has +2 range"). Profiles are a first-class entity saved to their own library, selectable at game start, hot-swappable mid-game at turn boundaries. Six T1 modifier categories ship end-to-end: HP bonus, movement range, movement directions, capture behavior, promotion override, damage resistance. > > **Deliverables**: > - 2 new preset hooks (`transformMoveGenerator`, `modifyMoveAttrs`) wrapping existing generators (not replacing) > - 6 new `ChessAttrMap` entries + self-registering modifier catalog > - `ModifierProfile` type, Zod schema, library persistence (`houserules:modifier-profiles:v1`) > - WS message `modifier-profile.update` for hot-swap with server-side legality validator + rollback > - Dedicated "Modifier Profiles" editor screen (reuses layout-editor primitives) accessible from rules drawer > - Hover tooltip + click-to-pin side panel for in-play inspection > - Full Playwright e2e vertical slice > - ADR + user docs > > **Estimated Effort**: Large > **Parallel Execution**: YES — 7 waves > **Critical Path**: T1 (ADR) → T3 (hooks) → T5 (registry) → T6 (descriptors) → T11 (apply) → T12 (hot-swap) → T20 (e2e) --- ## Context ### Original Request > "plan out the feature of being able to add presets that apply to certain pieces? So like, a particular piece can have more HP, or higher movement, or move backwards, etc" ### Interview Summary **Key Decisions (user-confirmed)**: - **Scope**: BOTH layered — per-type (all knights) AND per-instance (the b1 knight). Per-instance overrides per-type. - **T1 modifier catalog** (all 6): HP bonus, movement range, movement directions, capture behavior, promotion override, invulnerability/damage resistance. - **UX**: Dedicated new editor screen, accessible from rules drawer. Visual (layout-editor style). In-play inspection: hover tooltip + click-to-pin panel. - **Timing**: Hot-swap mid-game (applied at turn boundary for determinism). - **Storage**: Separate "modifier profiles" as first-class reusable entities — profile + layout combine at game start. - **Depth**: Full vertical slice (engine + data + serialization + UI + e2e) for T1. T2/T3 planned as future tiers. ### Research Findings (bg_6129dc58) - Preset hook system is rich (13 hooks) but **missing a move-generator override** — we add one. - Pieces have per-instance `EntityId`s already. All per-piece state stored via declared `pieceAttributes` → `ChessAttrMap` union → seeded in `onActivate`/`onPieceSpawn`, auto-retracted via `effectivePieceAttrs` on death. - `piece-hp.ts` is the reference implementation — follow its pattern. - Facts serialize automatically via `GameStatePayload.facts` — **no wire changes needed for modifier VALUES**, only for modifier DEFINITIONS (new WS message + room-create field). - Move generation keyed by piece TYPE but passes `pieceId` — per-instance state readable during generation. ### Metis Review (`ses_25c313fd2ffeljiMUzgKRTyMM0`) - Identified 3 architecture decisions needing resolution before task-level planning. - All 3 have been resolved in this plan (see "Architecture Decisions (ADR)" below) with sensible defaults aligned to user's interview answers. User may override. - Recommended registry pattern for modifier catalog (adopted — T5). - Recommended wrap semantics over replace for move-generator hook (adopted — T3). - Recommended layout-bound keying for per-instance (adopted — Option B). --- ## Architecture Decisions (ADR) > These are baked in as defaults. Each has a sensible rationale aligned with user's interview answers. User may override any before execution begins. ### ADR-1: Move Generator Hook Contract — **WRAP (not REPLACE)** - **Decision**: New hook signature: `transformMoveGenerator?(engine, pieceId, prevGenerator) => MoveGenerator` - **Rationale**: Composition — multiple presets + profile can stack. Each receives the previous generator and returns a new one. Enables "profile adds backward move" + "preset forbids captures" to layer cleanly. - **Contract**: Returned generator emits **pseudo-legal** moves. Engine's legality layer (self-check filter, turn validation, repetition) ALWAYS runs downstream. Profile-aware generators MUST NOT bypass self-check. ### ADR-2: Per-Instance Identity Keying — **Layout-Slot Bound (Option B)** - **Decision**: Per-instance modifiers stored keyed by layout-slot identifier (`square` from the layout, e.g. `"b1"`). Profile specifies a target `layoutId` it's bound to. At game start, when `applyLayout()` assigns `EntityId`s to pieces, engine maps `square → EntityId` and seeds facts accordingly. - **Rationale**: Aligned with user's "profile + layout combine at game start". Portable within a single layout. Cross-layout reuse allowed for per-type; per-instance portion dropped with warning if applied to a non-matching layout. - **Orphan handling**: If profile's per-instance entry targets a `square` with no piece in the current layout, show warning in editor; skip entry at game start (non-fatal). ### ADR-3: Hot-Swap Reconciliation (per modifier kind, at turn boundary only) | Modifier | On swap-apply | On swap-remove | |----------|---------------|----------------| | HpBonus | `maxHp` recomputed; `currentHp = min(currentHp, newMax)` (never grows) | Same clamp rule | | RangeBonus | Next legal-move query reflects new range | Next legal-move query reflects baseline | | DirectionAdditions | Next legal-move query includes new directions | Next legal-move query excludes removed directions | | CaptureFlags | Applies to next capture event | Baseline capture rules from next event | | PromotionOverride | Applies to future promotions only; past promotions untouched | Future promotions use default | | DamageResistance | Applies to next damage event | Baseline damage from next event | - **Timing**: Hot-swap applies at **turn boundary only** (after `turnEnd`, before next player's `turnStart`). - **Mid-turn edit attempts**: Queued server-side; applied at next turn boundary. - **Rollback**: Server runs legality validator BEFORE broadcast. If invalid → NACK to sender with error code, no broadcast. No client optimistic state to rewind. ### ADR-4: Stacking Rules per Modifier Kind | Modifier | Stacking rule | |----------|---------------| | HpBonus | **Additive**: base + perType + perInstance + sumOf(activePreset.hpBonus) | | RangeBonus | **Additive**: clamped to `[0, 7]` | | DirectionAdditions | **Union** of direction sets | | CaptureFlags | **Union** (bitflags OR'd) | | PromotionOverride | **Precedence wins**: perInstance > perType > presetDefault | | DamageResistance | **Multiplicative**: `finalDamage = amount * ∏(1 - resistance_i)`, clamped to `≥ 0` | ### ADR-5: Precedence (Source Priority) `per-instance (profile) > per-type (profile) > preset > engine base` - For additive/union stacking: all sources contribute. - For override-style (PromotionOverride): higher-priority source wins. ### ADR-6: Legality Validator Checklist On each profile apply / swap, validate: 1. Every side has ≥1 king on board AFTER modifiers applied. 2. King pieces MUST NOT carry `Invulnerability` modifier (hard rejection). 3. No per-instance entries reference non-existent squares (warn, skip). 4. Each side has ≥1 legal move on next turn (run fast legal-move query). 5. Total facts per piece ≤ 16 attrs (prevent DoS via attr bloat). - Error codes: `E_PROFILE_NO_KING`, `E_PROFILE_INVULN_KING`, `E_PROFILE_ORPHAN_INSTANCE`, `E_PROFILE_DEADLOCK`, `E_PROFILE_ATTR_LIMIT`. ### ADR-7: Single Active Profile Per Game (T1) - One profile active at a time. Changing profile = full swap via `modifier-profile.update`. - Profile composition/stacking → T2. ### ADR-8: Registry Pattern for Modifier Catalog - Each T1 modifier lives in ONE file under `packages/chess/src/modifiers/descriptors/{kind}.ts`. - Registers into `MODIFIER_REGISTRY` at module load (mirror `PRESET_REGISTRY`, `LAYOUT_REGISTRY`). - Descriptor shape: `{ id, attrName, valueSchema, stackingRule, apply(session, pieceId, value), describe(value), uiForm }`. - T3 custom modifiers will plug into this same registry. --- ## Work Objectives ### Core Objective Ship a first-class "Modifier Profile" system that lets users attach rule modifiers to pieces by type OR by layout slot, save/share profiles in a library, select them at game start, hot-swap them mid-game, and inspect active modifiers during play. ### Concrete Deliverables **Engine package** (`packages/chess/src/`): - `modifiers/types.ts` — `ModifierProfile`, `TypeModifier`, `InstanceModifier`, `ModifierKindId`, `ModifierDescriptor` - `modifiers/registry.ts` — `MODIFIER_REGISTRY` singleton, registration API - `modifiers/descriptors/{hp-bonus,range-bonus,direction-additions,capture-flags,promotion-override,damage-resistance}.ts` — 6 descriptor impls - `modifiers/apply.ts` — `applyProfileToSession(session, profile, layout)` — seed facts at game start - `modifiers/reconcile.ts` — `reconcileProfileSwap(session, oldProfile, newProfile)` — hot-swap diff + clamp - `modifiers/validate.ts` — legality checklist - `modifiers/schema.ts` — Zod schema for profile serialization - `modifiers/library.ts` — localStorage persistence (`houserules:modifier-profiles:v1`) - `modifiers/index.ts` — barrel + side-effect registration - Extend `presets/registry.ts`: add `transformMoveGenerator?`, `modifyMoveAttrs?` hooks - Extend `schema.ts`: 6 new attrs in `ChessAttrMap` - Extend `engine.ts`: call `transformMoveGenerator` chain before type's default generator; integrate profile application in lifecycle **UI package** (`packages/chess/src/ui/`): - `ModifierProfileEditor.tsx` — dedicated editor modal/screen (reuses layout board primitives) - `ModifierPalettePanel.tsx` — modifier catalog (schema-driven forms) - `PerTypePanel.tsx` — per-type modifier list - `PerInstancePanel.tsx` — board view + click-to-select + per-piece modifier list - `ModifierTooltip.tsx` — hover tooltip showing active modifiers with source attribution - `ModifierPinnedPanel.tsx` — click-pinned side panel - `RulesDrawer.tsx` — add "Modifier Profiles" entry - Extend `Lobby.tsx` — profile picker alongside layout picker - Extend `GameView.tsx` — wire tooltip + pinned panel **Server package** (`packages/server/src/`): - Extend `protocol.ts`: `ModifierProfileSchema`, `ProfileRequestSchema`, `modifier-profile.update` message, `ModifierProfileUpdatedPayload`, new error codes - Extend `rooms.ts`: `Room.profile?: ModifierProfile` field - Extend `game-session.ts`: accept optional profile - Extend `broadcast.ts`: resolve + validate profile on room-create; handle `modifier-profile.update` message (validate + broadcast or NACK) - New `profile-validator.ts` — runs legality checklist **Net package** (`packages/chess/src/net/`): - Extend `types.ts`: `ModifierProfileWire`, `TypeModifierWire`, `InstanceModifierWire` **Docs**: - `docs/adr/modifier-profiles.md` — ADR (this plan's architecture section formalized) - `docs/user/modifier-profiles.md` — user-facing guide **E2E** (`packages/chess/e2e/`): - `modifier-profiles.spec.ts` — ~18 scenarios ### Definition of Done - [ ] `bun run check` green (typecheck + lint + vitest) - [ ] All 6 modifier descriptors pass unit tests including stacking + precedence - [ ] `modifier-profile.update` WS round-trip tested server-side (happy + illegal + rollback) - [ ] Hot-swap reconciliation idempotent (same inputs → same state) - [ ] Editor accessible from rules drawer, reachable in ≤2 clicks - [ ] Hover tooltip shows modifiers within 200ms; pinned panel updates within 500ms on hot-swap - [ ] Full e2e vertical slice passes in Playwright - [ ] ADR + user docs published - [ ] Profile JSON size ≤ 8KB (fits URL share) ### Must Have - All 6 T1 modifier categories working end-to-end. - Per-type AND per-instance (Option B layout-slot keying) both functional. - Hot-swap at turn boundary with server-authoritative validation. - In-play inspection with source attribution (per-instance/per-type/preset/base). - Reuse of layout-editor board primitives — NO duplicate board component. - Shared engine code path for client move preview + server validation (no drift). ### Must NOT Have (Guardrails — from Metis) - ❌ Custom DSL / scripting for user-authored modifiers (T3) - ❌ Cross-piece aura effects (T3) - ❌ Multiple profiles stacking (post-T3) - ❌ Copy/paste modifiers between pieces in editor (T2) - ❌ Visual diff of "modified" vs base pieces (T2) - ❌ Conflict resolution UI (T2 — show inline validator error only in T1) - ❌ Undo/redo inside editor beyond native text inputs (T2) - ❌ Mid-turn hot-swap (T1: turn-boundary-only) - ❌ New piece TYPES via modifiers (modifiers modify, they don't invent) - ❌ Breaking changes to existing WS messages (all additions are NEW messages) - ❌ Invulnerability on kings (hard-rejected by validator) - ❌ Client computing effective modifiers differently from server (shared code path mandatory) - ❌ Duplicating the board editor component (reuse layout-editor primitives) - ❌ Any acceptance criterion requiring human visual confirmation - ❌ Monster commits combining engine + UI + tests ### Must NOT Have (AI Slop Patterns) - ❌ `as any`, `@ts-ignore`, or bypassing strict TS - ❌ Generic names like `data`, `result`, `item`, `temp` - ❌ Empty catch blocks - ❌ `console.log` in production code - ❌ Over-commenting (don't narrate obvious code) - ❌ Premature abstraction (keep descriptors concrete until 3+ share shape) - ❌ Duplicate validation logic across client/server (server is authority; client mirrors) --- ## Verification Strategy (MANDATORY) > **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. ### Test Decision - **Infrastructure exists**: YES (vitest + Playwright, established patterns) - **Automated tests**: TDD — RED failing test first, GREEN minimal impl, REFACTOR - **Framework**: `bun run test` (vitest) for unit/integration, `bunx playwright test` for e2e - **Pattern**: Mirror `piece-hp.test.ts`, `layout-library.test.ts`, `layouts.spec.ts` ### QA Policy Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/piece-modifiers/task-{N}-{slug}.{ext}`. - **Engine/data**: `bun run test {path}` — assertion-based - **Server**: `bun run test {server-path}` + WebSocket harness - **Frontend/UI**: Playwright from repo root (`bunx playwright test e2e/modifier-profiles.spec.ts`) - **Integration**: E2E full-flow scenarios --- ## Execution Strategy ### Parallel Execution Waves ``` Wave 1 (Foundation - start immediately): └── T1: Architecture ADR document (single task — blocks everything) Wave 2 (Schema scaffolding - PARALLEL after T1): ├── T2: Add 6 ChessAttrMap entries ├── T3: Add transformMoveGenerator + modifyMoveAttrs preset hooks ├── T4: Modifier types (ModifierProfile, descriptor types) └── T5: Modifier registry + barrel Wave 3 (Descriptors + schema - PARALLEL after Wave 2): ├── T6: HP-bonus descriptor + tests ├── T7: Range-bonus descriptor + tests ├── T8: Direction-additions descriptor + tests ├── T9: Capture-flags descriptor + tests ├── T10: Promotion-override descriptor + tests ├── T11: Damage-resistance descriptor + tests ├── T12: Zod schema + roundtrip tests └── T13: Library persistence Wave 4 (Integration - PARALLEL after Wave 3): ├── T14: applyProfileToSession (game start) ├── T15: reconcileProfileSwap (hot-swap) ├── T16: Legality validator ├── T17: Server protocol extensions (schemas + error codes) └── T18: UI editor shell + rules drawer entry Wave 5 (Server + UI core - PARALLEL after Wave 4): ├── T19: Server room-create accepts profile ├── T20: Server modifier-profile.update WS handler ├── T21: UI per-type panel ├── T22: UI per-instance panel (board picker) └── T23: UI save/load library + URL share Wave 6 (In-play integration - PARALLEL after Wave 5): ├── T24: Hover tooltip inspection ├── T25: Pinned side panel └── T26: Lobby profile picker integration Wave 7 (End-to-end verification - PARALLEL): ├── T27: Full e2e Playwright suite └── T28: ADR + user docs Wave FINAL (after ALL tasks — 4 parallel reviews, then user okay): ├── F1: Plan compliance audit (oracle) ├── F2: Code quality review (unspecified-high) ├── F3: Real manual QA (unspecified-high) └── F4: Scope fidelity check (deep) ``` ### Dependency Matrix | Task | Depends On | Blocks | |------|------------|--------| | 1 | — | 2-28 | | 2 | 1 | 6-11, 14 | | 3 | 1 | 6-11, 14 | | 4 | 1 | 5, 6-11, 12 | | 5 | 4 | 6-11, 14 | | 6 | 2, 3, 5 | 14, 21, 22, 24 | | 7 | 2, 3, 5 | 14, 21, 22, 24 | | 8 | 2, 3, 5 | 14, 21, 22, 24 | | 9 | 2, 3, 5 | 14, 21, 22, 24 | | 10 | 2, 3, 5 | 14, 21, 22, 24 | | 11 | 2, 3, 5 | 14, 21, 22, 24 | | 12 | 4 | 13, 17, 19 | | 13 | 12 | 23 | | 14 | 6-11, 16 | 15, 19 | | 15 | 14 | 20 | | 16 | 12 | 14, 19, 20 | | 17 | 12, 16 | 19, 20 | | 18 | 5 | 21, 22, 23 | | 19 | 14, 17 | 26, 27 | | 20 | 15, 17 | 24, 25, 27 | | 21 | 6-11, 18 | 26, 27 | | 22 | 6-11, 18 | 26, 27 | | 23 | 13, 18 | 26, 27 | | 24 | 6-11, 20 | 25, 27 | | 25 | 24 | 27 | | 26 | 19, 21, 22, 23 | 27 | | 27 | 19-26 | 28 | | 28 | 1, 27 | F1 | ### Agent Dispatch Summary | Wave | Tasks | Categories | |------|-------|------------| | 1 | T1 (ADR) | `ultrabrain` | | 2 | T2-T5 | T2: `quick`, T3: `deep`, T4: `unspecified-low`, T5: `deep` | | 3 | T6-T13 | T6-T11: `unspecified-high`, T12: `unspecified-low`, T13: `unspecified-low` | | 4 | T14-T18 | T14-T16: `deep`, T17: `unspecified-low`, T18: `visual-engineering` | | 5 | T19-T23 | T19-T20: `deep`, T21-T22: `visual-engineering`, T23: `unspecified-low` | | 6 | T24-T26 | T24-T25: `visual-engineering`, T26: `unspecified-low` | | 7 | T27-T28 | T27: `unspecified-high` (+ playwright skill), T28: `writing` | | FINAL | F1-F4 | F1: `oracle`, F2: `unspecified-high`, F3: `unspecified-high`, F4: `deep` | --- ## TODOs - [x] 1. **Architecture ADR document** **What to do**: - Create `docs/adr/modifier-profiles.md` formalizing ADR-1 through ADR-8 from the plan's Architecture Decisions section. - Include: hook contract (WRAP, pseudo-legal), per-instance keying (Option B layout-slot bound), hot-swap semantics (turn-boundary), stacking rules table, precedence chain, legality validator checklist, single-profile constraint, registry pattern choice. - Add a "Rejected Alternatives" section (REPLACE semantics, Option A/C keying, mid-turn swap) with rationale. **Must NOT do**: Don't write code in this task. Don't design UI. ADR is a pure decision log. **Recommended Agent Profile**: - **Category**: `ultrabrain` — foundational architectural decisions - **Skills**: [`repo-analysis`] — read existing ADR patterns if any **Parallelization**: Wave 1 (solo). Blocks: ALL other tasks. Blocked By: None. **References**: - `.sisyphus/plans/piece-modifiers.md` § "Architecture Decisions (ADR)" — source of truth - `.sisyphus/plans/starting-layouts.md` — prior plan format reference - `packages/chess/src/presets/piece-hp.ts` — reference preset pattern **Acceptance Criteria**: - [ ] File `docs/adr/modifier-profiles.md` exists - [ ] `ls docs/adr/modifier-profiles.md` returns file (verify via Bash) - [ ] Contains all 8 ADR decisions, each with: Decision statement, Rationale, Rejected Alternatives - [ ] Stacking rules table covers all 6 T1 modifier kinds **QA Scenarios**: ``` Scenario: ADR file readable and contains all sections Tool: Bash Steps: 1. Run: grep -c "^## ADR-" docs/adr/modifier-profiles.md 2. Assert output: 8 3. Run: grep -c "^| HpBonus\|^| RangeBonus\|^| DirectionAdditions\|^| CaptureFlags\|^| PromotionOverride\|^| DamageResistance" docs/adr/modifier-profiles.md 4. Assert output: ≥6 (stacking table rows) Evidence: .sisyphus/evidence/piece-modifiers/task-1-adr-structure.txt ``` **Commit**: YES (solo) — `docs(adr): modifier-profiles architecture decisions` - Files: `docs/adr/modifier-profiles.md` - Pre-commit: `bun run check` - [x] 2. **Add 6 modifier attrs to ChessAttrMap** **What to do**: - Edit `packages/chess/src/schema.ts` - Add 6 new optional attribute types to `ChessAttrMap`: - `HpBonus: number` - `RangeBonus: number` - `DirectionAdditions: readonly Direction[]` (use existing `Direction` type) - `CaptureFlags: number` (bitflags: `CAN_CAPTURE_OWN=1`, `CANNOT_BE_CAPTURED=2`, `EN_PASSANT=4`) - `PromotionOverride: PieceType | "disabled"` - `DamageResistance: number` (0-1 range, multiplicative) - Export capture-flag constants from `schema.ts`. **Must NOT do**: Don't modify core attrs (PieceType, Color, Position, HasMoved). Don't touch engine.ts — types only. **Recommended Agent Profile**: - **Category**: `quick` — mechanical type additions - **Skills**: [] **Parallelization**: Wave 2. Blocks: 6-11, 14. Blocked By: 1. **References**: - `packages/chess/src/schema.ts` — current ChessAttrMap shape - `packages/chess/src/presets/piece-hp.ts:13-15` — how Hp attr was added (same pattern) - `docs/adr/modifier-profiles.md` § ADR-4 — stacking rules dictate value shapes **Acceptance Criteria**: - [ ] `bun run check` passes (typecheck green) - [ ] `grep "HpBonus\|RangeBonus\|DirectionAdditions\|CaptureFlags\|PromotionOverride\|DamageResistance" packages/chess/src/schema.ts` → 6 matches **QA Scenarios**: ``` Scenario: Types added without breaking existing build Tool: Bash Steps: 1. Run: bun run check 2. Assert: exit code 0, output contains "Tests" and no TS errors Evidence: .sisyphus/evidence/piece-modifiers/task-2-typecheck.txt ``` **Commit**: YES — `feat(engine): add 6 modifier attrs to ChessAttrMap` - Files: `packages/chess/src/schema.ts` - Pre-commit: `bun run check` - [x] 3. **Add transformMoveGenerator + modifyMoveAttrs preset hooks** **What to do**: - Extend `PresetDef` in `packages/chess/src/presets/registry.ts`: ```ts transformMoveGenerator?: ( engine: ChessEngine, pieceId: EntityId, prev: MoveGenerator ) => MoveGenerator; modifyMoveAttrs?: ( engine: ChessEngine, pieceId: EntityId ) => { rangeBonus?: number; directionAdditions?: readonly Direction[] }; ``` - Update `ChessEngine.getAllLegalMoves()` in `packages/chess/src/engine.ts`: - After resolving piece type's base generator, fold through all active presets' `transformMoveGenerator` (reduce: `acc, preset => preset.transformMoveGenerator?.(engine, pieceId, acc) ?? acc`). - Existing `getExtraMoves` / `filterMoves` continue to run downstream. - Self-check filter MUST still run after all transformations. - Add unit tests in `packages/chess/src/presets/transform-hook.test.ts`: - Noop preset: legal moves unchanged. - Wrapping preset: adds backward move to pawn; base forward moves still present. - Two wrapping presets compose: second receives output of first. - Self-check filter still runs: king-in-check blocks non-escaping moves even if transform added them. **Must NOT do**: Don't add REPLACE semantics. Don't bypass self-check filter. Don't change existing hook signatures. **Recommended Agent Profile**: - **Category**: `deep` — contract design, subtle composition semantics - **Skills**: [`code-search`] **Parallelization**: Wave 2. Blocks: 6-11, 14. Blocked By: 1. **References**: - `packages/chess/src/presets/registry.ts` — current PresetDef shape - `packages/chess/src/engine.ts:getAllLegalMoves` — where to integrate - `docs/adr/modifier-profiles.md` § ADR-1 — contract spec **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/presets/transform-hook.test.ts` → all tests pass - [ ] `bun run check` green - [ ] Existing preset tests unchanged (no regressions) **QA Scenarios**: ``` Scenario: Noop transform preserves legal moves Tool: Bash Steps: 1. Run: bun run test packages/chess/src/presets/transform-hook.test.ts 2. Assert: exit 0, "noop preset preserves legal moves" test passes Evidence: .sisyphus/evidence/piece-modifiers/task-3-noop.txt Scenario: Self-check filter still applies after transform Tool: Bash Steps: 1. Same test file — scenario "transform cannot bypass self-check" 2. Assert: king-in-check with transformed pawn → pawn move blocked Evidence: .sisyphus/evidence/piece-modifiers/task-3-selfcheck.txt ``` **Commit**: YES — `feat(engine): add transformMoveGenerator + modifyMoveAttrs preset hooks` - Files: `packages/chess/src/presets/registry.ts`, `packages/chess/src/engine.ts`, `packages/chess/src/presets/transform-hook.test.ts` - Pre-commit: `bun run check` - [x] 4. **Modifier profile types** **What to do**: - Create `packages/chess/src/modifiers/types.ts`: ```ts export type ModifierKindId = "hp-bonus" | "range-bonus" | "direction-additions" | "capture-flags" | "promotion-override" | "damage-resistance"; export interface TypeModifier { readonly kind: ModifierKindId; readonly pieceType: PieceType; readonly color: PieceColor; readonly value: unknown; } export interface InstanceModifier { readonly kind: ModifierKindId; readonly square: Square; readonly value: unknown; } export interface ModifierProfile { readonly id: string; readonly name: string; readonly description: string; readonly layoutId?: string; // Optional - only needed if per-instance used readonly perType: readonly TypeModifier[]; readonly perInstance: readonly InstanceModifier[]; readonly version: 1; readonly source: "premade" | "custom"; } export interface ModifierDescriptor { readonly id: ModifierKindId; readonly attrName: keyof ChessAttrMap; readonly label: string; readonly valueSchema: ZodSchema; readonly stackingRule: "additive" | "union" | "multiplicative" | "priority-wins"; readonly apply: (session: Session, pieceId: EntityId, value: V, sumOfOthers: V | null) => void; readonly describe: (value: V) => string; readonly uiForm: "number" | "direction-set" | "capture-flags" | "promotion-target" | "percentage"; } ``` **Must NOT do**: Don't implement descriptors yet (T6-T11). Don't write schema here (T12). Types only. **Recommended Agent Profile**: - **Category**: `unspecified-low` — type definitions - **Skills**: [] **Parallelization**: Wave 2. Blocks: 5, 6-11, 12. Blocked By: 1. **References**: - `packages/chess/src/layouts/types.ts` — parallel structure for reference - `packages/chess/src/schema.ts` — ChessAttrMap import target - `docs/adr/modifier-profiles.md` § ADR-2, ADR-8 **Acceptance Criteria**: - [ ] File `packages/chess/src/modifiers/types.ts` exists - [ ] `bun run check` green - [ ] Types export: `ModifierKindId`, `TypeModifier`, `InstanceModifier`, `ModifierProfile`, `ModifierDescriptor` **QA Scenarios**: ``` Scenario: Types compile and export correctly Tool: Bash Steps: 1. Run: bun run check 2. Assert: exit 0 3. Run: grep "^export " packages/chess/src/modifiers/types.ts | wc -l 4. Assert: ≥5 exports Evidence: .sisyphus/evidence/piece-modifiers/task-4-types.txt ``` **Commit**: YES — `feat(engine): modifier profile types` - Files: `packages/chess/src/modifiers/types.ts` - Pre-commit: `bun run check` - [x] 5. **Modifier registry + barrel** **What to do**: - Create `packages/chess/src/modifiers/registry.ts`: ```ts class ModifierRegistry { private byId = new Map(); register(d: ModifierDescriptor): void { /* throw if dup */ } get(id: ModifierKindId): ModifierDescriptor | undefined { ... } list(): readonly ModifierDescriptor[] { ... } has(id: ModifierKindId): boolean { ... } } export const MODIFIER_REGISTRY = new ModifierRegistry(); ``` - Create `packages/chess/src/modifiers/index.ts` — barrel + side-effect imports for all 6 descriptors (T6-T11 will add imports as they're written). - Unit tests `packages/chess/src/modifiers/registry.test.ts`: register/get/list/duplicate-throws. **Must NOT do**: Don't register any descriptors here. Don't implement descriptor logic. **Recommended Agent Profile**: - **Category**: `deep` — mirror existing registry patterns - **Skills**: [`code-search`] **Parallelization**: Wave 2. Blocks: 6-11, 14. Blocked By: 4. **References**: - `packages/chess/src/presets/registry.ts` — PresetRegistry pattern - `packages/chess/src/layouts/registry.ts` — LayoutRegistry pattern - `packages/chess/src/presets/piece-type-registry.ts` — another mirror **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/registry.test.ts` passes - [ ] `MODIFIER_REGISTRY.list().length === 0` initially (before T6-T11 register) **QA Scenarios**: ``` Scenario: Registry accepts registrations and prevents duplicates Tool: Bash Steps: 1. Run: bun run test packages/chess/src/modifiers/registry.test.ts 2. Assert: exit 0, all tests pass Evidence: .sisyphus/evidence/piece-modifiers/task-5-registry.txt ``` **Commit**: YES — `feat(engine): modifier registry pattern` - Files: `packages/chess/src/modifiers/registry.ts`, `packages/chess/src/modifiers/index.ts`, `packages/chess/src/modifiers/registry.test.ts` - Pre-commit: `bun run check` - [x] 6. **HP-bonus descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/hp-bonus.ts`: - `id: "hp-bonus"`, `attrName: "HpBonus"`, `valueSchema: z.number().int().min(-10).max(10)`, `stackingRule: "additive"`, `uiForm: "number"`. - `apply(session, pieceId, value, sumOfOthers)` → `session.insert(pieceId, "HpBonus", (sumOfOthers ?? 0) + value)`. - `describe(v)` → `"HP ${v >= 0 ? '+' : ''}${v}"`. - Side-effect register in `modifiers/index.ts`. - Tests in `packages/chess/src/modifiers/descriptors/hp-bonus.test.ts`: - Additive stacking (2 per-type + 1 per-instance → sum all 3). - Precedence: no override behavior (additive means all contribute). - Integration with piece-hp preset: total maxHp = base + HpBonus. **Must NOT do**: Don't modify piece-hp preset itself. Don't handle damage here — that's orthogonal (DamageResistance). **Recommended Agent Profile**: - **Category**: `unspecified-high` — descriptor + tests - **Skills**: [`code-search`] **Parallelization**: Wave 3 (PARALLEL with 7-11). Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/presets/piece-hp.ts` — HP system integration - `packages/chess/src/modifiers/types.ts` — descriptor shape - `docs/adr/modifier-profiles.md` § ADR-4 **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/descriptors/hp-bonus.test.ts` → all pass - [ ] `MODIFIER_REGISTRY.get("hp-bonus")` returns descriptor - [ ] Stacking test: 3 entries of +2 → piece gets HpBonus=6 **QA Scenarios**: ``` Scenario: HP bonus stacks additively across sources Tool: Bash Steps: 1. Run: bun run test packages/chess/src/modifiers/descriptors/hp-bonus.test.ts -t "stacks additively" 2. Assert: test passes, final HpBonus = sum of all sources Evidence: .sisyphus/evidence/piece-modifiers/task-6-hp-stacking.txt Scenario: HP bonus integrates with existing HP preset Tool: Bash Steps: 1. Same test file — "integrates with piece-hp preset" 2. Assert: maxHp === baseHp + HpBonus Evidence: .sisyphus/evidence/piece-modifiers/task-6-hp-integration.txt ``` **Commit**: YES — `feat(engine): hp-bonus modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/hp-bonus.ts`, `packages/chess/src/modifiers/descriptors/hp-bonus.test.ts`, `packages/chess/src/modifiers/index.ts` - Pre-commit: `bun run check` - [x] 7. **Range-bonus descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/range-bonus.ts`: - `id: "range-bonus"`, `attrName: "RangeBonus"`, `valueSchema: z.number().int().min(-7).max(7)`, `stackingRule: "additive"` (clamped to `[0, 7]`), `uiForm: "number"`. - `apply` seeds `RangeBonus` fact as clamped sum. - Integration: modify `modifyMoveAttrs` return so sliding pieces (rook/bishop/queen) see extended range. - Unit behavior: update existing sliding-move generators to consume `session.get(pieceId, "RangeBonus") ?? 0` if present. **Must NOT do**: Don't affect knight/king/pawn (non-sliding). Those get separate modifier (DirectionAdditions) if needed. **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [`code-search`] **Parallelization**: Wave 3 (PARALLEL with 6, 8-11). Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/presets/piece-type-registry.ts` — sliding-move generators (rook, bishop, queen) - `packages/chess/src/modifiers/types.ts` **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/descriptors/range-bonus.test.ts` → all pass - [ ] Rook with +1 range: legal-move list includes one extra square per ray direction (when not blocked) - [ ] Clamping: +10 + +5 on same piece → effective 7 (max) **QA Scenarios**: ``` Scenario: Range bonus extends sliding pieces Tool: Bash Steps: 1. Set up empty board + rook on a1 with RangeBonus=1 (test fixture, board size effectively lets us observe clamping) 2. Run test: verify rook legal moves extend by 1 square per direction Evidence: .sisyphus/evidence/piece-modifiers/task-7-range-extension.txt Scenario: Range bonus clamped to [0, 7] Tool: Bash Steps: 1. Apply +10 stacked bonus 2. Assert: effective value === 7 Evidence: .sisyphus/evidence/piece-modifiers/task-7-clamp.txt ``` **Commit**: YES — `feat(engine): range-bonus modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/range-bonus.ts`, `.test.ts`, `modifiers/index.ts`, sliding-move generator updates - Pre-commit: `bun run check` - [x] 8. **Direction-additions descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/direction-additions.ts`: - `id: "direction-additions"`, `attrName: "DirectionAdditions"`, `valueSchema: z.array(directionEnum)`, `stackingRule: "union"`, `uiForm: "direction-set"`. - `apply`: set fact to union of arrays. - Integration: use `transformMoveGenerator` to wrap piece's base generator. Added directions generate 1-square moves (for step pieces) OR extend sliding pieces (for rook/bishop/queen) per the added direction. - Test: pawn with `["backward"]` gets a backward-1 move; rook with `["diagonal-ne"]` gets a diagonal ray added. **Must NOT do**: Don't auto-add capture semantics for new directions — that stays governed by CaptureFlags. Don't change existing generators except via the transform hook. **Recommended Agent Profile**: - **Category**: `unspecified-high` — non-trivial integration through transform hook - **Skills**: [`code-search`] **Parallelization**: Wave 3. Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/presets/pawns-move-backward.ts` (if exists — search for backward pawn preset; else model from piece-type-registry) - `packages/chess/src/modifiers/descriptors/range-bonus.ts` (T7 — similar transform hook pattern) **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/descriptors/direction-additions.test.ts` passes - [ ] Pawn + backward direction: 1-square backward move appears in legal moves - [ ] Union stacking: two entries each adding different directions → both present **QA Scenarios**: ``` Scenario: Backward-pawn modifier grants backward move Tool: Bash Steps: 1. Fixture: pawn on e4 white, no blockers 2. Apply DirectionAdditions=["backward"] 3. Query legal moves → assert e3 is in the set Evidence: .sisyphus/evidence/piece-modifiers/task-8-backward-pawn.txt Scenario: Direction union stacks without duplication Tool: Bash Steps: 1. Two sources: ["backward"] and ["sideways-left"] and ["backward"] 2. Assert final set = {backward, sideways-left} (no dup backward) Evidence: .sisyphus/evidence/piece-modifiers/task-8-union.txt ``` **Commit**: YES — `feat(engine): direction-additions modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/direction-additions.ts`, `.test.ts`, `modifiers/index.ts` - Pre-commit: `bun run check` - [x] 9. **Capture-flags descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/capture-flags.ts`: - `id: "capture-flags"`, `attrName: "CaptureFlags"`, `valueSchema: z.number().int().min(0).max(7)`, `stackingRule: "union"` (bitwise OR), `uiForm: "capture-flags"`. - Flags: `CAN_CAPTURE_OWN = 1`, `CANNOT_BE_CAPTURED = 2`, `EN_PASSANT = 4`. - Integration via `filterMoves` (if piece has `CANNOT_BE_CAPTURED`, filter out moves that would capture it from ALL enemy pieces) — this requires engine-wide scan. - Integration via `filterMoves` for `CAN_CAPTURE_OWN`: include moves to own-color squares. - Tests: 3 flag scenarios × happy path, flag union stacking, toggling behavior on live board. **Must NOT do**: Don't conflate with `CaptureHook` (which governs damage events). CaptureFlags govern move-generation legality only. **Recommended Agent Profile**: - **Category**: `unspecified-high` — touches multiple hook points - **Skills**: [`code-search`] **Parallelization**: Wave 3. Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/rules/capture.ts` — existing capture logic - `packages/chess/src/engine.ts:filterMoves invocation` **Acceptance Criteria**: - [ ] Piece with `CANNOT_BE_CAPTURED`: no enemy legal move targets it - [ ] Piece with `CAN_CAPTURE_OWN`: legal moves include own-color squares - [ ] Union stacking: flags OR'd correctly **QA Scenarios**: ``` Scenario: CANNOT_BE_CAPTURED shields piece from enemies Tool: Bash Steps: 1. Fixture: white knight b1 with flag=2; black queen in check range 2. Query black's legal moves → assert queen cannot target b1 Evidence: .sisyphus/evidence/piece-modifiers/task-9-shielded.txt Scenario: CAN_CAPTURE_OWN allows friendly fire Tool: Bash Steps: 1. White knight with flag=1, friendly pawn in move range 2. Assert knight's legal moves include the friendly square Evidence: .sisyphus/evidence/piece-modifiers/task-9-friendly-fire.txt ``` **Commit**: YES — `feat(engine): capture-flags modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/capture-flags.ts`, `.test.ts`, `modifiers/index.ts` - Pre-commit: `bun run check` - [x] 10. **Promotion-override descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/promotion-override.ts`: - `id: "promotion-override"`, `attrName: "PromotionOverride"`, `valueSchema: z.union([pieceTypeSchema, z.literal("disabled")])`, `stackingRule: "priority-wins"` (per-instance > per-type), `uiForm: "promotion-target"`. - Integration: hook into existing promotion logic in move-resolver. If piece has `PromotionOverride="disabled"`, remove the promotion step from the move. If set to a piece type, force that type. - Tests: pawn with override="queen" always promotes to queen; override="disabled" → pawn stays pawn; per-instance beats per-type. **Must NOT do**: Don't allow promotion to "king" (validator rejects — kings are singular-ish). Don't change default promotion prompt for pieces without override. **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [`code-search`] **Parallelization**: Wave 3. Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/rules/` — search for "promotion" to find resolver - `packages/chess/src/modifiers/types.ts` **Acceptance Criteria**: - [ ] Pawn reaches rank 8 with override="bishop" → becomes bishop (no prompt) - [ ] Pawn with override="disabled" → stays pawn on rank 8 - [ ] Per-instance override beats per-type override **QA Scenarios**: ``` Scenario: Promotion forced to specific type Tool: Bash Steps: 1. Fixture: pawn on e7 with PromotionOverride="bishop" 2. Execute move e7→e8 3. Assert piece on e8 is bishop Evidence: .sisyphus/evidence/piece-modifiers/task-10-forced.txt Scenario: Promotion disabled keeps piece as pawn Tool: Bash Steps: 1. Fixture: pawn on e7 with PromotionOverride="disabled" 2. Move to e8 — assert piece remains pawn Evidence: .sisyphus/evidence/piece-modifiers/task-10-disabled.txt ``` **Commit**: YES — `feat(engine): promotion-override modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/promotion-override.ts`, `.test.ts`, `modifiers/index.ts` - Pre-commit: `bun run check` - [x] 11. **Damage-resistance descriptor + tests** **What to do**: - Create `packages/chess/src/modifiers/descriptors/damage-resistance.ts`: - `id: "damage-resistance"`, `attrName: "DamageResistance"`, `valueSchema: z.number().min(0).max(1)`, `stackingRule: "multiplicative"` (as `1 - ∏(1 - r_i)`), `uiForm: "percentage"`. - Hook into `onDamage`: reduce incoming damage by `amount * (1 - effectiveResistance)`. Clamp final damage to `≥ 0`. - Tests: - 0.5 resistance → half damage. - Stacking: 0.5 + 0.5 → `1 - (0.5 * 0.5) = 0.75` → 25% damage taken. - Integration with HP preset: piece with HP=2 takes 1 damage (instead of 2) with 0.5 resistance. **Must NOT do**: Don't reduce damage below 0 (no healing via damage). Don't make resistance value negative. **Recommended Agent Profile**: - **Category**: `unspecified-high` — integrates with existing damage pipeline - **Skills**: [`code-search`] **Parallelization**: Wave 3. Blocks: 14, 21, 22, 24. Blocked By: 2, 3, 5. **References**: - `packages/chess/src/presets/piece-hp.ts` — existing `onDamage` hook - `packages/chess/src/engine.ts:dealDamage` **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/descriptors/damage-resistance.test.ts` passes - [ ] Multiplicative stacking formula verified - [ ] Damage clamped to ≥ 0 **QA Scenarios**: ``` Scenario: Resistance reduces damage multiplicatively Tool: Bash Steps: 1. Piece HP=10 with resistance=0.5 2. Deal 4 damage 3. Assert HP after = 8 (damage halved) Evidence: .sisyphus/evidence/piece-modifiers/task-11-reduction.txt Scenario: Stacked resistances compound Tool: Bash Steps: 1. Two resistances 0.5 + 0.5 → effective 0.75 2. Deal 4 damage → assert HP reduced by 1 (4 * 0.25) Evidence: .sisyphus/evidence/piece-modifiers/task-11-stack.txt ``` **Commit**: YES — `feat(engine): damage-resistance modifier descriptor` - Files: `packages/chess/src/modifiers/descriptors/damage-resistance.ts`, `.test.ts`, `modifiers/index.ts` - Pre-commit: `bun run check` - [x] 12. **Zod schema + roundtrip tests** **What to do**: - Create `packages/chess/src/modifiers/schema.ts` with Zod schemas for: - `TypeModifierSchema` — validates `kind` ∈ registry, `pieceType`, `color`, `value` dispatched via `MODIFIER_REGISTRY.get(kind).valueSchema`. - `InstanceModifierSchema` — same but with `square` instead of `pieceType`. - `ModifierProfileSchema` — full profile with `version: z.literal(1)`. - `parse(profile)` → `ModifierProfile`; `stringify(profile)` → canonical JSON. - Tests: roundtrip (parse-stringify-parse deep-equal); 10+ invalid fixtures rejected with specific errors; unknown `kind` rejected. **Must NOT do**: Don't hardcode value schemas — delegate to registry. Don't version-skip (v1 only in T1; v0 migration stub only if needed for future). **Recommended Agent Profile**: - **Category**: `unspecified-low` — Zod schema work - **Skills**: [`context7`] — latest Zod API **Parallelization**: Wave 3. Blocks: 13, 17, 19. Blocked By: 4. **References**: - `packages/chess/src/layouts/schema.ts` or equivalent — mirror layouts pattern - `packages/server/src/protocol.ts` — Zod usage patterns in project - Zod v3 docs: `z.discriminatedUnion`, `z.record` **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/schema.test.ts` passes - [ ] 10+ invalid fixtures rejected with specific error messages - [ ] Roundtrip test: `parse(JSON.parse(JSON.stringify(profile)))` deep-equal to input **QA Scenarios**: ``` Scenario: Valid profile roundtrips losslessly Tool: Bash Steps: 1. Run: bun run test packages/chess/src/modifiers/schema.test.ts -t "roundtrip" 2. Assert: exit 0, no output diff Evidence: .sisyphus/evidence/piece-modifiers/task-12-roundtrip.txt Scenario: Invalid kind rejected Tool: Bash Steps: 1. Test fixture with kind="nonexistent" 2. Run schema test — assert rejection with `ZodError` path Evidence: .sisyphus/evidence/piece-modifiers/task-12-invalid.txt ``` **Commit**: YES — `feat(engine): ModifierProfile Zod schema + roundtrip tests` - Files: `packages/chess/src/modifiers/schema.ts`, `packages/chess/src/modifiers/schema.test.ts` - Pre-commit: `bun run check` - [x] 13. **Library persistence** **What to do**: - Create `packages/chess/src/modifiers/library.ts`: - Key: `houserules:modifier-profiles:v1` - `SavedModifierProfile` shape: `{ profile: ModifierProfile, starred: boolean, createdAt, updatedAt }` - API: `saveToLibrary`, `loadLibrary`, `deleteFromLibrary`, `setStarred`, `duplicateEntry`, `makeId`. - Max 20 entries; oldest-non-starred eviction (mirror layout-library). - localStorage shim for tests (mirror `layout-library.test.ts`). - Tests: `packages/chess/src/modifiers/library.test.ts` — save/load/delete/star/evict/max-starred-full (14+ tests mirroring layout-library). **Must NOT do**: Don't add cloud sync. Don't support multiple library namespaces. **Recommended Agent Profile**: - **Category**: `unspecified-low` — direct mirror of layout-library - **Skills**: [] **Parallelization**: Wave 3. Blocks: 23. Blocked By: 12. **References**: - `packages/chess/src/persist/layout-library.ts` — direct mirror - `packages/chess/src/persist/layout-library.test.ts` — mirror tests **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/library.test.ts` passes (14+ tests) - [ ] Save → load roundtrip returns identical entries - [ ] Eviction works (21st save removes oldest-non-starred) **QA Scenarios**: ``` Scenario: Save and reload library entry Tool: Bash Steps: 1. Run: bun run test packages/chess/src/modifiers/library.test.ts -t "save and load" 2. Assert: entry saved, loaded identically Evidence: .sisyphus/evidence/piece-modifiers/task-13-save.txt Scenario: Eviction preserves starred entries Tool: Bash Steps: 1. Test "evict oldest non-starred" 2. Assert: starred entries survive, oldest unstarred evicted Evidence: .sisyphus/evidence/piece-modifiers/task-13-evict.txt ``` **Commit**: YES — `feat(engine): modifier-profile library persistence (v1)` - Files: `packages/chess/src/modifiers/library.ts`, `packages/chess/src/modifiers/library.test.ts` - Pre-commit: `bun run check` - [x] 14. **applyProfileToSession (game start)** **What to do**: - Create `packages/chess/src/modifiers/apply.ts`: - `applyProfileToSession(session, profile, layout)`: 1. For each `perType` entry: iterate pieces matching `(pieceType, color)`, seed modifier facts via `descriptor.apply(session, pieceId, value, sumOfOthers)`. 2. For each `perInstance` entry: look up pieceId at `square` in layout's piece mapping; if found, seed fact; if not found, log warning (orphan handling per ADR-2). 3. Stacking: collect all values for each `(pieceId, kind)` pair BEFORE applying, compute effective per ADR-4 stacking rules, then apply once. - Integrate with `ChessEngine` constructor: if `options.profile` supplied, call after layout applied but before presets activate. - Tests `apply.test.ts`: seed facts correctly for per-type + per-instance; stacking verified; orphan instances logged + skipped. **Must NOT do**: Don't apply profile mid-game (that's T15). Don't modify engine's preset activation order beyond the single insertion point. **Recommended Agent Profile**: - **Category**: `deep` — critical lifecycle integration - **Skills**: [] **Parallelization**: Wave 4. Blocks: 15, 19. Blocked By: 6-11, 16. **References**: - `packages/chess/src/starting-position.ts:applyLayout` — similar function, same invocation site - `packages/chess/src/engine.ts` — constructor + lifecycle - `docs/adr/modifier-profiles.md` § ADR-4, ADR-5 **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/apply.test.ts` passes - [ ] `effectivePieceAttrs(pieceId)` on a modified piece matches expected table - [ ] Orphan `perInstance` entry logged as warning, skipped **QA Scenarios**: ``` Scenario: Per-type modifiers seed all matching pieces Tool: Bash Steps: 1. Profile: perType=[{kind:"hp-bonus", pieceType:"knight", color:"white", value:3}] 2. Apply to session with classic layout 3. Assert: both white knights have HpBonus=3 Evidence: .sisyphus/evidence/piece-modifiers/task-14-per-type.txt Scenario: Per-instance modifier targets specific square Tool: Bash Steps: 1. Profile: perInstance=[{kind:"range-bonus", square:"b1", value:2}] 2. Apply 3. Assert: b1 knight has RangeBonus=2; g1 knight does not Evidence: .sisyphus/evidence/piece-modifiers/task-14-per-instance.txt Scenario: Orphan instance entry handled gracefully Tool: Bash Steps: 1. Profile with perInstance square="z9" (invalid) 2. Apply — assert no throw, warning logged, other entries still applied Evidence: .sisyphus/evidence/piece-modifiers/task-14-orphan.txt ``` **Commit**: YES — `feat(engine): apply profile at game start` - Files: `packages/chess/src/modifiers/apply.ts`, `.test.ts`, `packages/chess/src/engine.ts` (integration point) - Pre-commit: `bun run check` - [x] 15. **reconcileProfileSwap (hot-swap)** **What to do**: - Create `packages/chess/src/modifiers/reconcile.ts`: - `reconcileProfileSwap(session, oldProfile, newProfile, layout)`: 1. Compute old effective modifiers per piece. 2. Compute new effective modifiers per piece. 3. Diff → for each (pieceId, attr) pair, apply per-kind reconciliation rule (ADR-3): - HpBonus change: recompute maxHp, clamp currentHp to `min(currentHp, newMax)`. - RangeBonus / DirectionAdditions change: overwrite fact (next move query will reflect). - CaptureFlags change: overwrite (applies to next move). - PromotionOverride change: overwrite (applies to future promotions). - DamageResistance change: overwrite (applies to next damage event). 4. Idempotent: `reconcile(a, b, layout); reconcile(a, b, layout)` → same state as single call. - Tests: 5+ scenarios including HP clamp on maxReduction, idempotency, no-op when profiles equal, full swap (all modifiers different), partial swap. **Must NOT do**: Don't apply mid-turn. Don't retroactively resolve past events (in-flight attacks resolve against pre-swap state). **Recommended Agent Profile**: - **Category**: `deep` — determinism-critical - **Skills**: [] **Parallelization**: Wave 4. Blocks: 20. Blocked By: 14. **References**: - `packages/chess/src/modifiers/apply.ts` (T14) — source of effective computation - `docs/adr/modifier-profiles.md` § ADR-3 **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/reconcile.test.ts` passes (5+ scenarios) - [ ] HP clamp rule: piece at 7/10 HP, modifier removes +3 → maxHp=7, currentHp=7 (clamped) - [ ] Idempotency test passes **QA Scenarios**: ``` Scenario: HP clamp on maxHp reduction Tool: Bash Steps: 1. Piece at currentHp=7, maxHp=10 (HpBonus=+3) 2. Reconcile to profile with HpBonus=0 → maxHp=7, currentHp=min(7,7)=7 Evidence: .sisyphus/evidence/piece-modifiers/task-15-hp-clamp.txt Scenario: Reconcile is idempotent Tool: Bash Steps: 1. Reconcile(old, new, layout) twice 2. Assert session state identical after 1 call and after 2 calls Evidence: .sisyphus/evidence/piece-modifiers/task-15-idempotent.txt ``` **Commit**: YES — `feat(engine): hot-swap reconciliation` - Files: `packages/chess/src/modifiers/reconcile.ts`, `.test.ts` - Pre-commit: `bun run check` - [x] 16. **Legality validator** **What to do**: - Create `packages/chess/src/modifiers/validate.ts`: - `validateProfile(profile, layout, session?): ValidationResult { errors: [...], warnings: [...] }` - Checklist (ADR-6): 1. Both sides have ≥1 king (after applying all kingless-impact modifiers — e.g., CANNOT_BE_CAPTURED on king is fine; we only check presence). 2. No king has Invulnerability / CANNOT_BE_CAPTURED (HARD — treated as error). 3. No per-instance orphan refs (WARN, not error). 4. Each side has ≥1 legal move on starting position (fast query — skip if session not provided). 5. Attrs per piece ≤ 16 (DoS prevention). - Error codes: `E_PROFILE_NO_KING`, `E_PROFILE_INVULN_KING`, `E_PROFILE_ORPHAN_INSTANCE`, `E_PROFILE_DEADLOCK`, `E_PROFILE_ATTR_LIMIT`. - Tests: each error code has a positive + negative fixture; warning-level orphan doesn't block save. **Must NOT do**: Don't run full legal-game simulation (too slow for hot-swap). Don't over-validate — T1 catches the critical-safety subset only. **Recommended Agent Profile**: - **Category**: `deep` — checklist correctness is critical - **Skills**: [] **Parallelization**: Wave 4. Blocks: 14, 19, 20. Blocked By: 12. **References**: - `packages/chess/src/layouts/validate.ts` — sibling validator pattern - `docs/adr/modifier-profiles.md` § ADR-6 **Acceptance Criteria**: - [ ] `bun run test packages/chess/src/modifiers/validate.test.ts` passes - [ ] 5 error codes each tested positive + negative (10+ tests) - [ ] Invuln on king → error; invuln on queen → allowed **QA Scenarios**: ``` Scenario: Invuln on king rejected Tool: Bash Steps: 1. Profile: perInstance=[{kind:"capture-flags", square:"e1", value: CANNOT_BE_CAPTURED}] 2. Validate against classic layout 3. Assert: errors contains E_PROFILE_INVULN_KING Evidence: .sisyphus/evidence/piece-modifiers/task-16-invuln-king.txt Scenario: Orphan instance triggers warning not error Tool: Bash Steps: 1. Profile with perInstance square="z9" 2. Validate 3. Assert: warnings has entry; errors empty Evidence: .sisyphus/evidence/piece-modifiers/task-16-orphan.txt ``` **Commit**: YES — `feat(engine): profile legality validator` - Files: `packages/chess/src/modifiers/validate.ts`, `.test.ts` - Pre-commit: `bun run check` - [x] 17. **Server protocol extensions** **What to do**: - Extend `packages/server/src/protocol.ts`: - Import `ModifierProfileSchema` from chess package (re-export). - Add to `RoomCreatePayloadSchema`: `profile?: z.union([ProfileByIdSchema, ModifierProfileSchema]).optional()` (by ID reference to library OR inline profile). - New message: `ModifierProfileUpdatePayloadSchema` — `{ roomCode, newProfile: ModifierProfile, version: number }`. - New broadcast: `ModifierProfileUpdatedPayload` — `{ profile: ModifierProfile, version: number, appliedAt: "turn-boundary" }`. - Error codes: `MODIFIER_PROFILE_INVALID`, `MODIFIER_PROFILE_NO_KING`, `MODIFIER_PROFILE_INVULN_KING`, `MODIFIER_PROFILE_DEADLOCK`. - Wire types in `packages/chess/src/net/types.ts`: mirror server. - Update `packages/server/PROTOCOL.md` with full docs + examples. - Tests in `protocol.test.ts`: all new schemas validate valid payloads, reject invalid. **Must NOT do**: Don't modify existing messages. Don't remove or rename existing fields. **Recommended Agent Profile**: - **Category**: `unspecified-low` — additive schema work - **Skills**: [] **Parallelization**: Wave 4. Blocks: 19, 20. Blocked By: 12, 16. **References**: - `packages/server/src/protocol.ts` — current shape (look for `RoomCreatePayloadSchema`, `LAYOUT_INVALID` precedent) - `packages/server/PROTOCOL.md` — docs pattern - `packages/chess/src/net/types.ts` — client mirror **Acceptance Criteria**: - [ ] `bun run test packages/server/src/protocol.test.ts` passes (existing + new) - [ ] `bun run check` green - [ ] PROTOCOL.md has sections for all new messages with example JSON **QA Scenarios**: ``` Scenario: room-create accepts inline profile Tool: Bash Steps: 1. Fixture: valid RoomCreatePayload with profile inline 2. Run schema.parse → assert no throw Evidence: .sisyphus/evidence/piece-modifiers/task-17-inline.txt Scenario: modifier-profile.update schema validates Tool: Bash Steps: 1. Fixture: valid update payload with version + profile 2. Assert parse succeeds 3. Fixture: missing version → assert rejection Evidence: .sisyphus/evidence/piece-modifiers/task-17-update.txt ``` **Commit**: YES — `feat(server): modifier profile protocol schemas + error codes` - Files: `packages/server/src/protocol.ts`, `packages/server/src/protocol.test.ts`, `packages/server/PROTOCOL.md`, `packages/chess/src/net/types.ts` - Pre-commit: `bun run check` - [x] 18. **UI editor shell + rules drawer entry** **What to do**: - Create `packages/chess/src/ui/ModifierProfileEditor.tsx`: - Modal-style editor (mirror `LayoutEditor.tsx` layout). - Layout: left=palette (modifier catalog), center=board preview (reuse `LayoutEditor`'s board primitives), right=panel switcher (per-type / per-instance / library). - State: working profile draft + isDirty flag. - Header: name input, description input, save/cancel buttons, library drawer toggle. - Accessible from rules drawer: add "Modifier Profiles" entry in `RulesDrawer.tsx` (or equivalent) that opens the editor. - Add e2e fixture: `data-testid="open-modifier-editor"`, `data-testid="modifier-editor-modal"`. **Must NOT do**: Don't implement per-type or per-instance panels yet (T21, T22). Don't write save/load (T23). Shell only. **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`interface-design`, `frontend-ui-ux`] **Parallelization**: Wave 4. Blocks: 21, 22, 23. Blocked By: 5. **References**: - `packages/chess/src/ui/LayoutEditor.tsx` — shell pattern to mirror - Find rules drawer: `grep -r "rules" packages/chess/src/ui --include="*.tsx"` (probably in GameView or settings drawer) **Acceptance Criteria**: - [ ] `bunx playwright test e2e/modifier-profiles.spec.ts -g "editor opens"` passes - [ ] Editor visible within 1s of click - [ ] Esc closes editor (reuse layout-editor pattern) **QA Scenarios**: ``` Scenario: Editor opens from rules drawer Tool: Playwright Preconditions: Game running solo mode, rules drawer available Steps: 1. Click [data-testid=open-rules-drawer] 2. Click [data-testid=open-modifier-editor] 3. Wait for [data-testid=modifier-editor-modal] visible (timeout 1s) 4. Screenshot evidence Expected Result: modal visible within 1s Evidence: .sisyphus/evidence/piece-modifiers/task-18-open.png Scenario: Esc closes editor Tool: Playwright Steps: 1. Open editor 2. Press Escape 3. Assert modal hidden within 300ms Evidence: .sisyphus/evidence/piece-modifiers/task-18-esc.png ``` **Commit**: YES — `feat(ui): modifier profile editor shell + rules drawer entry` - Files: `packages/chess/src/ui/ModifierProfileEditor.tsx`, rules-drawer update, `e2e/modifier-profiles.spec.ts` (first tests) - Pre-commit: `bun run check` - [x] 19. **Server: room-create accepts profile** **What to do**: - Update `packages/server/src/rooms.ts`: add `Room.profile?: ModifierProfile` field. - Update `packages/server/src/game-session.ts`: accept optional profile, pass to ChessEngine options bag. - Update `packages/server/src/broadcast.ts`: in `handleRoomCreate`: 1. If payload has `profile`, resolve it (inline OR by-id lookup stub — for T1 inline only). 2. Validate via chess `validateProfile(profile, layout)`. 3. If invalid → respond with error code; don't create room. 4. If valid → create room with profile; echo in `room.created` payload. - Tests `packages/server/src/room.create-profile.test.ts`: valid profile creates room; invalid king-invuln rejected with `MODIFIER_PROFILE_INVULN_KING`. **Must NOT do**: Don't support profile-by-id lookup (T1: inline only). Don't break existing no-profile room-create. **Recommended Agent Profile**: - **Category**: `deep` — integration + validation - **Skills**: [] **Parallelization**: Wave 5. Blocks: 26, 27. Blocked By: 14, 17. **References**: - `packages/server/src/broadcast.ts` — existing layout resolution pattern (in `handleRoomCreate`) - `packages/server/src/rooms.ts` — Room shape (see layout field added in prior plan) **Acceptance Criteria**: - [ ] `bun run test packages/server/src/room.create-profile.test.ts` passes - [ ] Existing room-create tests without profile still pass - [ ] Invalid profile rejected with specific error code **QA Scenarios**: ``` Scenario: Room created with valid profile Tool: Bash Steps: 1. Test creates room with profile in payload 2. Assert: room.profile === submitted profile; broadcast echoes Evidence: .sisyphus/evidence/piece-modifiers/task-19-create.txt Scenario: Invalid profile rejects room creation Tool: Bash Steps: 1. Test with profile granting CANNOT_BE_CAPTURED to king 2. Assert: response error code MODIFIER_PROFILE_INVULN_KING 3. Assert: no room created Evidence: .sisyphus/evidence/piece-modifiers/task-19-invuln.txt ``` **Commit**: YES — `feat(server): room-create accepts profile` - Files: `packages/server/src/rooms.ts`, `packages/server/src/game-session.ts`, `packages/server/src/broadcast.ts`, `packages/server/src/room.create-profile.test.ts` - Pre-commit: `bun run check` - [x] 20. **Server: modifier-profile.update WS handler** **What to do**: - Add handler in `packages/server/src/broadcast.ts` for `modifier-profile.update` message: 1. Parse payload via `ModifierProfileUpdatePayloadSchema`. 2. Authenticate: only room host OR both-player consent (T1: host-only for simplicity). 3. Queue until turn-boundary (ADR-3): if mid-turn, set `room.pendingProfile`; apply on next `turnEnd`. 4. On apply: validate via `validateProfile(profile, layout, session)`. If invalid → NACK to sender with error code; don't broadcast. 5. If valid: call `reconcileProfileSwap(session, oldProfile, newProfile, layout)` (T15); update `room.profile`; increment version; broadcast `modifier-profile.updated` to all clients with new profile + facts delta. - Tests `packages/server/src/ws.modifier-profile-update.test.ts`: happy path, illegal-rejected-with-rollback, turn-boundary queueing, simultaneous-swap (last-write-wins). **Must NOT do**: Don't broadcast invalid swaps. Don't allow clients to bypass validator. Don't support mid-turn application (queue instead). **Recommended Agent Profile**: - **Category**: `deep` — concurrency + state consistency - **Skills**: [`code-search`] **Parallelization**: Wave 5. Blocks: 24, 25, 27. Blocked By: 15, 17. **References**: - `packages/server/src/broadcast.ts` — existing WS message dispatcher - `docs/adr/modifier-profiles.md` § ADR-3, ADR-6 **Acceptance Criteria**: - [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` passes (4+ scenarios) - [ ] Happy path: broadcast to all clients with new version number - [ ] Illegal: NACK to sender only; no broadcast - [ ] Turn-boundary queueing: mid-turn update doesn't apply immediately **QA Scenarios**: ``` Scenario: Happy-path hot-swap broadcasts Tool: Bash Steps: 1. Test sends valid modifier-profile.update 2. Trigger turnEnd 3. Assert: all connected clients receive modifier-profile.updated with new version Evidence: .sisyphus/evidence/piece-modifiers/task-20-happy.txt Scenario: Illegal swap rollback Tool: Bash Steps: 1. Send update granting invuln to king 2. Trigger turnEnd 3. Assert: sender receives NACK with error code; no broadcast; room.profile unchanged Evidence: .sisyphus/evidence/piece-modifiers/task-20-illegal.txt ``` **Commit**: YES — `feat(server): modifier-profile.update WS handler` - Files: `packages/server/src/broadcast.ts`, `packages/server/src/ws.modifier-profile-update.test.ts` - Pre-commit: `bun run check` - [x] 21. **UI per-type panel** **What to do**: - Create `packages/chess/src/ui/PerTypePanel.tsx`: - Layout: list of `TypeModifier` rows; "Add Type Modifier" button opens sub-form. - Sub-form: piece type dropdown (from PIECE_TYPE_REGISTRY), color toggle (white/black/both), modifier kind dropdown (from MODIFIER_REGISTRY), value input (schema-driven from `descriptor.uiForm`). - Validation: Zod schemas per modifier kind validate value input live. - On save: append to working profile's `perType` array. - On delete: remove entry. - Unit tests via Playwright: add per-type modifier → panel shows row with descriptor.describe output. **Must NOT do**: Don't implement per-instance here (T22). Don't bake hardcoded modifier kinds — iterate `MODIFIER_REGISTRY.list()`. **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`interface-design`] **Parallelization**: Wave 5. Blocks: 26, 27. Blocked By: 6-11, 18. **References**: - `packages/chess/src/ui/ModifierProfileEditor.tsx` (T18) — parent shell - `packages/chess/src/ui/LayoutEditor.tsx` — form patterns - `packages/chess/src/modifiers/registry.ts` — iteration source **Acceptance Criteria**: - [ ] Adding a per-type modifier shows in row list - [ ] All 6 modifier kinds selectable in dropdown - [ ] Invalid value input disables Save button **QA Scenarios**: ``` Scenario: Add per-type HP modifier Tool: Playwright Steps: 1. Open modifier editor → Per-Type tab 2. Click [data-testid=add-type-modifier] 3. Select pieceType=knight, color=white, kind=hp-bonus, value=2 4. Click [data-testid=save-type-modifier] 5. Assert: row shows "Knight (white): HP +2" 6. Screenshot evidence Evidence: .sisyphus/evidence/piece-modifiers/task-21-add.png Scenario: Invalid value disables save Tool: Playwright Steps: 1. Open add-modifier form, set range-bonus value=100 (out of range) 2. Assert [data-testid=save-type-modifier] is disabled Evidence: .sisyphus/evidence/piece-modifiers/task-21-invalid.png ``` **Commit**: YES — `feat(ui): per-type modifier panel` - Files: `packages/chess/src/ui/PerTypePanel.tsx`, editor wiring, e2e additions - Pre-commit: `bun run check` - [x] 22. **UI per-instance panel (board picker)** **What to do**: - Create `packages/chess/src/ui/PerInstancePanel.tsx`: - Layout: board preview (reuse LayoutEditor's board primitive) + click-to-select piece. - Selected piece panel: list current per-instance modifiers for that square; "Add Modifier" form (modifier kind + value input — schema-driven). - Per ADR-2: profile must be bound to a layout. If no layout selected yet → prompt user to pick layout first. - On selection, show modifier list; on add, append to working profile's `perInstance` array keyed by `square`. **Must NOT do**: Don't allow per-instance without a layout. Don't permit editing to produce orphans silently (show warning). **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`interface-design`] **Parallelization**: Wave 5. Blocks: 26, 27. Blocked By: 6-11, 18. **References**: - `packages/chess/src/ui/LayoutEditor.tsx:BoardPanel` — board primitive - `packages/chess/src/ui/PerTypePanel.tsx` (T21) — parallel form pattern - `docs/adr/modifier-profiles.md` § ADR-2 **Acceptance Criteria**: - [ ] Click piece on board → selection highlight + modifier list appears - [ ] Adding modifier keys it by `square` in `perInstance` array - [ ] Changing bound layout re-maps selection appropriately **QA Scenarios**: ``` Scenario: Per-instance modifier attached to specific piece Tool: Playwright Steps: 1. Open modifier editor → Per-Instance tab → select layout "classic" 2. Click b1 knight on preview board 3. Add modifier: kind=range-bonus, value=1 4. Assert: profile's perInstance contains {square:"b1", kind:"range-bonus", value:1} Evidence: .sisyphus/evidence/piece-modifiers/task-22-attach.png Scenario: No-layout state prompts selection Tool: Playwright Steps: 1. Open editor, no layout bound 2. Assert Per-Instance tab shows "Select a layout first" Evidence: .sisyphus/evidence/piece-modifiers/task-22-no-layout.png ``` **Commit**: YES — `feat(ui): per-instance modifier panel` - Files: `packages/chess/src/ui/PerInstancePanel.tsx`, e2e additions - Pre-commit: `bun run check` - [x] 23. **UI save/load library + URL share** **What to do**: - Add to `ModifierProfileEditor.tsx`: - Save button: writes to library via `saveToLibrary()`. - Library drawer: list saved profiles, load/rename/delete/star actions. - Share button: encode profile as URL param `?modifierProfileId=` OR `?modifierProfile=` for portability. - URL reader: `Lobby.tsx` reads `modifierProfile*` params and seeds editor/creates room. - Size check: enforce ≤ 8KB for URL-encoded profile; fall back to library-id share if too large. **Must NOT do**: Don't auto-save (explicit Save click only). Don't share as plain URL if > 8KB. **Recommended Agent Profile**: - **Category**: `unspecified-low` — mirrors layout save/share - **Skills**: [] **Parallelization**: Wave 5. Blocks: 26, 27. Blocked By: 13, 18. **References**: - `packages/chess/src/ui/Lobby.tsx` — layout URL-param pattern (same approach) - `packages/chess/src/ui/LayoutEditor.tsx` — library drawer pattern - `packages/chess/src/modifiers/library.ts` (T13) **Acceptance Criteria**: - [ ] Save → refresh page → reload from library → profile identical - [ ] URL share encode/decode roundtrips - [ ] Profile > 8KB falls back to library-id path **QA Scenarios**: ``` Scenario: Save and reload profile from library Tool: Playwright Steps: 1. Create profile "My HP" with a modifier, click Save 2. Close editor, reopen, open library drawer 3. Click "My HP" entry → assert editor loads with matching state Evidence: .sisyphus/evidence/piece-modifiers/task-23-save-reload.png Scenario: URL share roundtrips Tool: Playwright Steps: 1. Save profile, click Share → copy URL 2. Open URL in new tab 3. Assert profile pre-loaded in editor Evidence: .sisyphus/evidence/piece-modifiers/task-23-share.png ``` **Commit**: YES — `feat(ui): profile library save/load + URL share` - Files: `ModifierProfileEditor.tsx` updates, Lobby.tsx URL reader, e2e additions - Pre-commit: `bun run check` - [x] 24. **Hover tooltip inspection** **What to do**: - Create `packages/chess/src/ui/ModifierTooltip.tsx`: - Listens to hover events on piece squares in `GameView.tsx`. - Reads effective modifiers from session facts via `engine.session.get(pieceId, attr)` for each registered attr. - Cross-references with active profile to determine SOURCE of each modifier (per-type vs per-instance vs preset vs base). - Tooltip layout: piece name + color, list of active modifiers with source badge each (e.g. "HP +2 (per-type)", "Range +1 (per-instance)"). - Appear within 200ms of hover. - Reuse existing floating-ui lib if in project (grep first). - Wire into GameView. **Must NOT do**: Don't make tooltip modal / blocking. Don't require click (hover only for this task). Don't show pre-T1 modifiers (unregistered attrs). **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`interface-design`, `frontend-ui-ux`] **Parallelization**: Wave 6. Blocks: 25, 27. Blocked By: 6-11, 20. **References**: - `packages/chess/src/ui/GameView.tsx` — hover hook target - `packages/chess/src/modifiers/registry.ts` — iterate to enumerate visible attrs - Existing tooltip libs in project: grep for `floating-ui|Tooltip|@radix-ui` **Acceptance Criteria**: - [ ] Hover any modified piece → tooltip appears within 200ms - [ ] Tooltip shows all active modifiers with correct source badges - [ ] Unmodified piece → tooltip shows no modifier rows **QA Scenarios**: ``` Scenario: Hover modified piece shows tooltip Tool: Playwright Preconditions: Solo game started with profile granting HP +2 per-type on knights Steps: 1. Hover white knight on b1 2. Wait for tooltip within 200ms timeout 3. Assert tooltip text contains "HP +2" and "(per-type)" source 4. Screenshot Evidence: .sisyphus/evidence/piece-modifiers/task-24-hover.png Scenario: Unmodified piece has empty modifier section Tool: Playwright Steps: 1. Hover pawn (no modifiers in profile) 2. Assert tooltip shows piece name but no modifier rows Evidence: .sisyphus/evidence/piece-modifiers/task-24-empty.png ``` **Commit**: YES — `feat(ui): hover modifier tooltip` - Files: `packages/chess/src/ui/ModifierTooltip.tsx`, `GameView.tsx` integration - Pre-commit: `bun run check` - [x] 25. **Pinned side panel** **What to do**: - Create `packages/chess/src/ui/ModifierPinnedPanel.tsx`: - Click a piece (not hover) → panel pins on right side. - Content: same as tooltip but more detailed (full descriptions, source chain for stacked modifiers). - Reactive: subscribes to session fact changes — updates on hot-swap within 500ms. - Pinned state persists across turns until user clicks X or clicks another piece (re-pins to new piece). - Wire into GameView. **Must NOT do**: Don't include change history log (T2). Don't support multiple pinned panels simultaneously. **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`interface-design`] **Parallelization**: Wave 6. Blocks: 27. Blocked By: 24. **References**: - `packages/chess/src/ui/ModifierTooltip.tsx` (T24) — content source - `packages/chess/src/ui/GameView.tsx` — panel mounting point - Existing side panels in GameView for layout reference **Acceptance Criteria**: - [ ] Click piece → panel pins within 200ms - [ ] Hot-swap updates pinned panel within 500ms - [ ] Clicking X closes panel **QA Scenarios**: ``` Scenario: Click pins panel with matching content Tool: Playwright Steps: 1. Click b1 knight 2. Wait for [data-testid=modifier-pinned-panel] visible 3. Assert content matches what tooltip would show 4. Screenshot Evidence: .sisyphus/evidence/piece-modifiers/task-25-pin.png Scenario: Hot-swap updates pinned panel reactively Tool: Playwright Steps: 1. Pin panel on a modified piece (current: HP +2) 2. Trigger hot-swap via modifier-profile.update (new: HP +5) 3. Wait for panel text to update — assert within 500ms Evidence: .sisyphus/evidence/piece-modifiers/task-25-hotswap.png ``` **Commit**: YES — `feat(ui): pinned modifier inspection panel` - Files: `packages/chess/src/ui/ModifierPinnedPanel.tsx`, `GameView.tsx` integration - Pre-commit: `bun run check` - [x] 26. **Lobby profile picker integration** **What to do**: - Update `packages/chess/src/ui/Lobby.tsx`: - Add Profile Picker next to Layout Picker. - Dropdown of saved profiles from library + "None" + "Custom…" (opens editor). - On Create Room: if profile selected, include in RoomCreatePayload. - Reads `?modifierProfile*` URL param and pre-selects. - Show active profile on GameView header similar to LayoutBadge (new `ModifierProfileBadge`). **Must NOT do**: Don't auto-load "last used" profile unless user explicitly enables (T2 feature). Don't duplicate editor entry point — reuse Custom… flow. **Recommended Agent Profile**: - **Category**: `unspecified-low` — mirror layout picker - **Skills**: [] **Parallelization**: Wave 6. Blocks: 27. Blocked By: 19, 21, 22, 23. **References**: - `packages/chess/src/ui/Lobby.tsx` — LayoutPicker wiring pattern - `packages/chess/src/ui/GameView.tsx:LayoutBadge` — badge pattern **Acceptance Criteria**: - [ ] Profile picker visible in lobby - [ ] Creating room with profile: room state includes profile; GameView shows badge - [ ] URL param pre-selects profile **QA Scenarios**: ``` Scenario: Create room with profile Tool: Playwright Steps: 1. In lobby, select layout=classic, profile="My HP Profile" 2. Click Create Room 3. Assert GameView shows ModifierProfileBadge with profile name Evidence: .sisyphus/evidence/piece-modifiers/task-26-create.png Scenario: URL pre-select Tool: Playwright Steps: 1. Visit /lobby?modifierProfileId= 2. Assert picker shows selected profile Evidence: .sisyphus/evidence/piece-modifiers/task-26-url.png ``` **Commit**: YES — `feat(ui): lobby profile picker integration` - Files: `packages/chess/src/ui/Lobby.tsx`, `GameView.tsx` (badge addition) - Pre-commit: `bun run check` - [x] 27. **Full e2e Playwright suite** **What to do**: - Create `packages/chess/e2e/modifier-profiles.spec.ts` with 18 scenarios: 1. Editor opens from rules drawer within 1s 2. Esc closes editor 3. Create per-type HP bonus → save → reload from library 4. Create per-instance range bonus on b1 → save → reload 5. URL share roundtrip (with `?modifierProfile*` param) 6. Invalid profile (invuln on king) save blocked in editor with inline error 7. Solo game creates with profile; modifier visible in hover tooltip 8. Multiplayer: host creates room with profile, joiner sees same profile on join 9. Hot-swap mid-game: host updates profile at turn boundary, both clients see new values 10. Illegal hot-swap rejected by server with NACK; profile unchanged 11. Pinned panel click + content verification 12. Pinned panel updates reactively on hot-swap 13. Pawn with DirectionAdditions=["backward"] can move backward in game 14. Rook with RangeBonus=+1 can move 1 square further 15. Knight with CaptureFlags=CANNOT_BE_CAPTURED — enemy cannot target it 16. Pawn with PromotionOverride="bishop" auto-promotes to bishop (no prompt) 17. Piece with DamageResistance=0.5 takes half damage (test via HP preset + attack flow) 18. Profile library max-20 eviction test - Run from repo root: `bunx playwright test e2e/modifier-profiles.spec.ts`. **Must NOT do**: Don't add tests for T2/T3 features. Don't modify existing layout/multiplayer specs. **Recommended Agent Profile**: - **Category**: `unspecified-high` — thorough e2e authoring - **Skills**: [`playwright`] **Parallelization**: Wave 7. Blocks: 28. Blocked By: 19-26. **References**: - `packages/chess/e2e/layouts.spec.ts` — 24-scenario exemplar - `packages/chess/e2e/multiplayer.spec.ts` — two-client test pattern - `playwright.config.ts` (repo root) **Acceptance Criteria**: - [ ] All 18 scenarios pass: `bunx playwright test e2e/modifier-profiles.spec.ts` → 18/18 green - [ ] Runs in < 90s total - [ ] Evidence files for each scenario saved to `.sisyphus/evidence/piece-modifiers/e2e/` **QA Scenarios**: ``` Scenario: Full e2e suite passes Tool: Bash (invoking Playwright) Steps: 1. Run: bunx playwright test e2e/modifier-profiles.spec.ts --reporter=list 2. Assert: exit 0 3. Assert: 18 passed, 0 failed Evidence: .sisyphus/evidence/piece-modifiers/task-27-e2e-results.txt Scenario: Cross-browser spot check (chromium baseline) Tool: Bash Steps: 1. Run: bunx playwright test e2e/modifier-profiles.spec.ts --project=chromium 2. Assert exit 0 Evidence: .sisyphus/evidence/piece-modifiers/task-27-chromium.txt ``` **Commit**: YES — `test(e2e): modifier profiles full vertical slice` - Files: `packages/chess/e2e/modifier-profiles.spec.ts` - Pre-commit: `bun run check` + `bunx playwright test e2e/modifier-profiles.spec.ts` - [x] 28. **ADR finalization + user docs** **What to do**: - Finalize `docs/adr/modifier-profiles.md` (T1): add "Implementation Retrospective" section noting any deviations or discoveries. - Create `docs/user/modifier-profiles.md`: - What are modifier profiles? - How to open the editor - Per-type vs per-instance (when to use each) - Catalog of 6 T1 modifiers (one subsection each, with 1 example) - Hot-swap semantics (turn-boundary, host-only in T1) - Library save/load/share workflow - In-play inspection (hover + pin) - Known limitations (T1 scope; roadmap to T2/T3) - Include screenshots from e2e evidence. **Must NOT do**: Don't describe T2/T3 as if shipped. Don't promise features not in plan. **Recommended Agent Profile**: - **Category**: `writing` - **Skills**: [] **Parallelization**: Wave 7. Blocks: F1. Blocked By: 1, 27. **References**: - `.sisyphus/evidence/piece-modifiers/` — screenshots - `docs/adr/modifier-profiles.md` (T1) - `docs/user/` — existing user docs pattern if present **Acceptance Criteria**: - [ ] Both files exist - [ ] User doc references all 6 modifiers with at least 1 example each - [ ] Screenshots embedded (verify files referenced exist) **QA Scenarios**: ``` Scenario: Docs exist and reference all modifiers Tool: Bash Steps: 1. Assert: ls docs/adr/modifier-profiles.md docs/user/modifier-profiles.md (both exist) 2. Run: grep -c "hp-bonus\|range-bonus\|direction-additions\|capture-flags\|promotion-override\|damage-resistance" docs/user/modifier-profiles.md 3. Assert output ≥ 6 Evidence: .sisyphus/evidence/piece-modifiers/task-28-docs.txt Scenario: Referenced screenshots exist Tool: Bash Steps: 1. Extract image refs from docs/user/modifier-profiles.md 2. For each, assert the file exists Evidence: .sisyphus/evidence/piece-modifiers/task-28-images.txt ``` **Commit**: YES — `docs(user): modifier profiles user guide` - Files: `docs/adr/modifier-profiles.md` (finalization), `docs/user/modifier-profiles.md` - Pre-commit: none (docs only) --- ## Final Verification Wave (MANDATORY — after ALL implementation tasks) - [x] F1. **Plan Compliance Audit** — `oracle` Read this plan end-to-end. For each "Must Have": verify implementation exists (read file, run tests). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check every ADR decision is reflected in code (WRAP semantics in transformMoveGenerator, layout-slot keying in per-instance, turn-boundary-only hot-swap, registry pattern, etc). Verify evidence files exist in `.sisyphus/evidence/piece-modifiers/`. Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - [x] F2. **Code Quality Review** — `unspecified-high` Run `bun run check` (typecheck + lint + vitest). Review all changed files for: `as any`/`@ts-ignore`, empty catches, `console.log` in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp). Verify descriptors follow registry pattern (no hardcoded switch statements across 6+ files). Verify shared engine code between client/server (no drift). Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | Pattern compliance [PASS/FAIL] | VERDICT` - [x] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill) Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration: create profile with all 6 modifier kinds → save to library → URL-share → load in new tab → start room with profile + layout → play 3 turns using modified moves → hot-swap to different profile → verify reconciliation → hover/pin inspection throughout → game ends correctly. Test edge cases: empty profile, profile with orphan instance entries, illegal profile (king invuln), simultaneous swap, reconnect during swap. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance per task. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. Verify T2/T3 features NOT smuggled into T1: no copy-paste, no diff UI, no DSL, no auras, no multi-profile stacking, no mid-turn swap. Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | Scope creep [CLEAN/N issues] | VERDICT` --- ## Commit Strategy Atomic commits per task. Each commit compiles + tests green. Use conventional commits. 1. `docs(adr): modifier-profiles architecture decisions` 2. `feat(engine): add 6 modifier attrs to ChessAttrMap` 3. `feat(engine): add transformMoveGenerator + modifyMoveAttrs preset hooks` 4. `feat(engine): modifier profile types` 5. `feat(engine): modifier registry pattern` 6-11. `feat(engine): {kind}-modifier descriptor with unit tests` (×6) 12. `feat(engine): ModifierProfile Zod schema + roundtrip tests` 13. `feat(engine): modifier-profile library persistence (v1)` 14. `feat(engine): apply profile at game start` 15. `feat(engine): hot-swap reconciliation` 16. `feat(engine): profile legality validator` 17. `feat(server): modifier profile protocol schemas + error codes` 18. `feat(ui): modifier profile editor shell + rules drawer entry` 19. `feat(server): room-create accepts profile` 20. `feat(server): modifier-profile.update WS handler` 21. `feat(ui): per-type modifier panel` 22. `feat(ui): per-instance modifier panel` 23. `feat(ui): profile library save/load + URL share` 24. `feat(ui): hover modifier tooltip` 25. `feat(ui): pinned modifier inspection panel` 26. `feat(ui): lobby profile picker integration` 27. `test(e2e): modifier profiles full vertical slice` 28. `docs(user): modifier profiles user guide` ## Success Criteria ### Verification Commands ```bash bun run check # Expected: all green bun run test packages/chess/src/modifiers/ # Expected: N pass, 0 fail bun run test packages/server/src/profile-validator.test.ts # Expected: all pass bunx playwright test e2e/modifier-profiles.spec.ts # Expected: 18/18 pass ``` ### Final Checklist - [ ] All 6 modifier descriptors registered and functional - [ ] Per-type + per-instance both work end-to-end - [ ] Hot-swap at turn boundary, validated server-side - [ ] Editor reachable in ≤2 clicks from game - [ ] Hover tooltip < 200ms, pinned panel < 500ms on update - [ ] All QA scenario evidence files present - [ ] ADR + user docs published - [ ] `bun run check` green - [ ] Playwright e2e 18/18 green - [ ] No `as any`, no `@ts-ignore`, no pattern violations - [ ] F1-F4 final-wave all APPROVE - [ ] User explicit "okay"