houserules/docs/adr/modifier-profiles.md

346 lines
14 KiB
Markdown

# 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.
---