docs(adr): modifier-profiles architecture decisions
This commit is contained in:
parent
8828908a59
commit
a442e54d79
1 changed files with 346 additions and 0 deletions
346
docs/adr/modifier-profiles.md
Normal file
346
docs/adr/modifier-profiles.md
Normal file
|
|
@ -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.
|
||||
|
||||
---
|
||||
Loading…
Add table
Add a link
Reference in a new issue