diff --git a/docs/adr/modifier-profiles.md b/docs/adr/modifier-profiles.md new file mode 100644 index 0000000..06167d3 --- /dev/null +++ b/docs/adr/modifier-profiles.md @@ -0,0 +1,346 @@ +# ADR: Piece Modifier Profiles Architecture + +This document captures the eight architectural decisions that shape the T1 +piece-modifier-profiles feature. Each section is self-contained and records +the decision, the reasoning behind it, and the alternatives that were +considered and rejected. + +--- + +## ADR-1: Move Generator Hook Contract — WRAP (not REPLACE) + +### Decision + +Introduce a new hook signature on modifier/preset descriptors: + +``` +transformMoveGenerator?(engine, pieceId, prevGenerator) => MoveGenerator +``` + +Each hook receives the *previous* generator in the chain and returns a new +one. Multiple presets plus the active profile can all contribute, composing +in precedence order. The generator returned by the chain emits +**pseudo-legal** moves only. + +### Rationale + +- **Composition over replacement.** Several modifier sources (presets, + per-type profile entries, per-instance profile entries) must be able to + stack without one clobbering another. +- **Clear layering.** The engine's legality layer (self-check filter, turn + validation, repetition detection) always runs downstream of the generator + chain. Hooks never need to reason about whole-board legality — only about + the piece's own movement facts. +- **Deterministic ordering.** Precedence (ADR-5) uniquely defines the order + in which generators are wrapped, so the final generator is reproducible + from the profile alone. + +### Rejected Alternatives + +- **REPLACE semantics** (only the first/highest-priority hook wins): kills + composition. Two independent modifiers that both want to alter movement + could not coexist, forcing users to choose one or hand-merge them. +- **Mid-chain interception** (hooks can peek at or mutate moves emitted by + other hooks): dramatically more complex to reason about, breaks locality, + and makes determinism hard to audit. + +--- + +## ADR-2: Per-Instance Identity Keying — Layout-Slot Bound (Option B) + +### Decision + +Per-instance modifier entries in a profile are keyed by the piece's +**starting square in algebraic notation** (e.g. `"b1"`). The profile +document carries an optional `layoutId` field. At game start, +`applyProfileToSession` resolves each `square → EntityId` by consulting the +layout's piece placement array. + +Cross-layout reuse is permitted for the *per-type* portion of a profile. +The *per-instance* portion is dropped with a warning when the active layout +does not contain the referenced square (orphan handling — see ADR-6 check +#3). + +### Rationale + +- **Human-readable.** Profile JSON (and any URL-encoded form) stays legible: + a designer can see `"b1": { ... }` and immediately know which piece it + targets. +- **Portable within a layout.** Two sessions using the same layout can + share the profile verbatim. +- **Graceful degradation.** When a profile is applied against a different + layout, per-type rules still apply; only the now-meaningless per-instance + keys are dropped, with a surfaced warning. + +### Rejected Alternatives + +- **Option A — key by `EntityId`.** EntityIds are session-scoped runtime + handles; they are meaningless outside the session that produced them. + This would make profiles non-portable and impossible to author by hand. +- **Option C — defer per-instance entirely to T2.** Per-instance keying is + the single feature that makes "this specific rook on b1" different from + "all rooks" possible in T1; deferring it would gut the feature's value. + +--- + +## ADR-3: Hot-Swap Reconciliation Rules + +### Decision + +When a profile is swapped on a live game, each modifier kind reconciles +according to the following table: + +| Modifier | On swap-apply | On swap-remove | +| ------------------- | ------------------------------------------------------------- | --------------------------------------- | +| HpBonus | `maxHp` recomputed; `currentHp = min(currentHp, newMax)` | `currentHp` never grows (same rule) | +| RangeBonus | Overwrite fact; next legal-move query reflects new range | Revert to base; same query semantics | +| DirectionAdditions | Overwrite fact; next legal-move query includes/excludes | Revert to base direction set | +| CaptureFlags | Overwrite; applies to the **next** capture event | Revert; applies to next capture | +| PromotionOverride | Overwrite; applies to **future** promotions only | Revert; future promotions use base | +| DamageResistance | Overwrite; applies to the **next** damage event | Revert; next damage uses base | + +**Timing.** Hot-swaps are applied at the **turn boundary only** — after +`turnEnd` fires and before the next `turnStart`. Any update received +mid-turn is queued and flushed at that boundary. + +**Failure / rollback.** The server is authoritative and clients hold no +optimistic state for profile changes. If a swap is rejected (legality +validator fails, permission denied, etc.) the server sends a NACK to the +originating client and performs no broadcast. There is no per-event +rollback to unwind partially-applied effects, because effects do not apply +until the boundary. + +### Rationale + +- **Determinism.** Applying changes strictly at turn boundaries means every + observer (players, spectators, replays) sees an identical sequence of + (state, swap, state, swap) transitions. +- **HP invariant.** The "never grows" rule for `currentHp` preserves the + intuition that removing a buff should not heal; applying a buff raises + the ceiling but never the floor. +- **Server authority.** Keeping clients dumb about swap outcomes avoids a + whole class of desync bugs; the NACK pattern keeps the protocol simple. + +### Rejected Alternatives + +- **Mid-turn application.** Introduces ordering ambiguity relative to + queued events, move-generation caches, and partially-resolved attacks; + determinism becomes painful to reason about. +- **Per-event rollback.** Would require journaling every effect so it can + be unwound on failure. T1 does not need this level of sophistication, + and the boundary-only rule makes it unnecessary. + +--- + +## ADR-4: Stacking Rules per Modifier Kind + +### Decision + +Each T1 modifier kind declares a fixed stacking rule, applied whenever more +than one source contributes a value (per ADR-5 precedence): + +| Modifier | Stacking rule | Formula | +| ------------------- | -------------------------------- | --------------------------------------------- | +| HpBonus | Additive | sum of all sources | +| RangeBonus | Additive, clamped `[0, 7]` | sum, then clamp | +| DirectionAdditions | Union | set union of arrays | +| CaptureFlags | Union (bitwise OR) | flags OR'd together | +| PromotionOverride | Precedence wins | perInstance > perType > preset | +| DamageResistance | Multiplicative | `1 - ∏(1 - r_i)`, clamped `≥ 0` | + +### Rationale + +- **Additive/union kinds** have natural commutative/associative semantics, + so order of application does not matter. This is safe to compute in any + order. +- **Clamping `RangeBonus`** to the 0..7 board diagonal keeps generators + sane; an 8-square-wide board can never need more than 7 steps of range. +- **Multiplicative `DamageResistance`** matches player intuition: two + sources of 50% resistance yield 75% total, not 100%. This prevents + accidental invulnerability from additive stacking. +- **PromotionOverride as "precedence wins"** reflects that overrides are + replacement-style facts — two simultaneous overrides cannot meaningfully + merge, so the highest-priority one is chosen. + +### Rejected Alternatives + +- **All-additive** (including resistance): trivially produces 100% + resistance with two modest sources, breaking balance. +- **All-overwrite**: kills the expressive power of stacking entirely and + forces designers to pre-merge any combination of modifiers they want. + +--- + +## ADR-5: Precedence Chain + +### Decision + +When multiple sources contribute to the same piece and modifier kind, the +priority order is: + +``` +per-instance (profile) > per-type (profile) > preset > engine base +``` + +- For **additive** and **union** stacking rules (see ADR-4), *all* sources + contribute; precedence only controls the order in which hooks wrap the + move generator (ADR-1). +- For **override** kinds (e.g. `PromotionOverride`), only the + highest-priority source wins. + +### Rationale + +- **Specificity first.** Per-instance data is the most specific statement + ("this piece on b1"), per-type is broader ("all bishops"), preset is + broader still ("this game mode"), and engine base is the default. Higher + specificity winning matches designer expectations and mirrors how CSS, + config layering, and similar systems behave. +- **Composable by default.** Treating additive/union kinds as contributors + rather than gated by precedence means a per-type buff and a per-instance + buff can *both* apply, which is the whole point of having two layers. + +### Rejected Alternatives + +- **Preset-first** (preset beats profile): undermines user-authored + profiles and makes game modes unmodifiable. +- **Flat merge with no precedence**: leaves override-style modifiers + ambiguous. + +--- + +## ADR-6: Legality Validator Checklist + +### Decision + +On every profile apply and every hot-swap, the engine runs a legality +validator with five checks. Each check has a stable error code for +client-side reporting. + +1. **Both sides have ≥ 1 king.** Code: `E_PROFILE_NO_KING`. Error. +2. **No king carries `CaptureFlags = CANNOT_BE_CAPTURED`.** Code: + `E_PROFILE_INVULN_KING`. Error. +3. **No orphan per-instance entries** (per-instance key references a + square not present in the active layout). Code: + `E_PROFILE_ORPHAN_INSTANCE`. **Warning only**, not a rejection — the + orphan entry is dropped per ADR-2. +4. **Each side has ≥ 1 legal move** from the current position. Code: + `E_PROFILE_DEADLOCK`. Error. +5. **Total resolved facts per piece ≤ 16 attributes.** Code: + `E_PROFILE_ATTR_LIMIT`. Error. + +Checks that emit an error cause the apply/swap to be rejected atomically; +no partial state is observable. Warnings are surfaced but do not block +application. + +### Rationale + +- **Win-condition integrity.** Checks 1 and 2 guarantee the game can still + be won — you cannot accidentally author a profile that removes all kings + or makes a king uncapturable. +- **Playability.** Check 4 prevents instant deadlocks at swap time. +- **Performance & sanity ceiling.** Check 5 caps the fact-set per piece so + that the attribute system cannot be overwhelmed by pathological + profiles. +- **User experience.** Check 3 is a warning rather than an error because + dropping irrelevant per-instance keys (see ADR-2) is the documented + behaviour when applying across layouts. + +### Rejected Alternatives + +- **No validator — trust the author.** Produces unrecoverable games and + makes server state hard to reason about. +- **Validator as errors only (no warnings).** Would force cross-layout + reuse to be a hard failure, defeating ADR-2's portability goal. +- **Validator at game-start only.** Misses hot-swap-introduced + corruptions. + +--- + +## ADR-7: Single Active Profile Per Game (T1) + +### Decision + +In T1, exactly one profile is active on a given game at a time. Changing +the active profile is a **full swap** performed by sending a +`modifier-profile.update` WebSocket message. Profile stacking or layered +composition of multiple profiles is explicitly deferred to T2+. + +### Rationale + +- **Scope control.** T1 already introduces a new hook, registry, profile + document shape, and validator. Adding multi-profile composition on top + would multiply the edge cases (ordering between profiles, overlap + conflicts, partial updates) and delay the feature. +- **Simple mental model.** "One profile, swap to change it" is trivially + understandable by end users and matches how presets are selected today. +- **Forward-compatible.** The swap message already carries a full profile + payload; a future multi-profile world can extend the same message shape + (e.g. `profiles: Profile[]`) without breaking T1 clients. + +### Rejected Alternatives + +- **Multi-profile composition in T1.** Punts on too many unresolved + design questions (inter-profile precedence, addition vs replacement, + diffing) to fit in the T1 milestone. +- **Additive deltas only (no full swap).** Harder to reason about when + recovering from a corrupted state; the full-swap primitive is simpler + and can always simulate a delta by applying a re-derived profile. + +--- + +## ADR-8: Registry Pattern for Modifier Catalog + +### Decision + +Each T1 modifier kind lives in its own file under: + +``` +packages/chess/src/modifiers/descriptors/{kind}.ts +``` + +At module load time the file self-registers into `MODIFIER_REGISTRY`, +mirroring the existing `PRESET_REGISTRY` and `LAYOUT_REGISTRY` patterns +(with `register(def)`, `get(id)`, `list()`, `has(id)` surface). + +The descriptor shape is: + +``` +{ + id, + attrName, + label, + valueSchema, + stackingRule, + apply, + describe, + uiForm, +} +``` + +T3 custom (user-defined) modifiers will plug into this same registry, +requiring no changes to the registry contract itself. + +### Rationale + +- **One modifier = one file.** Adding a new modifier kind is a single-file + addition, not a multi-file edit across schema, engine, UI, and + validator. This is the same ergonomic property that makes the preset + and layout registries pleasant to extend. +- **Consistency with existing patterns.** The codebase already has two + registries following this shape; a third avoids introducing a new + idiom to learn. +- **T3-ready.** Framing the registry as the single integration point now + means T3 custom modifiers can register through the same API without + special-casing. +- **Discoverability.** `MODIFIER_REGISTRY.list()` produces the full + catalog for UI/UX (picker widgets, docs generators, validation hints). + +### Rejected Alternatives + +- **Hardcoded switch/if-else.** Adding a new modifier kind would require + editing six or more files (schema, engine apply path, hooks, UI, docs, + validator). Each edit is an opportunity for drift between layers. +- **Plugin manifest with lazy loading.** Overkill for a fixed T1 catalog + and incompatible with deterministic module load ordering. + +---