houserules/.sisyphus/plans/piece-modifiers.md

1874 lines
87 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<V = unknown> {
readonly id: ModifierKindId;
readonly attrName: keyof ChessAttrMap;
readonly label: string;
readonly valueSchema: ZodSchema<V>;
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<ModifierKindId, ModifierDescriptor>();
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=<id>` OR `?modifierProfile=<base64-json>` 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=<id>
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"