14 KiB
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
currentHppreserves 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
RangeBonusto the 0..7 board diagonal keeps generators sane; an 8-square-wide board can never need more than 7 steps of range. - Multiplicative
DamageResistancematches 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.
- Both sides have ≥ 1 king. Code:
E_PROFILE_NO_KING. Error. - No king carries
CaptureFlags = CANNOT_BE_CAPTURED. Code:E_PROFILE_INVULN_KING. Error. - 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. - Each side has ≥ 1 legal move from the current position. Code:
E_PROFILE_DEADLOCK. Error. - 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.