houserules/docs/adr/modifier-profiles.md

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