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

87 KiB
Raw Permalink Blame History

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 EntityIds 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 EntityIds 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

  • 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
  • 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
  • 3. Add transformMoveGenerator + modifyMoveAttrs preset hooks

    What to do:

    • Extend PresetDef in packages/chess/src/presets/registry.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
  • 4. Modifier profile types

    What to do:

    • Create packages/chess/src/modifiers/types.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
  • 5. Modifier registry + barrel

    What to do:

    • Create packages/chess/src/modifiers/registry.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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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)

  • 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

  • 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

  • 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

  • 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)
  6. feat(engine): ModifierProfile Zod schema + roundtrip tests
  7. feat(engine): modifier-profile library persistence (v1)
  8. feat(engine): apply profile at game start
  9. feat(engine): hot-swap reconciliation
  10. feat(engine): profile legality validator
  11. feat(server): modifier profile protocol schemas + error codes
  12. feat(ui): modifier profile editor shell + rules drawer entry
  13. feat(server): room-create accepts profile
  14. feat(server): modifier-profile.update WS handler
  15. feat(ui): per-type modifier panel
  16. feat(ui): per-instance modifier panel
  17. feat(ui): profile library save/load + URL share
  18. feat(ui): hover modifier tooltip
  19. feat(ui): pinned modifier inspection panel
  20. feat(ui): lobby profile picker integration
  21. test(e2e): modifier profiles full vertical slice
  22. docs(user): modifier profiles user guide

Success Criteria

Verification Commands

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"