346 lines
14 KiB
Markdown
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.
|
|
|
|
---
|