Compare commits

...

88 commits

Author SHA1 Message Date
6cddb1dcd0
chore(sisyphus): T3 Final Verification Wave — all reviewers APPROVE
F1 Plan Compliance Audit — APPROVE
  Primitives [15/15] | Tasks [17/17 top-level] | ADRs [7/7]

F2 Code Quality Review — APPROVE
  Build [PASS] | Lint [PASS] | Tests [1386 pass] | No 'as any' / '@ts-ignore'
  in non-test source | Registry-dispatch pattern throughout (no
  hardcoded kind switches)

F3 Manual QA — APPROVE
  e2e [79/79] including 18/18 custom-modifiers.spec.ts scenarios
  (plan called for 15, shipped 18). All former fixmes passing.

F4 Scope Fidelity — APPROVE
  Recursion cap [3, enforced by MAX_RECURSION_DEPTH in validate.ts]
  Primitive count cap [50, enforced by MAX_PRIMITIVE_COUNT in
  validate.ts + server Zod .max(50)]
  Per-room cap [10, enforced by CUSTOM_MODIFIER_ROOM_CAP in
  broadcast.ts]
  Per-engine custom registry [CustomModifierRegistry owned by
  ChessEngine, never global — cross-room leakage structurally
  impossible]
  T4 smuggling [CLEAN — scripted type is rejected in both
  validate.test.ts and schema.test.ts; no runtime scripted
  descriptor shipped]

T3 boulder complete.
2026-04-19 22:04:36 -06:00
babee38702
feat(ui): share custom modifier with multiplayer room — closes T29 fixmes
Threads the multiplayer publisher all the way from useMultiplayerGame
down through GameView → RulesDrawer → ModifierProfileEditor →
CustomModifierEditor, surfacing a Share with Room button in the
custom modifier editor when (and only when) the editor was opened
from a multiplayer game.

Wiring summary (top-down):
- useMultiplayerGame.ts: returns sendRegisterCustomModifier(descriptor),
  a thin wrapper around the GameClient.sendRegisterCustomModifier
  helper added in the previous commit.
- useMultiplayerGame.ts: onError handler surfaces CUSTOM_MODIFIER_INVALID
  and CUSTOM_MODIFIER_LIMIT as toasts on top of the existing in-game
  error banner so the user notices the rejection immediately.
- GameView.tsx: GameEngineState gains an optional
  sendRegisterCustomModifier field; the multiplayer destructure
  pulls it out and passes it to RulesDrawer as
  onShareCustomModifierWithRoom (omitted in solo, where the prop is
  undefined and Share UI doesn't render).
- RulesDrawer.tsx: optional onShareCustomModifierWithRoom prop;
  conditionally forwards to ModifierProfileEditor.
- ModifierProfileEditor.tsx: optional onShareCustomModifierWithRoom
  prop; conditionally forwards to CustomModifierEditor as onShareWithRoom.
- CustomModifierEditor.tsx: when onShareWithRoom is provided, renders
  a green Share with Room button in the header alongside Save. Click
  invokes the publisher with the current descriptor; toast confirms
  the share landed (server broadcast is the actual proof, observed
  by the local PredictionManager subscriber registering the descriptor
  on the engine's customModifiers registry).

E2E coverage (both formerly-fixme tests now PASS):
- multiplayer custom modifier sharing — both clients see the
  registered descriptor: opens two browser contexts via raw WS
  (matches modifier-profiles.spec.ts MP pattern), host registers a
  descriptor after both reconnect-by-token complete, both sides
  observe custom-modifier.registered.
- server rejects custom modifier with > 50 primitives — error event
  observed: host registers a 51-primitive descriptor, asserts an
  INVALID_MESSAGE / CUSTOM_MODIFIER_INVALID error is observed and
  no broadcast fires.

Final state: 79/79 e2e + 1386 unit tests, zero fixmes, zero skipped.
2026-04-19 21:56:34 -06:00
8fb5669c9a
chore(sisyphus): mark T3 Wave 5 complete (T29-T32) 2026-04-19 21:38:56 -06:00
63c46a3f9e
feat(net): client subscriber + send method for custom-modifier broadcast
Wires the T24 server-side custom-modifier.register handler all the way
through to per-engine custom registries on every connected client.

net/types.ts:
- new CustomModifierDescriptorWire interface mirroring the chess-side
  CustomModifierDescriptor (structurally identical; Zod-mirrored across
  the v3/v4 boundary).
- new CustomModifierRegisterPayload + CustomModifierRegisteredPayload.
- ServerMessage union extended with custom-modifier.registered envelope.
- ClientMessage union extended with custom-modifier.register envelope.

net/client.ts:
- GameClientEvent union extended with custom-modifier.registered.
- handleMessage dispatch switch routes the event to listeners.
- new GameClient.sendRegisterCustomModifier(descriptor) helper that
  ships the message under the active room code; silent no-op when
  the client isn't in a room (mirrors sendMove's pre-connect guard).

net/prediction.ts:
- PredictionManager subscribes to custom-modifier.registered. On
  receipt, registers the descriptor onto BOTH baseEngine.customModifiers
  AND predictedEngine.customModifiers (when present) so subsequent
  profile applies and reconciliation from a future game.state can
  resolve the kind. Triggers an onStateChange so the UI re-renders.

Two e2e fixmes remain — both depend on a CustomModifierEditor button
that calls sendRegisterCustomModifier when the editor is opened from
a multiplayer game. The wire is fully implemented; only the editor's
multiplayer-aware send-button surface is missing. Documented as a
T3.1 follow-up in the e2e fixme comments.
2026-04-19 21:38:32 -06:00
747d0fb728
feat(engine): wire trigger primitives + absorb-damage into runtime pipelines
T3 follow-up addressing 4 of the 6 e2e fixmes (T29). Ships the engine
wiring that was deferred in T3's original scope per the implementation
retrospective.

New triggers.ts exports four dispatchers + an HP snapshot helper:
- fireOnTurnStartHooks(engine, whoseTurn): walks every piece of the
  given color, runs each OnTurnStartHooks entry as a primitive list.
- fireOnCaptureHooks(engine, attackerId): runs OnCaptureHooks for the
  attacker piece. attackerId is captured in onBeforeMove (engine.moveLog
  isn't yet populated when onAfterMove fires, so we can't read from it).
- fireOnDamagedHooks(engine, preMoveHp): compares post-move Hp facts
  against a pre-move snapshot; pieces whose Hp dropped (or whose Hp
  fact was retracted = died) get their OnDamagedHooks fired.
- fireConditionalHooks(engine): re-evaluates every ConditionalHook's
  condition against current piece state; runs the matching then/else
  branch.
- snapshotHp(session): freezes Hp facts at the supplied phase for the
  on-damaged dispatcher to compare against.

Conditions supported: attr-lt, attr-gt, attr-eq, always, never (matches
the ConditionSpec union from T14).

Each dispatcher recurses through nested primitives via the same
PRIMITIVE_REGISTRY lookup the descriptor applier uses, with the runtime
depth cap (8) as a backstop. Trigger evaluation has no parent
descriptor — synthesised __trigger__ ref fills the contract.

Integration in apply.ts (__modifier-profile-integration__ preset):

- onBeforeMove: snapshots Hp + the attacker pieceId (when isCapture).
  Both stored in WeakMaps keyed by engine so concurrent engines
  (server-authoritative + client-predicted) keep independent state.
- onDamage: NEW absorb-damage-with-attribute branch BEFORE the existing
  DamageResistance branch. When AbsorbDamageAttr+AbsorbDamageRate are
  set on the target, incoming damage spends the attribute first;
  full absorption short-circuits with consume:true died:false.
  Partial absorbs fall through (same documented limitation as
  partial DamageResistance).
- onAfterMove: now runs computeAuraFacts (T28, unchanged) + four
  trigger dispatchers in order:
    fireOnDamagedHooks → fireOnCaptureHooks → fireConditionalHooks
    → fireOnTurnStartHooks (for the next-mover's color)

7 vitest scenarios in triggers.test.ts cover each dispatcher
including the absorb-damage shield (3 charges → 0 → fall through).
2026-04-19 21:38:01 -06:00
fb6170127e
test(e2e): custom modifier DSL vertical slice + integration fixes
T3 Wave 5 (T29). New Playwright suite at e2e/custom-modifiers.spec.ts
covering 16 scenarios across the user-facing flows:

- Editor opens from the Modifier Profile editor header
- Palette click adds primitive to tree
- Save button reflects validator state (disabled when name empty)
- Custom modifier appears in PerType kind dropdown
- Selecting a custom kind shows the summary card
- Library survives page reload
- Solo game with custom-kind profile renders modifier indicators
- Multi-profile stack: stack two profiles, remove an entry, reorder
- Aura primitive: page survives onAfterMove recompute
- Library cap holds at 20 entries

Plus 4 trigger primitive scenarios (formerly fixme, unblocked by the
trigger evaluator wiring committed alongside):
  - on-turn-start nested primitives fire at turn boundary
  - on-capture nested primitives fire on capture
  - conditional evaluates and runs matching branch
  - absorb-damage-with-attribute integrates with damage pipeline

2 fixmes remain — both blocked on the editor-side multiplayer send UI
(server + client wire-side is fully implemented in this commit).

Integration fixes uncovered while writing the suite:

1. ModifierKindIdSchema widened from z.enum([built-ins]) to z.string().min(1).
   The pre-T3 enum silently rejected every profile that referenced a
   custom modifier id (e.g. 'custom:my-shield'), causing library load
   to drop the entry and the picker to have no option. Validity is
   now enforced at apply time via the registry-dispatch fallback
   (MODIFIER_REGISTRY → engine.customModifiers → warn-and-skip).

2. Lobby.resetToFreshGame now passes loadCustomModifierLibrary()
   results to ChessEngine.opts.customModifiers so a profile that
   references a custom kind can resolve at apply time. Without this
   the apply silently no-opped the custom-kind entries and indicators
   never rendered.

Schema tests updated: the 'rejects unknown modifier kind' test flipped
to 'accepts arbitrary kind strings (T3 widening)' with explanatory
JSDoc; an empty-string-rejection test added to preserve the min(1)
guard.

77 e2e + 1386 unit tests green; 2 honest fixmes documented.
2026-04-19 21:37:23 -06:00
52752ecf33
docs(adr): T4 scripted modifiers forward-design
T3 Wave 5 (T32). New forward-design document at
docs/adr/T4-scripted-modifiers-design.md capturing where T4 would land
if/when it becomes a priority:

- Why T4 is deferred (security surface area)
- Sandbox candidates evaluated (QuickJS recommended; Duktape, vanilla
  WASM, custom interpreter, Web Workers, vm2 / isolated-vm rejected
  with rationale)
- Descriptor shape extension preserving T3 backwards compatibility
  via the type: 'data' | 'scripted' discriminator already reserved
  on CustomModifierDescriptor
- Permission model sketch (read/write self/board, history, effects,
  random — granted/prompt defaults per permission)
- Validation strategy (static analysis + runtime sandbox enforcement,
  whitelist over blacklist for forbidden globals, source/AST size
  caps, loop-bound checks)
- T3 → T4 ejection path (a T3 descriptor can generate equivalent
  scripted source as a starting point)
- 7 open questions blocking T4 kickoff (DSL surface, multiplayer
  determinism, editor experience, sharing trust, rate limiting,
  versioning, failure mode)
2026-04-19 21:09:52 -06:00
6dd5eb17ce
docs(user): custom modifier DSL user guide
T3 Wave 5 (T31). New user-facing guide at docs/user/custom-modifiers.md
covering:
- Opening the editor + 3-column workspace
- All 15 effect primitives (state / mechanic / advanced) with one
  example per primitive
- Composing primitives (simple boosted-pawn → medium shield → complex
  aura king)
- Saving + loading from the local library
- Using a custom modifier in a profile (panel kind dropdown)
- Multi-profile stacking (solo-only T3 limitation noted)
- Aura semantics (Chebyshev distance, recompute cadence)
- Limits and DoS guards (50 primitives, depth 3, 20 per library, 10
  per multiplayer room)
- T3 limitations called out inline (trigger primitives seed but don't
  yet fire; AuraContributions written but not yet consumed)
2026-04-19 21:09:24 -06:00
1e69675596
docs(adr): T3 implementation retrospective 2026-04-19 21:08:01 -06:00
dcd782fa5a
chore(sisyphus): mark T3 Wave 4 (UI) complete 2026-04-19 20:23:21 -06:00
31af101b55
feat(ui): multi-profile stacking in lobby
T3 Wave 4 (T27). Lobby now supports stacking multiple modifier
profiles for solo play. The primary picker stays single-select for
backwards compatibility with multiplayer create/join (which sends
exactly one ModifierProfile on the wire today); a 'Stacked (solo
only)' list below it lets the user append additional profiles, with
up/down reorder and remove buttons per entry.

State:
- additionalProfiles: ModifierProfile[] holds the stack-on-top
  entries. Empty by default; appears in the UI only when the primary
  picker has a selection.

resetToFreshGame branches:
- 0 or 1 profile total → unchanged single-profile fast path through
  EngineOptions.profile (preserves T1/T2 behaviour exactly).
- 2+ profiles → constructs a profile-less engine, then runs
  applyProfilesToSession across the full ordered stack so additive
  contributions compose correctly across profiles. Sets activeProfile
  to the LAST entry as a UI-display canonical (header badge etc.).

UI:
- Primary picker unchanged.
- profile-stack list with data-testids profile-stack, profile-stack-{i},
  profile-stack-{i}-up/down/remove for e2e access.
- profile-stack-add dropdown filters out already-selected ids.

Multiplayer wire shape unchanged — sending the stack to the server is
a future T27 extension that would touch the protocol.

E2E for the new stacking UI defers to T29; existing 9/9 solo-smoke
+ all 1378 unit tests pass.
2026-04-19 20:22:58 -06:00
9b586b83b5
feat(ui): custom modifiers in modifier profile panels
T3 Wave 4 (T26). PerTypePanel and PerInstancePanel kind dropdowns now
include the user's custom modifier library alongside built-ins.

- Kind dropdown uses <optgroup> separation: 'Built-in' (MODIFIER_REGISTRY)
  + 'Custom (from library)' (loadCustomModifierLibrary).
- Selecting a custom kind hides the per-instance value input and shows
  a small summary card ('Custom modifier — N primitives. Edit it in
  the Custom Modifier editor.') — the descriptor's primitive list IS
  the payload; per-instance value is null.
- Custom kinds skip Zod-schema-based validation (they have no
  per-instance value to validate); isValid is true once a custom
  kind is selected.
- handleSave / handleAdd branches on isCustomKind: built-in path
  preserves T1/T2 behaviour exactly; custom path emits {kind: id,
  pieceType, color, value: null}.
- PerInstancePanel mirrors the same pattern in AddModifierForm.

E2E tests for the new UI defer to T29; existing 1378 unit tests pass.
2026-04-19 20:20:03 -06:00
cbe4a4b5f6
feat(ui): custom modifier editor
T3 Wave 4 (T25). 3-column visual primitive composer for authoring
custom modifier descriptors from the 15 T3 effect primitives.

- Left palette: 15 primitives grouped by category (State / Mechanic /
  Advanced); click adds to the descriptor's primitive list.
- Center tree: shows current primitives[]; click selects, delete
  button per node.
- Right inspector: parameter form per selected primitive. Introspects
  the primitive's Zod schema to render typed inputs (number / string /
  boolean / enum / array). Falls back to a JSON textarea for complex
  param shapes (e.g. nested EffectPrimitiveNode arrays).
- Header: descriptor name/description inputs + Save/Load library +
  live validation status from validateCustomDescriptor.
- Open from ModifierProfileEditor's header via the new + Custom
  Modifier button (data-testid open-custom-modifier-editor).

Type widening: ModifierKindId gains '| (string & {})' so the kind
field on TypeModifier/InstanceModifier accepts custom descriptor ids
without losing literal-completion on built-in kinds. The
applyCollectedContributions dispatcher already handles arbitrary
strings via its custom-registry fallback (T22).

Lint cleanup: replaced 4 'as any' casts on Zod internals with named
ZodObjectInternal / ZodWrappedDefInternal / ZodEnumDefInternal
structural shapes — auditable in one place if Zod renames _def.

E2E + extended tests deferred to T29; T26 (panel kind-dropdown
extension) and T27 (multi-profile lobby) shipped separately.
2026-04-19 20:17:33 -06:00
28b11f342d
chore(sisyphus): mark T24 + T28 complete 2026-04-19 20:06:24 -06:00
9441570349
feat(engine): aura effect computation + onAfterMove hook
T3 Wave 4 (T28). Wires the add-aura primitive's seeded AuraSpec facts
into a runtime recomputation that produces AuraContributions on
affected pieces every move.

- computeAuraFacts(session):
    1. Retracts every AuraContributions fact (clean slate).
    2. Walks every piece with an AuraSpec list and, for each aura,
       finds all in-range pieces via Chebyshev (king-move) distance
       and accumulates deltas per (targetId, targetAttr).
    3. Commits the staging map as AuraContributions = { attr → delta }
       on each affected piece. Empty maps are NOT written, so
       unaffected pieces return undefined for session.get(id, 'AuraContributions').
- New ChessAttrMap entry: AuraContributions = Readonly<Record<string, number>>.
- __modifier-profile-integration__ preset gains an onAfterMove hook
  that calls computeAuraFacts(session) after every successful move.
  Self-application is skipped; source moving out of range retracts
  contribution on next recompute.

9 vitest scenarios: neighbour coverage, out-of-range exclusion,
multi-source accumulation, self-application skip, stale retraction
on source move, idempotency, multi-attr per source, empty-state
no-op, and the engine end-to-end wiring via onAfterMove.

Consumer wiring (HpBonus + AuraContributions[HpBonus] compose at
effective-attr read points) is deferred — this task delivers the
infrastructure and the recompute cadence; downstream readers
integrate on an as-needed basis.
2026-04-19 20:05:38 -06:00
109be25be6
feat(server): custom-modifier.register WS handler
T3 Wave 4 (T24). Adds wire protocol + server handler for registering
user-authored custom modifier descriptors on a room's per-engine
custom registry (ADR-4).

Protocol:
- CustomModifierDescriptorSchema (Zod v3 mirror of the chess-side
  schema; structural validation only — primitive-kind semantics stay
  client-side to avoid duplicating the primitive catalog across the
  zod v3/v4 boundary).
- 'custom-modifier.register' client message carrying { roomCode,
  descriptor }.
- 'custom-modifier.registered' server broadcast mirroring the same
  shape out to every connected client in the room.
- New error codes: CUSTOM_MODIFIER_INVALID, CUSTOM_MODIFIER_LIMIT.

Handler:
- Auth + room-match checks matching the rest of the WS surface.
- Lazy per-room Map<id, CustomModifierDescriptorWire> created on first
  register.
- Capacity cap at 10 distinct ids per room (T3 DoS guard). Re-register
  with the same id REPLACES without consuming a new slot.
- On success: broadcasts to every connected client (proposer included
  — confirms server-authoritative state).

6 integration tests (mock ServerWebSocket harness; same pattern as
ws.modifier-profile-update.test.ts): happy-path accept+broadcast,
same-id replace, 11th-distinct rejection, roomCode mismatch, no-auth
rejection, malformed descriptor (schema layer). All green; 1369 unit
tests pass. Engine-side wire-up (late-joiner mirroring, client client.ts
subscriber) deferred to T25/T26 UI work.
2026-04-19 20:01:11 -06:00
2b641c78bb
chore(sisyphus): mark T3 Wave 3 (validator/schema/library/apply/stacking) complete 2026-04-19 18:12:43 -06:00
1d5efaa95f
feat(engine): apply custom modifier descriptors + multi-profile stacking
T3 Wave 3 (T22 + T23). Two tightly-coupled deliverables landed in one
commit because the second's API surface depends on the first's signature
extensions:

T22 — Custom descriptor application
- new CustomModifierRegistry (per-engine, in custom/registry.ts) — ADR-4
  isolation: descriptors registered on engineA never leak to engineB.
- new applyCustomDescriptor(engine, session, pieceId, descriptor) walks
  primitive nodes, dispatches each kind through PRIMITIVE_REGISTRY,
  and recurses into nested children via childPrimitives() with depth
  tracking (mirrors T19's static depth guard at runtime).
- ChessEngine gains a customModifiers field + opts.customModifiers in
  EngineOptions for bootstrap registration.
- applyProfileToSession's signature widens to accept (..., engine?,
  customRegistry?) — when a profile entry's kind misses MODIFIER_REGISTRY,
  the custom registry is consulted as a fallback. Existing T1/T2
  callers stay source-compatible (the new params are optional).

T23 — Multi-profile stacking
- collectProfileContributions: pure value-collection helper extracted
  from applyProfileToSession's body.
- new applyProfilesToSession(session, profiles[], layout, engine?,
  customRegistry?) iterates the helper across every profile in order
  before stacking — built-in stacking rules apply across the union.
- new reconcileProfilesSwap mirrors the same generalization for the
  retract-then-reapply hot-swap path.
- single-profile applyProfileToSession / reconcileProfileSwap remain as
  thin wrappers calling the array versions with [profile].

14 vitest scenarios cover: single-primitive apply, multi-primitive
apply, nested-children walk via on-turn-start, unknown-kind tolerance,
custom-registry fallback in applyProfileToSession, per-engine isolation,
constructor pre-registration, two-profile additive stacking, mixed
built-in + custom across profiles, single-profile passthrough, empty
array no-op, and CustomModifierRegistry CRUD.

Engine wiring (damage pipeline, turn-start hooks, aura recompute) is
deferred to T28 — primitives currently SEED facts that those wires
will observe.
2026-04-19 18:12:19 -06:00
795207e8b4
feat(engine): custom modifier library persistence
T3 Wave 3 (T21). localStorage-backed library mirroring the T1 modifier-
profile library at modifiers/library.ts:

- Storage key: houserules:custom-modifiers:v1
- Capacity: MAX_ENTRIES (20) with starred-aware FIFO eviction; refusal
  with a human reason when every slot is starred.
- API: loadCustomModifierLibrary, saveToCustomModifierLibrary (runs
  T19's validateCustomDescriptor before persisting; rejects with the
  validator's error list on failure), removeFromCustomModifierLibrary,
  setCustomModifierStarred.
- Auto-populates descriptor.createdAt on first save; preserves it
  across subsequent saves so library order stays stable.
- Per-entry shape failures during load are silently dropped (matches
  T1 library behaviour — one bad row never blocks the drawer).

12 vitest scenarios cover round-trip, malformed-JSON tolerance,
update/remove/star, validator rejection, capacity eviction, and
all-starred refusal.
2026-04-19 18:02:35 -06:00
b35d758c57
feat(engine): custom modifier Zod schema
T3 Wave 3 (T20). Structural Zod schema for CustomModifierDescriptor.

- EffectPrimitiveNodeSchema: recursive via z.lazy(); kind is z.string()
  + min(1) (semantic check belongs to T19's validator); params is
  z.unknown() so nested trees pass through cleanly.
- CustomModifierDescriptorSchema: literal discriminators on
  type/version/uiForm/source; min/max bounds on name/description.
- parse/safeParse/serialize wrappers; parse() carries a single boundary
  cast to bridge Zod's structural inference (kind: string,
  optional: T | undefined) with the typed interface (kind: PrimitiveKind,
  optional: ?). Documented in JSDoc; T19 enforces every narrowing
  separately.
2026-04-19 17:59:45 -06:00
8b9d3a7a4c
feat(engine): custom modifier descriptor validator
T3 Wave 3 (T19). Walks a CustomModifierDescriptor and emits a list of
ValidationErrors covering:

- descriptor.id non-empty
- descriptor.name length 1-40, description length 0-200
- descriptor.version === 1, type === 'data'
- every primitive node's kind is in PRIMITIVE_REGISTRY
- every node's params satisfies its primitive's paramsSchema (Zod
  safeParse, all errors collected — never bails on first)
- recursion depth <= 3 across nodes that declare childPrimitives()
- total primitive count <= 50 (single error emitted, walk continues)
- structural circularity guard (visited-set + active-stack tracking)
  for any future params shape that could embed object references
- self-reference guard (params containing 'id' === descriptor.id)

Returns { ok: true } on clean walk, { ok: false, errors: [] } otherwise.
Each error carries a stable code, JSON-pointer-style path, and a human
message — designed for editor inline feedback (T25).
2026-04-19 17:56:09 -06:00
8a7c1b3f54
chore(sisyphus): mark T3 Wave 2 (15 primitives) complete + notepad updates 2026-04-19 17:42:32 -06:00
ce49b55a60
feat(engine): advanced effect primitives (aura/triggers/conditional)
T3 Wave 2 batch C. Implements 5 advanced primitives — auras and event
triggers — that nest other primitives via childPrimitives() so the
T19 validator can walk the tree for depth/count enforcement:

- add-aura: leaf primitive seeding AuraSpec[] entries
  ({radius:1-7, targetAttr, delta}). T28 wires the per-move recompute.
- on-turn-start: nesting primitive seeding OnTurnStartHooks (an array of
  primitive lists). childPrimitives() returns the inner list.
- on-capture: same pattern with OnCaptureHooks.
- on-damaged: same pattern with OnDamagedHooks.
- conditional: discriminated-union ConditionSpec (attr-lt/gt/eq, always,
  never), then[], optional else[]. childPrimitives() returns then+else
  combined so the validator can count both branches.

ChessAttrMap gains AuraSpec, OnTurnStartHooks, OnCaptureHooks,
OnDamagedHooks, ConditionalHooks; ConditionSpec is a public discriminated
union. Engine integration (trigger evaluation, aura recompute) deferred
to T22/T28.

The primitives/index.ts barrel now imports all 15 T3 primitives in
batch order (state → mechanic → advanced).
2026-04-19 17:42:09 -06:00
b93b4b7322
feat(engine): mechanic effect primitives (absorb/reflect/range/block/promotion)
T3 Wave 2 batch B. Implements 5 game-mechanic primitives that seed
specs the engine's existing pipelines (T22 will wire) consume:

- absorb-damage-with-attribute: seeds {AbsorbDamageAttr, AbsorbDamageRate}.
- reflect-damage: seeds ReflectDamagePercent (0-100, validated).
- modify-movement-range: composes with T1's range-bonus by reading
  existing RangeBonus (default 0) and writing existing+delta.
- block-move-type: appends a move-type filter into BlockedMoveTypes.
- override-promotion: writes the canonical PromotionOverride (mirrors
  T1's promotion-override descriptor exactly).

ChessAttrMap gains AbsorbDamageAttr, AbsorbDamageRate, ReflectDamagePercent,
and BlockedMoveTypes for the new fact namespaces. Engine integration
deferred to T22; this batch is data-seeding only.

All 5 register in PRIMITIVE_REGISTRY via side-effect import. Each
primitive ships with ≥3 vitest scenarios.
2026-04-19 17:41:44 -06:00
d17f4bd1fd
feat(engine): state effect primitives (seed/add/multiply/direction/capture-flag)
T3 Wave 2 batch A. Implements 5 state-mutating primitives that compose
to seed and adjust EAV facts on a piece during profile apply():

- seed-attribute: insert {attr,value} (overwrites existing).
- add-to-attribute: read existing number (or 0 if absent), write +delta.
- multiply-attribute: read existing number (no-op if absent), write *factor.
- add-direction: append named directions (forward/backward/...) into the
  T1-shared DirectionAdditions string[] with dedupe. Composes additively
  with the T1 direction-additions descriptor's writes — the engine's
  existing generateDirectionMoves walker handles both contributions.
- set-capture-flag: OR a CaptureFlag bitflag into the existing CaptureFlags
  field (idempotent).

All 5 register in PRIMITIVE_REGISTRY via side-effect import. Each primitive
ships with ≥3 vitest scenarios. The barrel (primitives/index.ts) imports
all 5 in registration-order.
2026-04-19 17:41:22 -06:00
2c36925d0b
feat(engine): custom modifier descriptor types
T3 Wave 1 (T3). Defines the user-authored CustomModifierDescriptor
shape that Wave 3 (validator, Zod schema, library, apply) and Wave 4
(server registration, editor UI) build on.

- CustomModifierId: branded string (mirrors asEntityId), with the
  asCustomModifierId() trust-boundary helper.
- CustomModifierDescriptor:
  - type: 'data' discriminator (T4 will add 'scripted' alongside).
  - id, name (1-40), description (0-200), version: 1 literal.
  - primitives: readonly EffectPrimitiveNode[] (re-exported from
    primitives/types so consumers have one import).
  - targetAttrs: readonly ChessAttrKey[] for editor conflict surfacing.
  - uiForm: 'primitive-composer' literal (routes editing to the
    custom-modifier composer UI in T25).
  - source: 'custom' for library typing.
  - Optional author, createdAt (auto-populated by library save).

Persistence, validation, Zod schema, and apply() arrive in Wave 3.
2026-04-19 17:24:28 -06:00
2e655a0c1a
feat(engine): primitive types and registry
T3 Wave 1 (T2). Lays the type-system foundation that the 15 effect
primitives in Wave 2 will conform to.

- PrimitiveKind: discriminated literal of all 15 T3 primitive ids (ADR-2).
- EffectPrimitive<Params>: descriptor contract with paramsSchema (Zod),
  apply(ctx, params), and optional childPrimitives() for nested-tree
  walking by the validator.
- EffectPrimitiveNode: runtime instance shape — kind + opaque params.
- PrimitiveApplyContext: { engine, session, pieceId, depth, descriptor } —
  depth threads through for the recursion cap (ADR-3).
- CustomModifierDescriptorRef: forward-declared trunk so primitives
  doesn't import custom (one-way import graph; full descriptor lives in
  custom/types.ts).
- PrimitiveRegistryClass + PRIMITIVE_REGISTRY singleton mirroring the
  MODIFIER_REGISTRY pattern (Map<kind, descriptor>, throws on duplicate,
  list() preserves registration order).

Side-effect registration of individual primitives lands in Wave 2.
2026-04-19 17:24:07 -06:00
60b89d8c5e
docs(adr): T3 custom modifier DSL architecture decisions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-19 17:16:17 -06:00
6e0479703d
fix(lobby): clear stale MP creds on Play Solo to avoid blank-screen trap
If the user played multiplayer earlier in the tab session, room-code,
room-token, and player-color persisted in sessionStorage. Clicking Play
Solo then:

  1. navigate('/game')  — no code param
  2. GameRoute reads sessionStorage, finds stale creds → Case 1
     canonicalises the URL to /game/<stale-code>
  3. MultiplayerGameView mounts, opens a WS to a dead room, handshake
     fails silently → blank white screen with a live URL like
     /game/OSJBJY in the address bar.

Fix: handlePlaySolo explicitly wipes room-code, room-token, player-color,
layout-name, and modifier-profile-name before navigating. The solo path
then goes through GameRoute's Case 2 (no code, no creds) and mounts
GameView cleanly.

Regression test in solo-smoke.spec.ts seeds sessionStorage with stale
MP creds, clicks Play Solo, and asserts:
  - URL settles on /game (not /game/<stale>)
  - No 'mp-joining' placeholder
  - Board renders (e2 pawn visible)
  - All stale keys are wiped from sessionStorage
  - No console errors

Verified the test fails without the fix (Playwright hits the blank
screen / Joining placeholder) and passes with it.
2026-04-19 17:03:21 -06:00
7bee3cbaa9
feat(ui): hide modifier tooltip on pieces with no active modifiers
The hover tooltip previously rendered on every piece regardless of
whether it had any modifier facts, showing just a piece-type header and
'No active modifiers' — noise with zero information the user can't
already see on the board.

Now returns null when there are no modifier rows. The pinned panel
(click-to-pin) keeps its empty-state copy because an explicit pin is a
deliberate inspect action where confirming 'nothing here' is valid.

Tests:
- Inverted the two T24 hover tests to assert the tooltip does NOT render
  on unmodified pieces (b1 knight, e2 pawn on a vanilla solo game).
- Added a positive test: hover a modified pawn (HP +1 from a seeded
  profile) and assert the tooltip + at least one row are visible.
2026-04-19 16:58:04 -06:00
ce6b2c1816
style(ui): floor editor modal height at 85vh so empty state isn't flat
Both ModifierProfileEditor and LayoutEditor already capped at max-h-[95vh]
but had no min-height, so they collapsed to just their content when empty
(no modifiers yet, no profiles saved). That looks like a flat toolbar
strip floating over the board rather than a proper editor dialog.

Add min-h-[85vh] to both so the dialog commits to a reasonable stage
regardless of content, and the user immediately understands it's a
full editor modal.
2026-04-19 16:52:08 -06:00
2643222373
refactor(rete): use asEntityId for AGG_FACT sentinel id
Drop the `-1 as unknown as EntityId` double-cast in favour of the
asEntityId() helper exported alongside the type. Single-site, documented
cast instead of an inline double-cast.

Completes the cleanup of `as unknown as` in non-test source across both
packages/chess and packages/rete.
2026-04-19 16:49:50 -06:00
a39b9921c6
refactor(net): type listener table as mapped type, drop double-casts
The listener map was typed as Map<GameClientEventType, AnyListener[]>, which
erased the per-type Listener<T> relationship and forced `as unknown as
AnyListener` double-casts at every on()/off()/emit() site.

Replace with a mapped-type record `{ [T in GameClientEventType]?: Listener<T>[] }`.
TS index lookup preserves the per-key relationship, so:

- off() has no cast
- emit() becomes generic over T and dispatches without casts
- on() retains a single scoped `Record<T, …>` projection at the write site
  (TS can't prove writes to a mapped-type index are safe under a generic T;
  this is a known limitation and the smallest workaround)

Also drops the now-unused LifecycleConnected/LifecycleDisconnected interfaces
(they existed only as emit() overload signatures, no longer needed with the
generic emit).
2026-04-19 16:48:35 -06:00
00533167b4
refactor(engine): drop pointless FactValue double-cast in PresetState.set
FactValue is `unknown` in @paratype/rete, so `value as unknown as FactValue`
was `unknown` → `unknown` → `unknown` — zero type narrowing, just noise.
The stale comment also claimed FactValue was `string | number | boolean | null`
(it isn't, and hasn't been for a while).

Pass value directly; update the comment to reflect actual serialization
responsibility (caller ensures JSON-round-trippable for event-log replay).
2026-04-19 16:43:41 -06:00
396051f5c0
test(e2e): add solo modifier indicator smoke guard
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-19 14:14:18 -06:00
3d49cd2792
refactor(modifiers): use baseAttr for preset source mapping
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-19 14:02:32 -06:00
c74a1fca00
refactor(modifiers): remove double-casts in registry and schema
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-19 14:00:10 -06:00
9960ea96cf
refactor(ui): use asEntityId helper at piece-id boundaries
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-19 13:53:45 -06:00
567480a788
test(e2e): unblock the 3 fixme tests in modifier-profiles spec
P6 (source chain in pinned panel):
  Required two server-side changes to make the badge actually meaningful:
  - Add `profile` field to GameStatePayload schema (server emits it,
    client receives it) so multiplayer clients see the room's active
    profile metadata, not just the modifier facts.
  - Make `ChessEngine.activeProfile` mutable via `setActiveProfile()`
    so PredictionManager can sync it from `game.state` snapshots.
  Also wire `modifier-profile.updated` through GameClient + Prediction-
  Manager so hot-swap broadcasts update the engine's profile field
  reactively.
  Fix Lobby.handlePlaySolo's resetToFreshGame to forward the selected
  profile to the new ChessEngine — otherwise the local engine had
  modifier facts (via server reconcile) but no profile metadata,
  breaking source-chain attribution and any other profile-aware UI.

P7 (multiplayer propose → approve → both observe updated):
P8 (multiplayer propose → reject → no updated broadcast):
  Implemented at the WS-protocol level using two parallel raw sockets
  per test (mirrors multiplayer.spec.ts pattern). Critical sequencing:
  - Both sockets opened concurrently via Promise.all so opponent is
    listening BEFORE host's propose arrives at the server (otherwise
    proposal-pending broadcasts to nobody and the test deadlocks).
  - Token must travel at the envelope level, not in payload, for the
    server's reconnect-by-token path to fire (otherwise hits ROOM_FULL
    on the second connection from each player).
  - game.move payload uses algebraic notation strings ('a2', 'a3'), not
    square indices — the protocol schema only accepts strings.
  - Host re-uses original room.create token, opponent re-uses their
    join token. Server's reconnectManager treats both as grace-window
    reconnects since the original WS closed cleanly.

Verification:
  - 1231 unit tests pass (96 files)
  - 58/58 Playwright tests pass in 1.9 min (was 55 + 3 fixme)
  - Total Playwright surface coverage: solo-smoke (7) + multiplayer (2) +
    full-flow (1) + layouts (24) + modifier-profiles (24 — including all
    8 T2-polish tests, 0 fixme).
2026-04-19 13:15:15 -06:00
748dde5d4c
chore: ignore .org.chromium.Chromium.* runtime files 2026-04-19 10:20:42 -06:00
646b16a8c0
chore(sisyphus): complete modifier-profiles-t2 boulder 2026-04-19 10:20:26 -06:00
0987adbff3
fix(ui): render computed source badge in pinned modifier panel 2026-04-19 10:15:21 -06:00
92dae32f31
test(e2e): T2 polish vertical slice + solo regression guards
Adds 8 Playwright scenarios to modifier-profiles.spec.ts under a new
'T2 polish' describe block:

  P1  editor undo/redo across 3 distinct type-modifier adds
  P2  copy / paste wire: Copy lights the Paste button with a count
  P3  paste-type-modifier disabled when clipboard empty (baseline)
  P4  conflict panel: seed an invuln-king profile via localStorage,
      bind layout=classic, Load, observe error + Fix clears it
  P5  modifier-indicator rendered without hover (create-room path,
      with the same no-WS-server test.skip fallback T26 uses)
  P6  source-chain in pinned panel — test.fixme; ModifierPinnedPanel
      computes row.source but does not render it yet
  P7  multiplayer propose->approve e2e — test.fixme; needs a
      two-context harness this spec doesn't have today. Protocol
      coverage lives at packages/server/src/ws.modifier-profile-
      consent.test.ts.
  P8  multiplayer propose->reject e2e — same harness gap as P7.

Adds 2 regression tests to solo-smoke.spec.ts:

  - Rules drawer: clicking the backdrop (far-left of viewport)
    closes the drawer and leaves the board interactive. Regression
    guard for the stuck-overlay pointer-events bug.
  - Modifier editor: Esc closes the editor but leaves the drawer
    open (capture-phase stopImmediatePropagation); a second Esc
    then closes the drawer. Documents the nested-Esc ordering
    contract and guards against a future change that would cascade
    both closes on one keystroke.

Result: 55 Playwright passing, 3 skipped (all documented fixme).
bun run check green.
2026-04-19 10:13:57 -06:00
8f5dca9c21
feat(ui): consent dialog for modifier profile proposals 2026-04-19 09:43:12 -06:00
ebed10d39a
docs(user): T2 modifier profile features
- Hot-Swap rewritten for solo vs multiplayer: propose/consent with
  60s window, turn-boundary semantics, last-write-wins on rapid
  proposals. Drops the T1 host-only caveat.
- New Editor Features section: undo/redo (Cmd/Ctrl+Z, 50-deep,
  cleared on save/cancel), per-instance and per-type copy/paste
  (editor-local clipboard), conflict resolution panel with Fix
  buttons plus the manual-only cases.
- New Board Indicators section: fuchsia dot on modified pieces,
  updates across hot-swaps.
- In-Play Inspection expanded with the enhanced source chain
  (per-instance / per-type / preset / default) and the combine
  semantics (HP additive, resistance multiplicative, directions
  unioned).
- Known Limitations: drop the T1 host-only bullet; add a Coming
  in T3 subsection (custom authoring, auras, multi-profile
  stacking).
2026-04-19 09:29:56 -06:00
a27cb29a5b
docs(adr): T2 implementation retrospective 2026-04-19 09:29:07 -06:00
2a04ae513c
test(server): two-player consent flow (T3)
Seven scenarios covering the propose/consent state machine:
propose -> proposal-pending + queued ack; approve -> consent-
received + T2 queue; reject -> rejected(rejected); 60s timeout
with vi.useFakeTimers -> rejected(timeout); self-consent blocked;
supersession preserves wire ordering
(rejected(superseded) before new proposal-pending); solo-mode
propose directs caller back to update.
2026-04-19 09:25:48 -06:00
929ee6da81
feat(server): handlers for two-player profile consent (T3)
Adds handleModifierProfilePropose and handleModifierProfileConsent
per T2-ADR-2. Propose requires 2 filled player slots; either player
may propose. Supersedes any prior pending proposal (old gets
modifier-profile.rejected reason="superseded"). 60s timeout auto-
rejects with reason="timeout". Approve promotes the candidate into
the existing T2 queue via setPendingProfile; reject broadcasts
rejected to both. Self-consent blocked.

modifier-profile.update (host-unilateral T2 path) remains valid in
all room configurations as an administrative shortcut and the solo-
mode entrypoint.
2026-04-19 09:23:40 -06:00
980d567354
feat(ui): enhanced modifier source chain in pinned panel 2026-04-19 09:22:49 -06:00
ca9072ce48
feat(chess): mirror consent-flow wire types on client side
Mirrors the 5 new message shapes added to server/src/protocol.ts
(T2-ADR-2): propose, proposal-pending, consent, rejected,
consent-received. Kept as independent interfaces to avoid
importing server types (direction: chess \u2190 server is forbidden).
Structural parity maintained by hand \u2014 any drift surfaces as
a typecheck error in net/client.ts when it starts emitting the
new messages.
2026-04-19 09:19:49 -06:00
2a903f8bd6
feat(server): protocol schemas for two-player consent (T3)
Adds 5 new wire messages (T2-ADR-2):
- client\u2192server: modifier-profile.propose, modifier-profile.consent
- server\u2192client: modifier-profile.proposal-pending,
  modifier-profile.rejected, modifier-profile.consent-received

All additive \u2014 no existing message shape changes. Wired into
ClientMessageSchema, ServerMessageSchema, AnyMessageSchema, and
KNOWN_MESSAGE_TYPES. Handlers follow in the next commit.
2026-04-19 09:18:52 -06:00
9af78ab5e2
feat(server): Room.proposalState scaffolding for T3 consent flow
Adds the optional `proposalState` field on `Room` holding the
in-flight two-player consent proposal per T2-ADR-2. Includes
profile, proposer color + token, timestamps, and the active
setTimeout handle so supersession / consent can cancel it cleanly.

Pure type-only addition \u2014 no runtime behavior change; handlers
land in the next commit.
2026-04-19 09:17:22 -06:00
d555232696
feat(ui): modified-piece indicator on board 2026-04-19 09:16:07 -06:00
0bd65e0a73
feat(server): turn-boundary queue for modifier profile updates
Replace T1 immediate-apply semantics with a single-slot pending
queue (T2-ADR-1). On `modifier-profile.update` receipt the server
validates shape + layout legality, stashes the profile on
`Room.pendingProfile` with the proposer's token, and acks the
sender with a new `modifier-profile.queued` message. The actual
`reconcileProfileSwap` + version bump + `modifier-profile.updated`
broadcast now runs in `applyPendingProfileIfAny` after the next
successful `applyMove` — either player's move triggers it.

- `Room` gains `pendingProfile` and `pendingProposerToken`
  (token-keyed for reconnect-safe NACK routing).
- `game-session.ts` exposes `setPendingProfile`,
  `applyPendingProfile`, `clearPendingProfile`. Apply re-runs
  `validateProfile` as defence in depth; rejections clear the
  slot and surface the validator error code.
- New `modifier-profile.queued` wire schema (server\u2192client ack
  carrying the expected post-apply version).
- Last-write-wins: a second update overwrites the pending slot
  because the server's `profileVersion` only bumps on apply, so
  the second request legitimately carries the same version.
- Existing early-rejection paths (non-host, stale version,
  invalid profile) remain unchanged.

Tests updated: 7 scenarios covering queued ACK, deferred apply,
last-write-wins, opponent-move-drains-queue, and all original
rejection paths. 1220 unit tests + 18 modifier Playwright tests
green (e2e specs never used `modifier-profile.update` at
runtime so were unaffected).
2026-04-19 09:06:15 -06:00
37e485537d
feat(ui): modifier editor undo/redo + conflict resolution panel + copy/paste modifiers 2026-04-19 08:55:48 -06:00
3557aa7cb4
docs(adr): T2 polish architecture decisions 2026-04-19 08:43:40 -06:00
728ad76a5e
fix(ui): rules drawer Esc handling — unblock board after drawer close
The T1 ModifierProfileEditor installed a window-level Esc handler that
closed the modal but the RulesDrawer had no Esc handler of its own.
Users hitting Esc with the drawer open (no modal) saw nothing happen;
worse, with both open+modal, closing the modal left the drawer's
pointer-events-blocking backdrop in place, silently breaking all
board drag-interaction afterward.

Fix:
- Add useEffect-based Esc handler to RulesDrawer that closes it when
  no nested modal is active.
- ModifierProfileEditor now uses capture-phase + stopImmediatePropagation
  so the drawer's Esc handler does NOT also fire on the same keystroke,
  preventing double-close.

Add packages/chess/e2e/solo-smoke.spec.ts — 5 regression scenarios
that would have caught this at T1 CI time. Test 4 specifically
reproduces the original bug (drawer open → Esc → drag board pieces).

Also queue 2 additional scenarios in the T2 plan since T2 work extends
both drawer + editor further.

All tests green: 1217 unit tests (94 files), 48 Playwright e2e in 1.3m.
2026-04-19 08:38:05 -06:00
8ae934f563
chore: remove Playwright cache dirs from tracking, ignore them 2026-04-19 08:26:15 -06:00
0aced40118
chore(sisyphus): complete piece-modifiers boulder 2026-04-19 08:25:35 -06:00
c292695309
test(e2e): modifier profiles full vertical slice 2026-04-18 23:33:42 -06:00
278370a630
feat(ui): pinned modifier inspection panel
- Create ModifierPinnedPanel.tsx: fixed-position side panel with piece
  header, modifier list (label + describe() value), and close button
- Board.tsx: add onPieceClick prop, fire on piece click (distinct from drag)
- GameView.tsx: add pinnedPieceId state; clicking a piece toggles pin;
  clicking same piece again or × closes panel; panel renders fixed right-4
- 2 new e2e tests: click b1 pins panel with 'knight' text; × dismisses it
2026-04-18 23:28:33 -06:00
cfc68bba51
docs(user): modifier profiles user guide 2026-04-18 23:23:09 -06:00
5f252e2dff
feat(ui): hover modifier tooltip
Adds ModifierTooltip component that reads MODIFIER_REGISTRY attrs from
engine.session for the hovered piece and renders them as labelled rows.
The tooltip always appears on piece hover (piece type + color header) and
shows modifier rows only when modifier facts are set on the entity.

Board.tsx gains an optional onPieceHover callback; GameView.tsx tracks
hoveredPieceId and renders the tooltip absolutely in the board wrapper.
A 120ms hide-delay prevents flicker when cursor briefly leaves a piece.

Two Playwright tests added: hover shows tooltip with piece name; hover
over unmodified piece shows zero modifier-tooltip-row elements.
2026-04-18 23:21:05 -06:00
fa1ef765f8
feat(server): modifier-profile.update WS handler 2026-04-18 23:11:21 -06:00
cc0b7b0446
feat(ui): lobby profile picker integration
Adds a modifier profile picker next to the layout picker in the Lobby, and a header badge in GameView that surfaces the active profile's name.

Lobby:

- New <select data-testid="profile-picker"> loads entries from loadLibrary() on mount and refreshes when the ModifierProfileEditor closes (auto-selecting the most recently updated entry).

- Selecting a saved profile sets the active ModifierProfile; selecting 'Custom…' opens the existing editor modal.

- URL param ?modifierProfile=<b64> decodes + pre-selects even when the profile isn't in the local library, via a synthetic '<name> (from link)' option so the <select> can reflect the choice without collapsing it.

- handleCreate now sends payload.profile when a profile is selected and stashes modifier-profile-name in sessionStorage.

- handleJoin reads profile from the server's room.joined echo so late joiners see the badge on first paint.

GameView:

- New ModifierProfileBadge component mirrors LayoutBadge but reads modifier-profile-name from sessionStorage and uses fuchsia tones so it's visually distinct when both badges are present.

lobby-request.ts:

- OneShotRoomResult exposes the optional profile field the server now echoes (T19).

E2E:

- 2 new Playwright tests: 'create room with profile — badge shows in game' seeds the library via localStorage, selects the profile, creates the room, and asserts the badge text. 'URL pre-select loads profile in picker' base64-encodes a profile into ?modifierProfile= and verifies the picker shows the correct value + 'from link' synthetic label.

All 8 modifier-profiles e2e tests pass; bun run check green (1213/1213 unit tests).
2026-04-18 23:11:11 -06:00
be7a3ea57b
chore: resolve stash merge conflicts + stage sliding.ts range-bonus changes 2026-04-18 22:59:03 -06:00
100bf5c909
feat(ui): per-type modifier panel
Adds PerTypePanel component (left panel of ModifierProfileEditor).

- Lists existing TypeModifier rows with piece type, color, and described
  value; each row has a delete button.
- Inline add form: piece type, color, and modifier kind selects + a
  uiForm-driven value input (number, percentage, promotion-target,
  or placeholders for direction-set/capture-flags).
- Save button disabled via Zod schema.safeParse — invalid values (e.g.
  range-bonus=100 > max 7) cannot be submitted.
- Wired into ModifierProfileEditor left panel via perType state.
- 2 new Playwright tests: add-modifier row appears, invalid value disables save.
2026-04-18 22:57:41 -06:00
28e03d06fa
feat(ui): per-instance modifier panel
- Export LayoutBoardView from LayoutEditor.tsx (reusable board display)
- Create PerInstancePanel.tsx: 8x8 board + per-square modifier list/form
- Wire PerInstancePanel into ModifierProfileEditor center panel
- Add bound-layout-picker select in editor header (uses LAYOUT_REGISTRY)
- 2 new e2e scenarios: no-layout prompt, attach modifier to b1 square
2026-04-18 22:56:31 -06:00
4c8e8467b7
feat(server): room-create accepts profile
Wires the T17 protocol's optional modifier profile through the server's room-create flow:

- rooms.Room gains an optional profile field; RoomRegistry.createRoom/joinRoom accept and echo it.

- GameSession constructor + GameSessionRegistry.create pass the profile through to ChessEngine's EngineOptions so modifier facts get seeded and the integration preset auto-activates at game start.

- broadcast.handleRoomCreate validates an inline profile via chess's validateProfile against the resolved layout, mapping validator codes (E_PROFILE_*) onto the wire protocol's MODIFIER_PROFILE_* family. Invalid profiles produce a non-fatal error and leave room / session state untouched; the creator is not bound.

- room.created and room.joined echo the active profile when present, so late joiners render piece modifier badges on first paint.

- RoomCreatedPayloadSchema and RoomJoinedPayloadSchema gain matching optional profile fields.

- @paratype/chess barrel: re-exports CaptureFlag + validateProfile + ModifierValidation* types so the server can consume them without reaching into internals.

Tests: 5 new cases (happy path, join echo, backward compat, INVULN_KING rejection, layout-invalid precedence). Full check green (1213/1213).
2026-04-18 22:55:24 -06:00
c34c11af92
feat(engine): hot-swap reconciliation 2026-04-18 22:48:31 -06:00
4b7d943edc
feat(server): modifier profile protocol schemas + error codes
Adds wire schemas and error codes for the modifier-profile feature:

- ModifierProfileSchema mirrored in server/protocol.ts (server pins a different zod major, so the chess-side schema cannot be re-exported directly). A keyof parity check guards against drift.

- RoomCreatePayloadSchema gains an optional 'profile' field — additive, existing callers unaffected.

- modifier-profile.update (client->server) payload schema with roomCode, newProfile, and version (for optimistic-concurrency checks).

- modifier-profile.updated (server->client) broadcast payload interface, typed for future promotion to the discriminated union when the broadcast is wired through rooms.

- Four new error codes: MODIFIER_PROFILE_INVALID / NO_KING / INVULN_KING / DEADLOCK, exported as const literals alongside the enum.

- Client wire types (packages/chess/src/net/types.ts) mirror all of the above: ModifierProfileWire shape, optional 'profile' on Room{Create,Created,Joined} payloads, and the new modifier-profile.* client/server message envelopes.

- PROTOCOL.md documents the new request/response flow and extends the error-code table.

Tests: 20 new cases across ModifierProfileSchema, RoomCreate profile integration, ModifierProfileUpdatePayloadSchema, and error-code acceptance.
2026-04-18 22:43:58 -06:00
0e9809007d
feat(engine): apply profile at game start 2026-04-18 22:42:27 -06:00
e23e69e0d0
feat(ui): modifier profile editor shell + rules drawer entry
Adds ModifierProfileEditor modal shell with 3 placeholder panels
(T21 catalog / T22 board preview / T23 profile list). Esc closes
the modal via a window keydown listener active only while isOpen.

Wires a 'Modifier Profiles' button into the RulesDrawer footer that
opens the editor. Adds e2e/modifier-profiles.spec.ts with 2 tests:
open-from-drawer and esc-to-close.
2026-04-18 22:35:06 -06:00
29a5ecfd3f
feat(engine): profile legality validator
- validateProfile(profile, layout) checks 4 rules:
  - E_PROFILE_NO_KING: layout must have ≥1 king per color
  - E_PROFILE_INVULN_KING: king cannot have CANNOT_BE_CAPTURED flag (per-type and per-instance)
  - E_PROFILE_ORPHAN_INSTANCE (warning): per-instance entry targeting empty square
  - E_PROFILE_ATTR_LIMIT: >12 distinct modifier kinds on one piece
  - E_PROFILE_DEADLOCK: reserved TODO (requires session simulation)
- 17 tests covering all codes, both warning/error paths, and valid field integrity
2026-04-18 22:33:09 -06:00
1402c7094b
feat(engine): modifier-profile library persistence (v1) 2026-04-18 22:30:02 -06:00
5f339d568f
feat(engine): wire all 6 descriptor side-effect imports in modifiers/index.ts 2026-04-18 22:25:23 -06:00
7d27620507
feat(engine): hp-bonus modifier descriptor 2026-04-18 22:24:13 -06:00
f566c3a488
feat(engine): direction-additions modifier descriptor
Adds DIRECTION_ADDITIONS_DESCRIPTOR (ModifierDescriptor<Direction[]>) and
generateDirectionMoves() helper. The descriptor seeds DirectionAdditions fact
on pieces; the helper generates 1-square non-capture moves for each listed
direction. Includes 11 tests covering registration, apply, describe, and
generateDirectionMoves with white/black color-relative semantics, blocking,
union stacking, and edge-board clamping.
2026-04-18 22:23:29 -06:00
ef93eb6101
feat(engine): promotion-override modifier descriptor
- Add PromotionOverride descriptor (queen/rook/bishop/knight/disabled)
- getPromotionMoves: returns [] when override='disabled'; single-type
  moves when override is a piece type
- applyPromotion: uses override value instead of promoteTo arg when set
- 24 tests: descriptor registry, describe(), apply(), valueSchema,
  getPromotionMoves integration (6 scenarios), applyPromotion integration
2026-04-18 22:22:45 -06:00
99a091509d
feat(engine): damage-resistance modifier descriptor
- Add DAMAGE_RESISTANCE_DESCRIPTOR with multiplicative stacking rule
- Export applyResistance() and stackResistances() math helpers
- 14 tests covering registry, apply, describe, applyResistance, stackResistances
- Fix eslint varsIgnorePattern to allow _-prefixed destructuring vars
- Remove now-redundant eslint-disable comment in schema.test.ts
2026-04-18 22:22:31 -06:00
ff8b8d9bb0
feat(engine): capture-flags modifier descriptor 2026-04-18 22:21:07 -06:00
761adfb699
feat(engine): range-bonus modifier descriptor
Add RANGE_BONUS_DESCRIPTOR that seeds RangeBonus fact on piece entities,
and update getSlidingMoves to respect it via a per-ray maxSteps cap.
Clamped to [0,7] in apply(); standard boards are unaffected (RangeBonus=0
→ maxSteps=7, identical to prior behaviour). Foundation for range-limit presets.
2026-04-18 22:20:24 -06:00
e58bb02605
feat(engine): ModifierProfile Zod schema + roundtrip tests 2026-04-18 22:17:41 -06:00
72ca8f4bd8
feat(engine): modifier registry pattern 2026-04-18 22:11:16 -06:00
fab8a8115b
feat(engine): add transformMoveGenerator + modifyMoveAttrs preset hooks
Adds two optional hooks to PresetDef:

- transformMoveGenerator wraps a piece's move generator. Presets compose in list order; each receives the previous wrapper's output. Returned moves are pseudo-legal and still pass through the engine's self-check filter.

- modifyMoveAttrs lets presets contribute additive range/direction deltas for generators that support them.

ChessEngine.getAllLegalMoves folds the transform chain before getExtraMoves/filterMoves, preserving existing hook ordering and the downstream self-check filter.

Tests cover: no-op wrap preserves moves, wrap adds pawn backward, two wraps compose, and self-check filter still prunes transform-added illegal moves.
2026-04-18 22:08:11 -06:00
fea511790b
feat(engine): modifier profile types 2026-04-18 22:05:58 -06:00
0f3b28ba55
feat(engine): add 6 modifier attrs to ChessAttrMap 2026-04-18 22:04:21 -06:00
a442e54d79
docs(adr): modifier-profiles architecture decisions 2026-04-18 22:03:06 -06:00
134 changed files with 24484 additions and 144 deletions

8
.gitignore vendored
View file

@ -11,3 +11,11 @@ test-results/
*.tsbuildinfo
bun.lock
node-compile-cache/
# Playwright/Chromium runtime caches (random hash directories)
7xOfJqsDU787fe61oSCq1/
IJGn1F-WxLUCbfU1lu8pU/
KMr2_dqTOvh2R-Vg1E6cO/
org.chromium.Chromium.*/
.org.chromium.Chromium.*

View file

@ -1,45 +0,0 @@
{
"active_plan": "/home/joey/Projects/rules/.sisyphus/plans/rete-rules-engine.md",
"started_at": "2026-04-16T21:50:28.031Z",
"session_ids": [
"ses_267b9d7a2ffeFkGcPFn1iv223J",
"ses_267b7c6a3ffeFBPE7j5hCcgvdq",
"ses_267b27b25ffe6ox746ql2Qj1E2",
"ses_267ae3c18ffe1Q0dx2aMzUZwid",
"ses_267ab53e5ffeXk8oWYjxiSryf0",
"ses_267a4cff0ffeCc0cSJZuxty3MR",
"ses_267a27b30ffeaIVszd2do4wYGU",
"ses_2678e1772ffeXFAdrjVVLAIh1s",
"ses_2677bcf14ffeCyy0Il5QV4Wdp0",
"ses_26776247dffehQGb1xnTBRqjq0",
"ses_26772a7e2ffep3REXuUXLd4YsX",
"ses_26770d3e0ffeWPNocV3HxsUb70",
"ses_2676e6648ffegH7o8GqgKw4hkM",
"ses_26768e818ffeacHy63Rn2RFmrS",
"ses_26760ae54ffezlg9ttb3a9P7wm",
"ses_2675a45d4ffee5V3zu7hjdOkD7",
"ses_26755c023ffeYvG2k7GuZIljF5",
"ses_26750ed18ffedLTtD3ziF7avO2",
"ses_2674cf6a7ffeOXPEFn6rhU551N",
"ses_26740710cffexgieUA3qB2B98Z",
"ses_26735c68effelwOfYs0gfmIKPZ",
"ses_2673618caffe5Rqdqzw1O6feF2",
"ses_26736499dffeeYMawv3CU88Hwp",
"ses_267368601ffeJ0vrgBbQg0z30R",
"ses_26730df9bffeFFGese2Qel8oBI",
"ses_2672b4d9bffekW9lZXc1JCVvXw",
"ses_2672b257dffeaG4lGP8jaN7Tp5",
"ses_263703df9ffegmVplLbaxmQIes",
"ses_262de3483ffe5v8SD8eFNqtdo0",
"ses_262de1b3bffexz022qa8FnxRyV",
"ses_262ddfb93ffeMGkZK2rspryFfX",
"ses_262998e6fffeRY6BJVTKB7Jtwb",
"ses_26299a749ffe3jnwQzrfJ9g1Wc",
"ses_261e4ca30ffexBShmvjEhVSHRy",
"ses_25d474e64ffeuPnRfi4X9al43a",
"ses_25d39bf44ffeY9WOIQgNCijBt0",
"ses_25d319ba5ffe37R9eKUEq95w7o"
],
"plan_name": "rete-rules-engine",
"agent": "atlas"
}

View file

@ -0,0 +1,18 @@
# Modifier Profiles T3 — Decisions Log
## [2026-04-19 17:12] Task: T1
- Read `docs/adr/modifier-profiles.md` fully before writing. Existing tone is
concise, decision-heavy, and uses `Decision`/`Rationale`/`Rejected
Alternatives` blocks with practical implementation wording.
- Existing ADR file has mixed historical structure:
- Early ADRs (T1/T2 base) mostly use `Decision`, `Rationale`, `Rejected
Alternatives`.
- Some entries include extra sections like `Semantics` and retrospectives.
- No strict global template is enforced across all sections.
- For T3 append, used a consistent five-part structure per request:
`Context`, `Decision`, `Rationale`, `Rejected Alternatives`, `Consequences`.
- Added one concrete example to each T3 ADR, including required aura example
(`radius=2`, `targetAttr=HpBonus`, `delta=+1` king-aura scenario).
- `decisions.md` did not previously exist; created it and appended this entry
without modifying existing notepad artifacts.

View file

@ -0,0 +1,26 @@
# Modifier Profiles T3 — Learnings
## [2026-04-19 17:14] Task: T3
- Added `packages/chess/src/modifiers/custom/types.ts` with branded `CustomModifierId` and `asCustomModifierId` helper that mirrors `asEntityId` trust-boundary wording/style from `packages/rete/src/schema.ts`.
- Added `CustomModifierDescriptor` with literal discriminators (`type: "data"`, `version: 1`, `uiForm: "primitive-composer"`, `source: "custom"`) and a forward-design JSDoc note for future `"scripted"` descriptors in T4.
- Divergence from ideal import shape: `EffectPrimitiveNode` is a local fallback interface in `custom/types.ts` because `packages/chess/src/modifiers/primitives/types.ts` is not yet committed in this branch state. Included TODO to swap to `../primitives/types.js` import immediately when T2 lands.
- Added `packages/chess/src/modifiers/custom/index.ts` as a focused barrel with explicit Wave 3 scope boundary comment.
- Added `packages/chess/src/modifiers/custom/types.test.ts` with four scenarios: branded helper runtime/type round-trip, descriptor structure assignment, readonly `targetAttrs` typing, readonly `primitives` typing.
## [2026-04-19 17:13] Task: T2
- Added `packages/chess/src/modifiers/primitives/types.ts` with T3 primitive core contracts:
- `PrimitiveKind` union with exactly 15 ADR-2 primitive ids.
- `EffectPrimitive<Params>` descriptor shape (`paramsSchema: ZodType<Params>`, `apply(ctx, params): void`, optional `maxDepth`, optional `childPrimitives`).
- `EffectPrimitiveNode` runtime node shape (`kind`, `params`).
- `PrimitiveApplyContext` (`engine`, `session`, `pieceId`, `depth`, `descriptor`).
- Forward-declared `CustomModifierDescriptor` placeholder interface to avoid circular dependency with future `../custom/types.ts`.
- Added `packages/chess/src/modifiers/primitives/registry.ts` + singleton export.
- Mirrored `MODIFIER_REGISTRY` class shape exactly: private `Map`, duplicate guard throw, `register/get/list/has`, generic register call-site support.
- Added `packages/chess/src/modifiers/primitives/index.ts` barrel with explicit Wave-2 side-effect-registration stub comment.
- Added `packages/chess/src/modifiers/primitives/registry.test.ts` with 6 scenarios: round-trip get, duplicate throw, list order stability, `has()` accuracy, unknown kind miss, and generic type preservation at register call site.
## [2026-04-19 17:28] Wave 2 batch B
- Added `absorb-damage-with-attribute` primitive + tests.

View file

@ -0,0 +1,86 @@
# Polish-T2 — Notepad
Scope: Option C cleanup after modifier-profiles-t2 ships. Touches modifier internals + UI + a small solo regression guard. Three commits max.
## Baseline (master @ 567480a)
- `bun run check` green: 1231 unit tests, 0 lint errors.
- `bunx playwright test` green: 58/58.
- Strict TS: no `as any` is a lint error. `as unknown as X` is allowed by the compiler but we're cleaning ours up because each one was flagged in T2 final audit as lazy typing.
## Key facts the polish relies on
### `as unknown as` sites in scope (5 targets)
1. `packages/chess/src/modifiers/registry.ts:49` — `this.#byId.set(descriptor.id, descriptor as unknown as ModifierDescriptor)`. Registry is a `Map<Id, ModifierDescriptor>` (unknown-widened). The generic `register<V>(descriptor: ModifierDescriptor<V>)` accepts typed descriptors; the store erases V. Fix: widen the parameter up-front via an explicit typed conversion that doesn't need `unknown`: e.g. accept as-is but annotate the Map value type with a stored intersection, or just do `const widened: ModifierDescriptor = descriptor;` — TS should accept this assignment because `ModifierDescriptor<V>` is assignable to `ModifierDescriptor<unknown>` only via its covariant `describe`/`apply`, which are actually contravariant in V → so direct assignment FAILS the check. Cleanest fix: change the generic signature to `register(descriptor: ModifierDescriptor)` with no generic (since V is immediately erased anyway), and have callers rely on `MODIFIER_REGISTRY.register(FOO_DESCRIPTOR)` implicitly widening `ModifierDescriptor<number>` → `ModifierDescriptor<unknown>`. But this ALSO fails because of contravariance. RIGHT fix: introduce an internal stored type `ModifierDescriptorAny` with `value: unknown` params and use a small `toStored(d)` converter that does the widening in one place with `// eslint-disable-next-line` if the compiler balks — OR rethink the descriptor's `apply`/`describe` to take `unknown` and let each descriptor narrow via a Zod parse inside. Pragmatically: take `descriptor as ModifierDescriptor` (single cast, not double) — that probably works because `ModifierDescriptor` (no-arg default) = `ModifierDescriptor<unknown>` and `ModifierDescriptor<number>` isn't structurally assignable to it, so a one-step cast is required. Accept that one cast; lose the `unknown` interstitial.
2. `packages/chess/src/modifiers/schema.ts:88` — `return ModifierProfileSchema.parse(raw) as unknown as ModifierProfile`. The schema types out to a plain object mirror of ModifierProfile but the nested `readonly` and branded differences trip up assignment. Fix: add `.transform((v): ModifierProfile => v as ModifierProfile)` on the schema, or use `z.custom<ModifierProfile>` at the top, or define `ModifierProfile` from `z.infer<typeof ModifierProfileSchema>` instead of the hand-rolled interface in `types.ts`. Simplest: a single `as ModifierProfile` post-parse, no `unknown` bridge.
3. `packages/chess/src/ui/ModifierTooltip.tsx:23`, `ui/ModifiedPieceIndicator.tsx:12`, `ui/ModifierPinnedPanel.tsx:29` — all of the form `pieceId as unknown as EntityId`. `EntityId = number & { readonly __brand: "EntityId" }`. The UI passes `number` because the `PieceState` at `Board.tsx:50-54` uses `id: number`. Fix: introduce a `toEntityId(n: number): EntityId` helper (single cast site, documented) OR change `PieceState.id` + the prop type to `EntityId`. The helper is smaller-surface. Place it in `packages/rete/src/schema.ts` alongside the type or in `packages/chess/src/modifiers/source.ts` (already re-imports EntityId from @paratype/rete). Actually `packages/rete` already has internal helpers like `mkId(n)` used only in tests; exporting a public one is the right move. Put it next to the type: `export const asEntityId = (n: number): EntityId => n as EntityId` with a doc comment explaining when to use it ("trust boundary: you've already verified this number came from Session.nextId or an EAV fact; otherwise use `session.allFacts()` to find a real one").
### Test-only casts NOT in scope (keep as-is)
- `source.test.ts:29/40`, `validate.test.ts:230`, `net/prediction.test.ts:44/73`, `net/client.test.ts:156`, `presets/*.test.ts` — these are test mocks / fixtures. Not cleaning up casts in tests as part of this polish (they'd need different mocks; scope creep).
- `engine.ts:268` — `value as unknown as import("@paratype/rete").FactValue` — different subsystem (Rete fact value widening), not flagged in T2 audit. Leave alone.
- `net/client.ts:182/190` — listener type widening for the subscriber bus. Not flagged. Leave alone.
### `getModifierSource` attr-aliasing (src/modifiers/source.ts:59-61)
Current hardcoded ladder:
```
if (attrName === "HpBonus") attrName = "Hp";
else if (attrName === "RangeBonus") attrName = "Range";
```
Semantically: "this modifier augments the preset-declared base attribute". `HpBonus` augments `Hp` (declared by `piece-hp` preset). `RangeBonus` has no corresponding declared base attribute — `Range` isn't in `ChessAttrMap` at all. The `"Range"` branch is dead code today; it only matters IF a future preset declares `Range` in its `pieceAttributes`.
Cleanest fix: add an optional `baseAttr?: ChessAttrKey` to `ModifierDescriptor`. `hp-bonus.ts` declares `baseAttr: "Hp"`. `range-bonus.ts` leaves it undefined (or adds it when/if a preset declares Range). Then `getModifierSource` checks `descriptor.baseAttr ?? descriptor.attrName` against preset `pieceAttributes` and the aliasing vanishes.
Watch out: `ModifierDescriptor` is in `types.ts`; adding an optional field is non-breaking. Test file at `registry.test.ts:26-39` constructs a mock descriptor with no `baseAttr` — optional means the test keeps working.
### Solo-mode modifier badges
Current state (verified post-T2):
- `Lobby.resetToFreshGame` (line 219-238) already forwards `selectedProfile` to `new ChessEngine({ layout, profile })` for solo — T2 fix from `567480a`/`980d567`. So the solo engine HAS the profile and the facts applied.
- `Board.tsx:354-356` renders `<ModifiedPieceIndicator pieceId={piece.id} engine={engine}>` unconditionally when `engine !== undefined`.
- `GameView` (solo path, `engineState` omitted) → `GameLayout` → passes `state.engine` to `<Board engine={...}>`.
Likely already works end-to-end! The "ship solo badges" task is mostly a regression-test addition: add a Playwright test that selects a profile in the lobby, clicks Play Solo, and asserts `[data-testid^="modifier-indicator-"]` is visible. If the test fails, then we debug; otherwise, just land the test as the guard.
Probable file: add test to `solo-smoke.spec.ts` (T2 added it as the canary) or extend `modifier-profiles.spec.ts` with a solo variant. Prefer `solo-smoke.spec.ts` — that's where solo invariants live.
### Commands
- `bun run check` — typecheck + lint + vitest
- `bunx playwright test --reporter=list` — full e2e
- `bunx playwright test e2e/solo-smoke.spec.ts --reporter=list` — solo only
- WS server for e2e multiplayer tests: `tmux new-session -d -s ws-server 'bun run packages/server/src/index.ts'` (not needed for solo tests)
## [2026-04-19 13:53] Task: commit-1 verification gate
- Gotcha: `bun run build` (tsup/vite) cleaned package `dist/` outputs and removed TS project-reference declarations; root `bun run check` then failed with TS6305 + missing `@paratype/rete` declarations.
- Resolution for this session: regenerate declaration outputs with `bunx tsc -b --force packages/rete packages/chess` before running the check gate; afterward `bun run check` returned PASS.
## [2026-04-19 13:54] Task: commit-2 registry cast cleanup
- Implemented the adapter path in `modifiers/registry.ts`: stored descriptors now wrap typed `apply/describe` in `unknown`-accepting closures at registration time.
- Result: removed the `descriptor as unknown as ModifierDescriptor` storage cast; `schema.ts` parse return also reduced from double-cast to `as ModifierProfile`.
## [2026-04-19 14:01] Task: commit-3 preset source attribution
- Added optional `baseAttr?: ChessAttrKey` to `ModifierDescriptor` and set `hp-bonus` to `baseAttr: "Hp"`.
- Removed hardcoded `HpBonus`/`RangeBonus` aliasing from `getModifierSource`; source matching now uses `descriptor.baseAttr ?? descriptor.attrName` directly.
## [2026-04-19 14:03] Task: commit-4 solo modifier indicator e2e
- Added a solo-smoke e2e that seeds one custom profile in localStorage, selects it in the lobby picker, enters solo mode, and asserts a `modifier-indicator-*` node renders.
- Guarded lobby navigation by opening the rules drawer only when `profile-picker` is initially hidden, matching existing T2 lobby interaction patterns.
## [2026-04-19 14:07] Task: solo indicator test fixture correction
- Initial fixture used `color: "white"` and no `layoutId`; the option did not appear in the lobby picker during full e2e.
- Corrected fixture to mirror known-good T2 shape (`layoutId: "classic"`, `color: "both"`), which allows picker selection and solo badge assertion.
## [2026-04-19 14:09] Task: solo indicator test storage key mismatch
- Root cause of missing picker option: seeded localStorage key used `paratype-chess:modifier-profiles:v1`, but runtime library key is `houserules:modifier-profiles:v1`.
- Updating the key fixed profile loading in the lobby picker for the solo smoke regression.

View file

@ -0,0 +1,547 @@
# Modifier Profiles — T2 Polish
## TL;DR
> **Quick Summary**: UX and robustness polish on top of shipped T1 modifier profiles. Adds copy/paste between pieces, a visual "this piece is modified" indicator on the live board, inline conflict-resolution in the editor, undo/redo in the editor, proper turn-boundary queuing server-side (replaces T1's immediate-apply simplification), two-player consent model for mid-game swaps (replaces host-only), and richer source-chain attribution in the pinned inspection panel.
>
> **Deliverables**:
> - Editor: copy/paste modifiers, undo/redo stack, inline conflict resolution UI
> - Board: subtle modifier-indicator badge on modified pieces (no hover required)
> - Server: turn-boundary queue (ADR-3 done properly), both-player consent for hot-swap
> - Inspection: enhanced source-chain breakdown in pinned panel
> - Full e2e vertical slice
> - Updated ADR + user docs
>
> **Estimated Effort**: Medium (15 tasks)
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: T1 (turn-queue server) → T3 (consent) → T13 (e2e)
---
## Context
### Original Request
> "can we do t2 and t3? T2 first, then T3 (Recommended)"
### T1 Recap (shipped)
T1 (completed) delivered the foundation: 6 built-in modifier descriptors, per-type + per-instance scope, editor UI, library persistence, URL sharing, hover tooltip, pinned panel, server-side validation, immediate-apply hot-swap, host-only authority. Full vertical slice working with 18/18 Playwright tests.
### T2 Rationale
Two categories of work:
1. **UX polish**: features users will notice immediately — copy/paste, visual indicators, undo/redo, better conflict messages.
2. **Robustness fixes**: close the documented simplifications from T1's Implementation Retrospective — turn-boundary queue (not immediate) + two-player consent (not host-only).
### Architectural Decisions (inherited from T1 ADRs)
Most T2 work doesn't need new ADR decisions. Three small extensions:
**T2-ADR-1: Turn-boundary queue**
- Client sends `modifier-profile.update` at any time.
- Server enqueues to `room.pendingProfile`. Apply runs in the server's `onAfterMove` hook after next move is validated.
- If pending profile is invalid when apply fires → NACK to sender, clear pending, no broadcast.
- Multiple updates before apply: last-write-wins (replace pending).
- Replaces T1 simplification where swap applied immediately on receipt.
**T2-ADR-2: Two-player consent**
- Host sends `modifier-profile.propose` with candidate profile.
- Server broadcasts `modifier-profile.proposal-pending` to opponent with profile contents.
- Opponent sends `modifier-profile.consent` with approve/reject.
- If approve → apply at next turn boundary (T2-ADR-1). If reject → clear proposal, broadcast `modifier-profile.rejected`.
- Timeout: 60s → auto-reject.
- Replaces T1's unilateral host authority.
**T2-ADR-3: Undo/redo in editor**
- Editor maintains a snapshot stack of working-profile states.
- Every meaningful user action (add/delete/edit a modifier) pushes a snapshot.
- Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z redoes.
- Stack capped at 50 snapshots.
- Cleared on save or cancel.
---
## Work Objectives
### Core Objective
Polish the T1 modifier-profile system with features users expect from a mature editor + close the documented T1 simplifications (immediate-apply, host-only) to ship production-grade semantics.
### Concrete Deliverables
**Editor UX** (`packages/chess/src/ui/`):
- `ModifierProfileEditor.tsx` — add undo/redo stack, toolbar with history controls
- `PerTypePanel.tsx` — add "Copy" + "Paste" buttons on each modifier row
- `PerInstancePanel.tsx` — add copy/paste between pieces ("copy from b1" → "paste to g1")
- `ConflictResolutionPanel.tsx` — NEW: shows validation errors inline with suggested fixes + auto-resolve options
**Board UX** (`packages/chess/src/ui/`):
- `ModifiedPieceIndicator.tsx` — NEW: small badge/glow on modified pieces
- `GameView.tsx` + `Board.tsx` — wire indicator into piece rendering
**Server** (`packages/server/src/`):
- `broadcast.ts` — `handleModifierProfileUpdate` now enqueues instead of applying
- `game-session.ts` — new `applyPendingProfile()` method called after `applyMove()`
- New handlers: `handleModifierProfilePropose`, `handleModifierProfileConsent`
- `rooms.ts` — `Room.pendingProfile`, `Room.proposalState`, `Room.proposalTimeoutHandle`
- `protocol.ts` — new messages: `modifier-profile.propose`, `modifier-profile.proposal-pending`, `modifier-profile.consent`, `modifier-profile.rejected`
**Inspection** (`packages/chess/src/ui/`):
- `ModifierPinnedPanel.tsx` — enhanced source breakdown:
- Per-instance entries labeled "(from layout square X)"
- Per-type entries labeled "(applies to all {color} {type}s)"
- Preset entries labeled "(from {preset name})"
- Base values labeled "(default)"
**Docs**:
- Update `docs/adr/modifier-profiles.md` — add T2-ADR-1, T2-ADR-2, T2-ADR-3 sections
- Update `docs/user/modifier-profiles.md` — document new features
**E2E** (`packages/chess/e2e/`):
- Extend `modifier-profiles.spec.ts` — 8 new scenarios
### Definition of Done
- [ ] `bun run check` green (typecheck + lint + vitest)
- [ ] Copy/paste between pieces: adding a per-instance modifier to b1, then copying to g1, produces identical entries at both squares
- [ ] Undo/redo: 5 actions then undo 3 times then redo 2 times produces state equal to 4-action state
- [ ] Visual indicator visible on modified pieces without requiring hover
- [ ] Turn-boundary queue: profile update during mid-move doesn't apply until after opponent's move resolves
- [ ] Two-player consent: black must approve before profile change broadcasts
- [ ] Proposal timeout: 60s no-response → auto-reject
- [ ] Inline conflict resolution shows specific error + suggested fix
- [ ] Source chain in pinned panel distinguishes all 4 sources
- [ ] Playwright e2e: 18 existing + 8 new = 26 scenarios all pass
- [ ] ADR + user docs updated
### Must Have
- All 8 UX/polish deliverables working end-to-end
- Turn-boundary queue replaces immediate-apply (no regression — hot-swap still works)
- Two-player consent with 60s timeout
- Editor undo/redo with 50-snapshot cap
- Shared schema between client/server (no drift)
### Must NOT Have (Guardrails)
- ❌ Custom modifier authoring (T3)
- ❌ Cross-piece aura effects (T3)
- ❌ Multi-profile stacking (post-T3)
- ❌ Editor persistence across sessions (reload = fresh editor, library is separate)
- ❌ Server-side undo (client-side only — no WS messages for undo state)
- ❌ Breaking changes to T1 WS messages (all additions are NEW messages; `modifier-profile.update` becomes an alias for `propose` + auto-approve when single-player)
- ❌ Mid-turn swap (turn-boundary gate is enforced)
- ❌ Client computing effective modifiers differently from server
### Must NOT Have (AI Slop Patterns)
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
- ❌ Generic names (`data`, `result`, `item`, `temp`)
- ❌ Empty catch blocks
- ❌ `console.log` in production code
- ❌ Premature abstraction
---
## Verification Strategy
### Test Decision
- **Infrastructure**: vitest + Playwright, same as T1.
- **Approach**: TDD for server + engine; Playwright scenarios for UX features.
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t2/task-{N}-{slug}.{ext}`.
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Server robustness foundation — PARALLEL):
├── T1: T2-ADR documentation (append to existing ADR)
├── T2: Turn-boundary queue server-side
└── T3: Two-player consent protocol
Wave 2 (Editor features — PARALLEL):
├── T4: Undo/redo snapshot stack in editor
├── T5: Copy/paste modifiers between pieces
└── T6: Inline conflict resolution panel
Wave 3 (Board UX + Inspection polish — PARALLEL):
├── T7: Modified-piece indicator on live board
├── T8: Enhanced source-chain in pinned panel
└── T9: Consent UI (proposal notification + approve/reject buttons)
Wave 4 (E2E + Docs — PARALLEL):
├── T10: Playwright e2e additions (8 scenarios)
├── T11: ADR updates
└── T12: User docs updates
Wave FINAL (4 parallel reviewers):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Manual QA
└── F4: Scope fidelity check
```
### Dependency Matrix
| Task | Depends On | Blocks |
|------|------------|--------|
| 1 | — | 2-12 |
| 2 | 1 | 3, 10, 11 |
| 3 | 2 | 9, 10, 11 |
| 4 | 1 | 10 |
| 5 | 1 | 10 |
| 6 | 1 | 10 |
| 7 | 1 | 10 |
| 8 | 1 | 10 |
| 9 | 3 | 10 |
| 10 | 2-9 | F1-F4 |
| 11 | 1, 2, 3 | F1 |
| 12 | 4-9 | F1 |
---
## TODOs
- [x] 1. **T2-ADR documentation**
**What to do**:
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
- `## T2-ADR-1: Turn-boundary queue` — full semantics, last-write-wins, NACK on invalid at apply time
- `## T2-ADR-2: Two-player consent` — proposal/consent flow, 60s timeout, auto-reject
- `## T2-ADR-3: Editor undo/redo` — snapshot stack, 50-item cap, cleared on save/cancel
**Must NOT do**: Don't write any code in this task.
**Recommended Agent Profile**: `writing` — no skills
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
**Acceptance Criteria**:
- [ ] File has 3 new T2-ADR sections
- [ ] `grep -c "^## T2-ADR-" docs/adr/modifier-profiles.md` → 3
**Commit**: `docs(adr): T2 polish architecture decisions`
- [x] 2. **Turn-boundary queue server-side**
**What to do**:
- Replace `handleModifierProfileUpdate`'s immediate-apply with queue semantics:
- Add `Room.pendingProfile?: ModifierProfile` field (may already exist from T1 — re-use or add)
- On receive: validate shape, store in `room.pendingProfile`, ack with `modifier-profile.queued`
- On `applyMove()` success: check pendingProfile, validate against post-move session, apply via `reconcileProfileSwap`, broadcast `modifier-profile.updated`, clear pending
- If pending profile invalid at apply time: NACK to original sender with specific error code, clear pending
- Last-write-wins: new pending replaces old pending before apply fires
**Must NOT do**: Don't break T1 e2e tests. Don't apply mid-move.
**Recommended Agent Profile**: `deep` — concurrency-sensitive
**Parallelization**: Wave 1 (PARALLEL with T1, T3). Blocks: T3, T10, T11. Blocked By: T1.
**References**:
- `packages/server/src/broadcast.ts` — current `handleModifierProfileUpdate`
- `packages/server/src/game-session.ts` — `applyMove`, post-move hook location
- `docs/adr/modifier-profiles.md` § T2-ADR-1
**Acceptance Criteria**:
- [ ] Update sent during move — profile does NOT apply until opponent completes their move
- [ ] 2 rapid updates → only the last is applied
- [ ] Invalid pending at apply time → NACK to sender, no broadcast
- [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` — all pass (updated)
**Commit**: `feat(server): turn-boundary queue for modifier profile updates`
- [x] 3. **Two-player consent protocol**
**What to do**:
- New WS messages in `protocol.ts`:
- `modifier-profile.propose` (client→server) — host sends candidate profile
- `modifier-profile.proposal-pending` (server→opponent) — notify with profile contents
- `modifier-profile.consent` (client→server) — opponent sends approve/reject
- `modifier-profile.rejected` (server→host) — proposal rejected or timed out
- `broadcast.ts`:
- `handleModifierProfilePropose` — store proposal + start 60s timeout
- `handleModifierProfileConsent` — on approve: promote to pendingProfile (feeds into T2 queue); on reject: clear + broadcast rejected
- On timeout: auto-reject
- `rooms.ts`:
- `Room.proposalState?: { profile, proposedBy, timeoutHandle, proposedAt }`
- Solo mode / host-only mode: `modifier-profile.update` alias auto-approves
**Must NOT do**: Don't change `modifier-profile.update` semantics in solo mode.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 1. Blocks: T9, T10, T11. Blocked By: T2.
**References**:
- `docs/adr/modifier-profiles.md` § T2-ADR-2
**Acceptance Criteria**:
- [ ] Propose → opponent sees pending → approve → broadcast to both
- [ ] Propose → opponent rejects → only host sees rejected, no broadcast to board
- [ ] Propose → 60s timeout → auto-reject
- [ ] Solo mode: propose = immediate apply (no consent step)
**Commit**: `feat(server): two-player consent for modifier profile swaps`
- [x] 4. **Editor undo/redo snapshot stack**
**What to do**:
- In `ModifierProfileEditor.tsx`:
- `const [history, setHistory] = useState<ModifierProfile[]>([initialProfile])`
- `const [historyIndex, setHistoryIndex] = useState(0)`
- `const currentProfile = history[historyIndex]`
- Every mutation path goes through `pushSnapshot(newProfile)` which truncates forward history and caps at 50
- `undo()` / `redo()` adjust historyIndex
- Keyboard handlers for Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z
- Header toolbar with Undo/Redo buttons (disabled when at ends of stack)
- `data-testid="undo-button"`, `data-testid="redo-button"`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add 3 modifiers → undo 2 → profile has 1 modifier
- [ ] Undo then redo → same state
- [ ] Cap at 50 snapshots (add 51st → oldest dropped)
- [ ] Save or cancel clears history
**Commit**: `feat(ui): modifier editor undo/redo`
- [x] 5. **Copy/paste modifiers between pieces**
**What to do**:
- In `PerInstancePanel.tsx`:
- When a square is selected: "Copy modifiers from this square" button
- When a different square is selected after copy: "Paste modifiers here" button (disabled when clipboard empty)
- Clipboard is component-local state (not OS clipboard)
- Paste appends all clipboard entries to the target square, with the kind preserved
- In `PerTypePanel.tsx`:
- "Copy to clipboard" on each row
- "Paste" button appends from clipboard
- `data-testid="copy-modifier-b1"` etc.
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add HP+2 to b1 → copy → paste on g1 → both have HP+2
- [ ] Paste when clipboard empty: button disabled
- [ ] Copy doesn't delete source
**Commit**: `feat(ui): copy/paste modifiers between pieces`
- [x] 6. **Inline conflict resolution panel**
**What to do**:
- New component `ConflictResolutionPanel.tsx`:
- Shows validation errors from `validateProfile()` inline
- Each error has a "Fix" button with a specific auto-resolve action:
- `E_PROFILE_NO_KING` → "Add king to e1" button (or suggest switching layout)
- `E_PROFILE_INVULN_KING` → "Remove invuln from king" button
- `E_PROFILE_ORPHAN_INSTANCE` (warning) → "Remove orphan entry" button
- `E_PROFILE_ATTR_LIMIT` → "Show affected pieces" + manual resolution
- Panel visible whenever profile has errors/warnings; hidden when clean
- Wire into `ModifierProfileEditor.tsx` — show panel at the top when dirty
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Create profile with invuln on king → panel shows error + Fix button
- [ ] Click Fix → error resolved
- [ ] Panel hidden when profile validates clean
**Commit**: `feat(ui): inline conflict resolution panel`
- [x] 7. **Modified-piece indicator on live board**
**What to do**:
- New component `ModifiedPieceIndicator.tsx`:
- Small colored dot (or glow/border) shown on pieces that have any active modifier
- Reads `MODIFIER_REGISTRY.list()` and checks `session.get(pieceId, attr)` for each
- If any defined → indicator visible
- CSS: absolute-positioned top-right corner of piece square, 8px dot
- Wire into `Board.tsx` — render indicator alongside piece
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Piece with modifier: indicator visible
- [ ] Piece without modifier: no indicator
- [ ] Indicator updates reactively on profile swap
**Commit**: `feat(ui): modified-piece indicator on board`
- [x] 8. **Enhanced source-chain in pinned panel**
**What to do**:
- Update `ModifierPinnedPanel.tsx`:
- For each modifier row, add source label:
- If piece's attr value matches a profile.perInstance entry at its square → "(per-instance: {square})"
- Else if matches a perType entry for this pieceType+color → "(per-type: all {color} {type}s)"
- Else if some active preset declared this attr → "(preset: {preset.name})"
- Else → "(default)"
- Reads the engine's active profile + presets via a new `getModifierSource(engine, pieceId, kind)` helper in `packages/chess/src/modifiers/source.ts`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Per-instance modifier → source labels show "per-instance: b1"
- [ ] Per-type modifier → source labels show "per-type: all white knights"
- [ ] piece-hp preset → source labels show "preset: Hit Points"
**Commit**: `feat(ui): enhanced modifier source chain in pinned panel`
- [x] 9. **Consent UI (proposal notification + buttons)**
**What to do**:
- New component `ModifierProposalDialog.tsx`:
- Shows when opponent has pending proposal (`modifier-profile.proposal-pending` received)
- Displays summary of proposed profile changes
- Approve / Reject buttons
- 60s countdown timer
- Wire into `GameView.tsx` via WS message subscription
- Host sees a "Proposal sent — waiting for opponent" state after proposing
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T3.
**Acceptance Criteria**:
- [ ] Host proposes → opponent sees dialog within 500ms
- [ ] Approve → profile applies at next turn boundary
- [ ] Reject → dialog closes on both sides, host sees "Proposal rejected"
- [ ] Timeout → auto-reject
**Commit**: `feat(ui): consent dialog for modifier profile proposals`
- [x] 10. **Playwright e2e additions (8 feature scenarios + solo regression guards)**
**What to do**:
- Add 8 new T2-feature tests to `packages/chess/e2e/modifier-profiles.spec.ts`:
1. Editor undo/redo: add 3 modifiers, undo 2, redo 1
2. Copy modifier between squares (per-instance)
3. Paste button disabled when clipboard empty
4. Conflict panel shows error + Fix works
5. Modified-piece indicator visible without hover
6. Source chain distinguishes per-instance vs per-type
7. (multiplayer) Proposal → approve → both clients see update
8. (multiplayer) Proposal → reject → no update
- **Keep and expand `packages/chess/e2e/solo-smoke.spec.ts`** (already exists from T1 post-audit fix). Adds ongoing regression coverage for solo play — these tests caught the T1 Rules-Drawer-Esc bug. Ensure the following scenarios remain and are extended:
- play-solo button navigates to /game with fresh board
- drag pawn e2→e4 resolves
- 4-ply sequence (e4 e5 Nf3 Nc6) completes
- **Rules drawer opens, closes via Esc, board remains interactive** (the T1 regression guard)
- No console errors on solo game start
- Add 2 more solo-regression scenarios in T2 since T2 touches drawer/editor further:
- Rules drawer: backdrop click closes drawer (workaround path still works)
- Modifier editor: Esc closes editor without dismissing drawer underneath (nested modal ordering)
**Why both?** `solo-smoke.spec.ts` is the canary — it exercises the baseline game flow without any modifier-profile setup. `modifier-profiles.spec.ts` covers feature paths. Keeping both prevents T2/T3 work from silently regressing solo play.
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
**Parallelization**: Wave 4. Blocks: F1-F4. Blocked By: T2-T9.
**Acceptance Criteria**:
- [ ] 26/26 modifier-profile tests pass (18 from T1 + 8 new)
- [ ] 7+ solo-smoke tests pass (5 original + 2 T2 additions)
- [ ] Multiplayer suite unchanged, still passes
- [ ] Total Playwright runtime < 120s
**Commit**: `test(e2e): T2 polish vertical slice + solo regression guards`
- [x] 11. **ADR updates — Implementation Retrospective T2 addendum**
**What to do**:
- Append to `docs/adr/modifier-profiles.md`:
- `## T2 Implementation Retrospective` section noting:
- T1's immediate-apply simplification resolved (T2-ADR-1)
- T1's host-only simplification resolved (T2-ADR-2)
- Editor UX parity with modern standards (undo/redo, copy/paste)
- Any deviations found during implementation
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T1, T2, T3.
**Commit**: `docs(adr): T2 implementation retrospective`
- [x] 12. **User docs updates**
**What to do**:
- Update `docs/user/modifier-profiles.md`:
- Update "Hot-Swap" section: describe proposal/consent flow (remove T1's "host-only" note)
- Add "Editor Features" section: undo/redo (keyboard shortcuts), copy/paste, conflict resolution
- Add "Board Indicators" section: modified-piece glow
- Update "In-Play Inspection" section: mention enhanced source chain
- Update "Known Limitations" section: remove T1 items, add T3 preview
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T4-T9.
**Commit**: `docs(user): T2 modifier profile features`
---
## Final Verification Wave
- [x] F1. **Plan Compliance Audit** — `oracle`
Verify all 12 tasks' deliverables exist. Verify T1 simplifications resolved. Check evidence files.
Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [3/3] | VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review** — `unspecified-high`
`bun run check`. Scan for slop patterns. Verify server-shared schemas.
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass] | VERDICT`
- [x] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
Execute all 26 Playwright scenarios. Multiplayer proposal/consent roundtrip. Undo/redo combos.
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
- [x] F4. **Scope Fidelity** — `deep`
No T3 features smuggled (no custom authoring, no auras, no multi-profile stacking). No breaking WS changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN] | VERDICT`
---
## Commit Strategy
1. `docs(adr): T2 polish architecture decisions`
2. `feat(server): turn-boundary queue for modifier profile updates`
3. `feat(server): two-player consent for modifier profile swaps`
4. `feat(ui): modifier editor undo/redo`
5. `feat(ui): copy/paste modifiers between pieces`
6. `feat(ui): inline conflict resolution panel`
7. `feat(ui): modified-piece indicator on board`
8. `feat(ui): enhanced modifier source chain in pinned panel`
9. `feat(ui): consent dialog for modifier profile proposals`
10. `test(e2e): T2 polish vertical slice`
11. `docs(adr): T2 implementation retrospective`
12. `docs(user): T2 modifier profile features`
## Success Criteria
```bash
bun run check # all green
bun run test packages/server/ # all pass (including updated ws tests)
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass
```
### Final Checklist
- [ ] All 9 feature tasks shipped end-to-end
- [ ] T1 simplifications (immediate-apply, host-only) fully resolved
- [ ] 26 Playwright e2e scenarios green
- [ ] ADR + user docs updated
- [ ] `bun run check` green
- [ ] F1-F4 all APPROVE
- [ ] User explicit "okay"

View file

@ -0,0 +1,601 @@
# Modifier Profiles — T3 User-Authored Custom Modifiers (DSL)
## TL;DR
> **Quick Summary**: Users can author their own modifier categories at runtime via a constrained config DSL. A custom modifier is a named, versioned, data-only descriptor composed from atomic effect primitives ("add N to attribute X", "add direction Y", "reduce damage by N%"). No code execution — purely structured data. Custom modifiers register into a per-game registry (alongside built-ins), persist in a separate library, and travel through the network via `ModifierProfileSchema` with full validation. Forward-designed for a T4 scripted-modifier extension.
>
> **Deliverables**:
> - Effect primitives catalog + registry for user-composable atoms
> - `CustomModifierDescriptor` type — data-only, validated, sandboxed
> - `CustomModifierEditor.tsx` — visual composer (no code typing)
> - Per-room custom modifier registration via WS protocol
> - Server-side validation of custom descriptors (anti-DoS, legality)
> - Modifier Profile editor: custom modifiers appear in kind dropdown alongside built-ins
> - Per-user library for custom descriptors + sharing
> - Cross-piece aura effects (built on the same primitive model)
> - Multi-profile stacking (ordered composition)
> - Full e2e vertical slice
> - T4 design note: how scripted modifiers would plug in
>
> **Estimated Effort**: Large (25 tasks, ~3-4 execution waves like T1)
> **Parallel Execution**: YES — 5 waves
> **Critical Path**: T1 (primitives ADR) → T3 (primitive registry) → T5 (custom descriptor type) → T13 (engine apply) → T19 (e2e)
---
## Context
### Original Request
> "Start DSL, design for script upgrade" (T3 approach)
> "T2 first, then T3 (Recommended)" (execution order)
### Pre-requisites
- T1 (shipped): built-in modifier descriptors + registry + editor + server integration
- T2 (must ship first): turn-boundary queue, two-player consent, undo/redo, copy/paste, conflict resolution
### T3 Rationale
Extend the T1/T2 foundation so users can define new modifier CATEGORIES (not just configure existing ones).
Example user-authored modifier: "Shield — piece has 3 shield charges. Each incoming damage spends one charge instead of reducing HP. No charges → damage falls through." Users compose this from primitives:
- `consume-attribute-on-damage` primitive with attr="ShieldCharges", consume=1, absorb=true
- `seed-attribute` primitive with attr="ShieldCharges", value=3
### Architecture (new ADRs)
**T3-ADR-1: DSL is structured data, not code**
Custom modifiers are composed from a fixed catalog of ~15 effect primitives. Each primitive is a TypeScript function (shipped in the engine) that takes parameters. Users compose primitives in the UI; the resulting JSON object IS the modifier. No eval, no sandbox, no script parsing — structured validation only.
**Rejected alternatives**:
- Sandboxed script runtime (QuickJS, etc.) — defers to T4. Security surface too large for T3.
- AST-based mini-language — same complexity as sandboxed script.
- String template interpolation — too limited.
**T3-ADR-2: Effect Primitive catalog (T3 v1)**
Primitives are atomic, composable, pure. Full catalog:
| Primitive | Parameters | Effect |
|-----------|------------|--------|
| `seed-attribute` | attr, value | Seeds a fact on the piece at profile apply time |
| `add-to-attribute` | attr, delta | Adds delta to existing attr value (additive stacking) |
| `multiply-attribute` | attr, factor | Multiplies existing attr value |
| `add-direction` | directions[] | Adds to DirectionAdditions |
| `set-capture-flag` | flag | ORs into CaptureFlags |
| `absorb-damage-with-attribute` | attr, rate | Each damage point consumes `rate` of attr instead of HP |
| `reflect-damage` | percentage | Damage sends back to attacker at percentage |
| `block-move-type` | moveType (capture / step / slide) | Filter out moves matching criteria |
| `add-aura` | radius, targetAttr, delta | For each piece within radius, add delta to attr |
| `on-turn-start` | primitive[] | Runs contained primitives at turn start |
| `on-capture` | primitive[] | Runs contained primitives when this piece captures |
| `on-damaged` | primitive[] | Runs contained primitives when this piece takes damage |
| `conditional` | condition, then-primitive[], else-primitive[] | Condition-branch (e.g., "if HP < 2, do X") |
| `modify-movement-range` | delta | RangeBonus integration |
| `override-promotion` | target | PromotionOverride integration |
Users compose these in a visual editor. Each primitive has a known shape, validation, UI form, and engine integration. Adding a new primitive = one file (same pattern as descriptors in T1).
**T3-ADR-3: Custom modifier authoring scope**
- **Per-room**: custom modifiers are registered on a room-by-room basis. Registration = send full descriptor via WS at room.create or via `custom-modifier.register` message.
- **Per-user library**: users save their custom descriptors locally (like profiles). Separate library key `houserules:custom-modifiers:v1`.
- **Sharing**: custom descriptors travel with profiles that reference them. A profile using a custom modifier "shield-v1" embeds the full `shield-v1` descriptor.
- **Versioning**: custom descriptors have a `version: 1` field. v2+ is a new modifier id.
- **Validation**: server-side validator checks primitive catalog membership, parameter bounds, recursion depth ≤ 3 (for on-turn-start / conditional nesting), total primitive count ≤ 50 per descriptor.
**T3-ADR-4: Registry extension — per-engine not per-process**
Built-in descriptors use module-level `MODIFIER_REGISTRY`. Custom descriptors use a per-engine `customModifiers: Map<string, CustomModifierDescriptor>`. `MODIFIER_REGISTRY.get(id)` transparently consults per-engine custom registry when global misses. This prevents user modifiers from leaking across rooms.
**T3-ADR-5: T4 forward-design (scripted modifiers)**
Future scripted modifiers would plug in as a new `ModifierDescriptor` type:
```typescript
interface ScriptedModifierDescriptor {
type: "scripted";
id: string;
script: string; // QuickJS or similar
permissions: Permission[];
// ... other fields
}
```
The engine's integration point (same `apply` signature) doesn't know the difference. T3's validator gets a `validateCustomDescriptor` branch-point that's currently `type: "data"` only; T4 adds `type: "scripted"` with separate validation + sandboxed execution.
**T3-ADR-6: Multi-profile stacking (bundled with T3 since primitives enable it)**
Profiles can be stacked. Engine maintains `activeProfiles: readonly ModifierProfile[]` instead of a single profile. Stacking rules per modifier kind (from ADR-4 of T1) apply across profiles. Conflict resolution: explicit priority order set by user. Default: profiles applied in registration order.
**T3-ADR-7: Aura effects (primitive `add-aura`)**
Auras are effect primitives that apply to OTHER pieces within a radius. At profile-apply time, auras create derived facts on affected pieces. Derived facts are re-computed on every move (affected pieces may change). Implementation: `effectivePieceAttrs` engine integration for aura-derived attrs.
---
## Work Objectives
### Core Objective
Ship user-authored custom modifier descriptors composed from an effect primitive catalog. Design the whole system so T4's scripted modifiers can plug in without redesigning.
### Concrete Deliverables
**Primitive catalog** (`packages/chess/src/modifiers/primitives/`):
- `types.ts` — `EffectPrimitive`, `PrimitiveKind`, primitive-specific parameter types
- `registry.ts` — `PRIMITIVE_REGISTRY`
- Individual primitive files (15 files):
- `seed-attribute.ts`, `add-to-attribute.ts`, `multiply-attribute.ts`
- `add-direction.ts`, `set-capture-flag.ts`
- `absorb-damage-with-attribute.ts`, `reflect-damage.ts`
- `block-move-type.ts`, `modify-movement-range.ts`, `override-promotion.ts`
- `add-aura.ts`
- `on-turn-start.ts`, `on-capture.ts`, `on-damaged.ts`, `conditional.ts`
- `index.ts` — barrel + side-effect registration
- `validate.ts` — descriptor-level validator (recursion depth, primitive count, parameter validation per primitive)
**Custom modifier descriptor** (`packages/chess/src/modifiers/custom/`):
- `types.ts` — `CustomModifierDescriptor`, `CustomModifierId`
- `apply.ts` — executes primitive list at profile apply time (replaces built-in `apply` for custom modifiers)
- `library.ts` — persistence at `houserules:custom-modifiers:v1`
- `schema.ts` — Zod schema for full descriptor serialization
**Registry extension** (`packages/chess/src/modifiers/registry.ts`):
- Extend `ModifierRegistryClass` with `customDescriptors: Map<string, CustomModifierDescriptor>`
- `.registerCustom(engine, descriptor)` and `.getCustom(engine, id)` methods
- `get(id)` falls back to customDescriptors if not in built-ins
**Aura engine support** (`packages/chess/src/modifiers/auras.ts`):
- `computeAuraFacts(session)` — runs all active auras, computes derived attrs, updates session
- Hooks: invoked after every move via pseudo-preset
**Multi-profile stacking** (`packages/chess/src/modifiers/apply.ts` + `reconcile.ts`):
- `applyProfilesToSession(session, profiles: readonly ModifierProfile[], layout)` — stack in order
- `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`
**Server protocol** (`packages/server/src/protocol.ts`):
- New message: `custom-modifier.register` — attach custom descriptor to room
- Extend `ModifierProfile` to include `customModifiers: readonly CustomModifierDescriptor[]` (embedded, not by-ref)
- Validator: `validateCustomDescriptor(descriptor)` runs before allowing registration
**UI** (`packages/chess/src/ui/`):
- `CustomModifierEditor.tsx` — visual primitive composer
- `PrimitivePalettePanel.tsx` — catalog of 15 primitives, drag/drop or click-to-add
- `PrimitiveInspectorPanel.tsx` — parameter editor for selected primitive
- `CustomModifierLibrary.tsx` — library drawer for saved custom modifiers
- Extend `PerTypePanel.tsx` + `PerInstancePanel.tsx` — "kind" dropdown shows custom modifiers alongside built-ins
**Profile stacking UI** (`packages/chess/src/ui/Lobby.tsx`):
- Allow selecting multiple profiles (stacked, ordered)
- Reorder via drag/drop
- Show stacked effect preview
**Docs**:
- Update `docs/adr/modifier-profiles.md` — add T3-ADR-1 through T3-ADR-7 sections
- New `docs/user/custom-modifiers.md` — user guide for authoring custom modifiers
- New `docs/adr/T4-scripted-modifiers-design.md` — forward-looking design doc
**E2E** (`packages/chess/e2e/`):
- New `custom-modifiers.spec.ts` — ~15 scenarios
### Definition of Done
- [ ] `bun run check` green
- [ ] All 15 primitives registered and individually tested
- [ ] A user can create a "Shield" custom modifier in the UI using `seed-attribute` + `absorb-damage-with-attribute` primitives
- [ ] Custom modifier saves to library, reloads after page refresh, applies to a game
- [ ] Multi-profile stacking: 2 profiles stacked → HP bonuses add, direction additions union
- [ ] Aura: a piece with `add-aura` radius=2 targetAttr=HpBonus delta=+1 gives +1 HP to all pieces within 2 squares
- [ ] Server rejects malformed custom descriptors (recursion too deep, unknown primitive, parameter out of range)
- [ ] Playwright e2e: 15 new scenarios pass in `custom-modifiers.spec.ts`
- [ ] Existing 26 T1+T2 e2e tests still pass (no regression)
- [ ] ADR + user docs + T4 design note published
### Must Have
- Full DSL with 15 T3 primitives covering the 6 built-in modifier behaviors + new capabilities (auras, conditionals, event hooks)
- Custom modifiers compose in the editor alongside built-ins
- Per-engine custom registry (no cross-room leakage)
- Multi-profile stacking with explicit priority
- Aura effects via `add-aura` primitive
- Server-side validation of custom descriptors (anti-DoS)
- T4 forward-design document
### Must NOT Have (Guardrails)
- ❌ Scripted modifiers (T4 — only the forward-design note lives here)
- ❌ Sandboxed script runtime in T3
- ❌ User-defined primitives (primitives are engine-shipped)
- ❌ Cross-room custom modifier sharing (explicit re-registration per room)
- ❌ Breaking changes to T1/T2 built-in modifiers
- ❌ Breaking WS protocol changes (custom-modifier messages are additive)
- ❌ Recursion depth > 3 in primitive nesting (DoS guard)
- ❌ More than 50 primitives per custom descriptor
- ❌ More than 10 custom modifiers per room (DoS guard)
### Must NOT Have (AI Slop Patterns)
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
- ❌ Generic names (`data`, `result`, `item`, `temp`)
- ❌ Empty catch blocks
- ❌ `console.log` in production code
- ❌ Switch statements on primitive kinds (use registry dispatch)
---
## Verification Strategy
### Test Decision
- **Infrastructure**: vitest + Playwright, same as T1/T2
- **Approach**: TDD per primitive, Playwright for UX
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t3/task-{N}-{slug}.{ext}`.
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Foundation — PARALLEL):
├── T1: T3-ADR documentation
├── T2: Primitive types + registry class
└── T3: Custom modifier descriptor types
Wave 2 (Primitive implementations — HIGHLY PARALLEL, 15 in parallel if caps allow):
├── T4-T8: 5 state primitives (seed-attribute, add-to-attribute, multiply-attribute, add-direction, set-capture-flag)
├── T9-T11: 3 damage primitives (absorb-damage-with-attribute, reflect-damage, modify-movement-range)
├── T12-T14: 3 control primitives (block-move-type, override-promotion, add-aura)
└── T15-T18: 4 event primitives (on-turn-start, on-capture, on-damaged, conditional)
Wave 3 (Integration — PARALLEL):
├── T19: Custom descriptor validator (depth cap, parameter check)
├── T20: Custom descriptor Zod schema
├── T21: Custom descriptor library persistence
├── T22: Engine integration — applyCustomDescriptor
└── T23: Multi-profile stacking in apply/reconcile
Wave 4 (Server + UI — PARALLEL):
├── T24: Server custom-modifier.register handler + validation
├── T25: CustomModifierEditor UI (primitive composer)
├── T26: Extend PerTypePanel/PerInstancePanel for custom kinds
├── T27: Multi-profile picker in Lobby
└── T28: Aura engine integration (computeAuraFacts hook)
Wave 5 (E2E + Docs — PARALLEL):
├── T29: Playwright e2e suite (15 scenarios)
├── T30: ADR updates
├── T31: User docs (docs/user/custom-modifiers.md)
└── T32: T4 forward-design document
Wave FINAL (4 parallel reviewers):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Manual QA
└── F4: Scope fidelity check
```
### Dependency Matrix (Abbreviated)
| Group | Depends On | Blocks |
|-------|------------|--------|
| T1 (ADR) | — | ALL |
| T2 (primitives registry) | T1 | T4-T18 |
| T3 (custom desc types) | T1 | T19-T28 |
| T4-T18 (primitives) | T2 | T19, T22, T25 |
| T19 (validator) | T2, T4-T18 | T24 |
| T20 (schema) | T3 | T24 |
| T21 (library) | T3, T20 | T27 |
| T22 (engine apply) | T4-T18 | T23, T28 |
| T23 (stacking) | T22 | T27, T29 |
| T24 (server) | T19, T20 | T29 |
| T25 (editor) | T4-T18 | T26, T29 |
| T26 (panel ext) | T25 | T29 |
| T27 (lobby stack) | T21, T23 | T29 |
| T28 (aura hook) | T22 | T29 |
| T29 (e2e) | T23-T28 | F1-F4 |
| T30-T32 (docs) | T1, T29 | F1 |
---
## TODOs
*(Abbreviated — each task follows the same 7-section format as T1/T2. Full details TBD when executing.)*
- [x] 1. **T3-ADR documentation**
**What to do**: Append T3-ADR-1 through T3-ADR-7 to `docs/adr/modifier-profiles.md`.
**Recommended Agent Profile**: `writing`
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
**Commit**: `docs(adr): T3 custom modifier DSL architecture decisions`
- [x] 2. **Primitive types + registry class**
**What to do**: Create `packages/chess/src/modifiers/primitives/types.ts` (EffectPrimitive, PrimitiveKind, parameter type families). Create `primitives/registry.ts` with `PRIMITIVE_REGISTRY` singleton.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 1. Blocks: T4-T18. Blocked By: T1.
**Commit**: `feat(engine): primitive types and registry`
- [x] 3. **Custom modifier descriptor types**
**What to do**: Create `packages/chess/src/modifiers/custom/types.ts` — `CustomModifierDescriptor` shape with { id, name, description, version, primitives[], targetAttrs[] }.
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 1. Blocks: T19-T28. Blocked By: T1.
**Commit**: `feat(engine): custom modifier descriptor types`
- [x] 4-18. **15 primitive implementations** (one per primitive)
**Pattern** (one task per primitive):
- Create `packages/chess/src/modifiers/primitives/{kind}.ts`:
- Export descriptor implementing `EffectPrimitive<Params>`
- Zod schema for params
- Apply function (session mutation or engine hook registration)
- Side-effect register in `primitives/index.ts`
- Create `.test.ts` with 3+ scenarios
Primitives to implement:
- T4: seed-attribute
- T5: add-to-attribute
- T6: multiply-attribute
- T7: add-direction
- T8: set-capture-flag
- T9: absorb-damage-with-attribute
- T10: reflect-damage
- T11: modify-movement-range
- T12: block-move-type
- T13: override-promotion
- T14: add-aura
- T15: on-turn-start
- T16: on-capture
- T17: on-damaged
- T18: conditional
**Recommended Agent Profile**: `unspecified-high` (all)
**Parallelization**: Wave 2 (PARALLEL). Blocks: T19, T22, T25. Blocked By: T2.
**Commits**: `feat(engine): {kind} effect primitive` (×15)
- [x] 19. **Custom descriptor validator**
**What to do**: `packages/chess/src/modifiers/custom/validate.ts` — validates a CustomModifierDescriptor:
- Every primitive kind is in PRIMITIVE_REGISTRY
- Primitive params satisfy primitive's Zod schema
- Recursion depth ≤ 3 (count nesting of on-turn-start / on-capture / on-damaged / conditional)
- Total primitive count ≤ 50
- No circular references (descriptor referencing itself is banned)
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T2, T4-T18.
**Commit**: `feat(engine): custom modifier descriptor validator`
- [x] 20. **Zod schema for custom descriptor serialization**
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T3.
**Commit**: `feat(engine): custom modifier Zod schema`
- [x] 21. **Custom modifier library persistence**
**What to do**: `packages/chess/src/modifiers/custom/library.ts` — localStorage at `houserules:custom-modifiers:v1`. Mirror T1 library API.
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 3. Blocks: T27. Blocked By: T3, T20.
**Commit**: `feat(engine): custom modifier library persistence`
- [x] 22. **Engine integration — applyCustomDescriptor**
**What to do**: `packages/chess/src/modifiers/custom/apply.ts` — when a profile's perType/perInstance entry references a custom kind, resolve the descriptor from per-engine registry, execute its primitive list on the target piece.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T23, T28. Blocked By: T4-T18.
**Commit**: `feat(engine): apply custom modifier descriptors`
- [x] 23. **Multi-profile stacking in apply/reconcile**
**What to do**: Extend `applyProfileToSession` → `applyProfilesToSession(session, profiles, layout)`. Extend `reconcileProfileSwap` → `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`. Stacking per ADR-4 rules apply across multiple profiles.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 3. Blocks: T27, T29. Blocked By: T22.
**Commit**: `feat(engine): multi-profile stacking`
- [x] 24. **Server custom-modifier.register handler**
**What to do**:
- New WS message `custom-modifier.register` with full descriptor payload
- Server validates via T19's validator
- Registers per-room (per-engine registry)
- Rejects if > 10 custom modifiers per room
- Broadcasts `custom-modifier.registered` to opponent
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T19, T20.
**Commit**: `feat(server): custom-modifier.register WS handler`
- [x] 25. **CustomModifierEditor.tsx — primitive composer UI**
**What to do**: Visual editor with:
- PrimitivePalettePanel — 15 primitives grouped by category
- Primitive tree view — shows nesting for on-turn-start / conditional
- PrimitiveInspectorPanel — parameter form per primitive, driven by primitive's Zod schema
- Add/delete/reorder primitives
- Save/load from library
- Inline validation (run T19 validator live)
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T26, T29. Blocked By: T4-T18.
**Commit**: `feat(ui): custom modifier editor`
- [x] 26. **Extend PerTypePanel/PerInstancePanel for custom kinds**
**What to do**: Kind dropdown iterates `MODIFIER_REGISTRY.list() + customRegistry.list()`. Custom kinds use the primitive composer for value input (or show a simplified parameter form).
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T25.
**Commit**: `feat(ui): custom modifiers in modifier profile panels`
- [x] 27. **Multi-profile picker in Lobby**
**What to do**: Lobby's profile picker becomes multi-select with ordering. Drag to reorder. Selected profiles stack in UI-visible order.
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T21, T23.
**Commit**: `feat(ui): multi-profile stacking in lobby`
- [x] 28. **Aura engine integration**
**What to do**: `packages/chess/src/modifiers/auras.ts` — `computeAuraFacts(session)` runs after every move via pseudo-preset. Walks all aura-primitive-declared modifiers, finds affected pieces within radius, updates derived facts.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T22.
**Commit**: `feat(engine): aura effect computation`
- [x] 29. **Playwright e2e suite (15 scenarios)**
**What to do**: Create `packages/chess/e2e/custom-modifiers.spec.ts`:
1. Open custom modifier editor from modifier profile editor
2. Create "Shield" custom modifier via primitive composer
3. Save custom modifier to library
4. Reload page → custom modifier still in library
5. Use custom modifier in a profile (per-type entry)
6. Start solo game with profile using custom modifier — verify behavior
7. Multi-profile stacking (2 profiles) — HP bonuses add
8. Aura effect — piece within radius gets derived attr
9. Aura updates after move (target moves out of radius → fact retracted)
10. Server rejects custom modifier with > 50 primitives
11. Server rejects custom modifier with > 3 recursion depth
12. Custom modifier sharing via multiplayer — both clients see same behavior
13. Conditional primitive works (if HP < 2, do X)
14. on-turn-start primitive fires
15. absorb-damage-with-attribute works (shield absorbs before HP)
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
**Parallelization**: Wave 5. Blocks: F1-F4. Blocked By: T22-T28.
**Commit**: `test(e2e): custom modifier DSL vertical slice`
- [x] 30. **ADR updates — T3 Implementation Retrospective**
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 5. Blocks: F1. Blocked By: T1, T22-T28.
**Commit**: `docs(adr): T3 implementation retrospective`
- [x] 31. **User docs: docs/user/custom-modifiers.md**
**What to do**: New user guide:
- What are custom modifiers?
- Opening the editor
- The 15 effect primitives (one subsection each with 1 example)
- Composing primitives (simple to complex)
- Saving & sharing
- Multi-profile stacking
- Aura effects (radius, affected pieces)
- Limitations (50 primitive cap, depth cap, 10 per room)
**Recommended Agent Profile**: `unspecified-low`
**Commit**: `docs(user): custom modifier DSL user guide`
- [x] 32. **T4 forward-design document**
**What to do**: Create `docs/adr/T4-scripted-modifiers-design.md`:
- Why T4 is deferred (security complexity)
- Sandbox candidates evaluated (QuickJS, Duktape, custom mini-interpreter)
- Descriptor shape extension (`type: "data" | "scripted"`)
- Permission system sketch
- Validation strategy (static analysis of script before execution)
- How T3 primitives can be migrated (scripted modifier that wraps a primitive sequence)
- Open questions
**Recommended Agent Profile**: `writing`
**Commit**: `docs(adr): T4 scripted modifiers forward-design`
---
## Final Verification Wave
- [x] F1. **Plan Compliance Audit** — `oracle`
Verify all 15 primitives, all 32 tasks' deliverables, T3-ADR decisions reflected. No T4 scripted modifiers shipped (only design doc).
Output: `Primitives [15/15] | Tasks [N/N] | ADRs [7/7] | VERDICT`
- [x] F2. **Code Quality Review** — `unspecified-high`
`bun run check`. Scan for slop. Verify registry-dispatch pattern (no hardcoded kind switches).
Output: `Build [PASS] | Lint [PASS] | Tests [N pass] | VERDICT`
- [x] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
Execute all 15 new e2e scenarios. Author a Shield custom modifier via UI, play a game with it, verify full behavior end-to-end.
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
- [x] F4. **Scope Fidelity** — `deep`
No scripted modifiers (only forward-design doc). No cross-room leakage. Recursion cap enforced. Primitive count cap enforced.
Output: `Tasks [N/N compliant] | T4 smuggling [CLEAN] | VERDICT`
---
## Commit Strategy
1. `docs(adr): T3 custom modifier DSL architecture decisions`
2. `feat(engine): primitive types and registry`
3. `feat(engine): custom modifier descriptor types`
4-18. `feat(engine): {kind} effect primitive` (×15)
19. `feat(engine): custom modifier descriptor validator`
20. `feat(engine): custom modifier Zod schema`
21. `feat(engine): custom modifier library persistence`
22. `feat(engine): apply custom modifier descriptors`
23. `feat(engine): multi-profile stacking`
24. `feat(server): custom-modifier.register WS handler`
25. `feat(ui): custom modifier editor`
26. `feat(ui): custom modifiers in modifier profile panels`
27. `feat(ui): multi-profile stacking in lobby`
28. `feat(engine): aura effect computation`
29. `test(e2e): custom modifier DSL vertical slice`
30. `docs(adr): T3 implementation retrospective`
31. `docs(user): custom modifier DSL user guide`
32. `docs(adr): T4 scripted modifiers forward-design`
## Success Criteria
```bash
bun run check # all green
bun run test packages/chess/src/modifiers/primitives/ # all 15 primitive tests pass
bun run test packages/chess/src/modifiers/custom/ # apply + validate + library pass
bunx playwright test e2e/custom-modifiers.spec.ts # 15/15 pass
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass (no regression)
```
### Final Checklist
- [ ] All 15 primitives registered and individually tested
- [ ] Custom modifier editor UI functional
- [ ] Server validates + registers custom descriptors per-room
- [ ] Multi-profile stacking works
- [ ] Auras work (derived facts update on move)
- [ ] 15 Playwright e2e + 26 existing pass (41 total)
- [ ] ADR + user docs + T4 design note published
- [ ] `bun run check` green
- [ ] F1-F4 all APPROVE
- [ ] User explicit "okay"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,203 @@
# T4 — Scripted Modifiers (Forward Design)
> **Status**: Forward design only. T4 is deferred. T3 ships data-only custom
> modifiers; this document records the design we'd reach for if and when T4
> becomes a priority.
T3 lets users compose custom modifiers from a fixed catalog of 15 primitives.
The natural next layer is letting users author primitives whose behaviour is
expressed in code (or a code-like DSL) rather than a shipped TypeScript
function. That's T4.
This document captures (a) why T4 is deferred, (b) the candidate sandbox
runtimes we evaluated, (c) the descriptor shape that preserves backwards
compatibility with T3, (d) the permission model we'd want, (e) the validation
strategy, (f) how T3 primitives migrate forward, and (g) open questions.
---
## Why T4 is deferred
The unifying concern is **security surface area**.
A T3 custom modifier can ONLY combine functions the engine already ships. A
malicious or buggy descriptor can no-op (unknown kinds skip silently) or
exhaust limits (caught by validator caps), but it can't mint new behaviour
the engine didn't already implement.
A T4 scripted modifier can express NEW behaviour written by an untrusted
author, run inside the same process the rest of the game uses. That demands
a sandbox: memory bounds, CPU bounds, deterministic execution (for
multiplayer replication), no host-API access (no `fetch`, no `Date.now()` in
the wrong place, no DOM), no infinite loops, no side channels into private
session state.
Building that sandbox correctly is a multi-week project and a permanent
maintenance burden. T3 delivers user-authored modifiers without it; T4
buys "Turing-complete user modifiers" at the cost of becoming a
sandbox-vendor team.
---
## Sandbox candidates evaluated
| Runtime | Verdict |
|---|---|
| **QuickJS (via wasm)** | Strong candidate. Mature ECMA-262 implementation, embeddable, used in production by Bun for `--smol` builds. Bun's `quickjs-emscripten` binding makes integration mechanical. CPU caps and memory caps are runtime-enforced. Determinism: would need to whitelist a subset of stdlib (no `Date.now`, no `Math.random` without a seeded shim) but well-trodden ground. **Likely T4 pick.** |
| **Duktape (via wasm)** | Smaller binary than QuickJS but lower spec compliance (ES5+). Less attractive for users writing modern JS. |
| **WebAssembly (Wasmer / Wasmtime in browser)** | Heaviest sandbox. Strongest isolation. Forces users to compile from a higher-level language (AssemblyScript, Rust). High authoring friction; better suited to a power-user tier than a casual editor. |
| **Custom mini-interpreter** | Same complexity as a vetted runtime, with less battle-testing. Rejected as not-invented-here. |
| **Web Workers** | Not a sandbox per se — same JS realm with structured cloning across the boundary. Doesn't bound CPU. Weaker isolation than QuickJS. |
| **vm2 / isolated-vm** | Server-only (Node), doesn't help our browser-runtime case. |
**Recommendation if/when T4 ships**: QuickJS via `quickjs-emscripten`, with a
whitelisted host-API surface and per-call CPU + memory caps.
---
## Descriptor shape extension
T3 reserved the discriminator field `type: "data"` on
`CustomModifierDescriptor` precisely so T4 could land alongside without
breaking changes:
```ts
type CustomModifierDescriptor =
| DataModifierDescriptor // T3 — what we ship today
| ScriptedModifierDescriptor; // T4 — future
interface DataModifierDescriptor {
readonly type: 'data';
readonly id: CustomModifierId;
readonly name: string;
readonly description: string;
readonly version: 1;
readonly primitives: readonly EffectPrimitiveNode[];
// ... other T3 fields
}
interface ScriptedModifierDescriptor {
readonly type: 'scripted';
readonly id: CustomModifierId;
readonly name: string;
readonly description: string;
readonly version: 1;
/** Source code in the chosen DSL (likely a JS subset). */
readonly source: string;
/** Compiled-and-validated bytecode hash; null = "needs compile". */
readonly hash: string | null;
/** Permissions the author requested; subject to user grant on import. */
readonly permissions: readonly Permission[];
// ... shared trunk: targetAttrs, uiForm, source, author, createdAt
}
```
The trunk fields stay identical so registry lookup, library persistence,
serialization, and the Modifier Profile picker UI all work uniformly across
both descriptor families. Only the apply-time dispatcher (and the editor's
right inspector panel) branches on `type`.
---
## Permission model sketch
Every scripted modifier declares the permissions it needs. The user grants
permissions explicitly on first use (mirroring browser permission prompts).
| Permission | Capabilities granted | Default |
|---|---|---|
| `read-self` | Read facts on the piece this descriptor is attached to | granted |
| `read-board` | Read facts on every other piece | prompt |
| `write-self` | Insert/retract facts on this piece | granted |
| `write-board` | Insert/retract facts on other pieces | prompt |
| `read-history` | Read the engine's move log | prompt |
| `emit-effect` | Push visual effects via `engine.emitEffect` | granted |
| `random` | Use the engine's seeded RNG | prompt |
Permissions are validated server-side on `custom-modifier.register` — a
descriptor with `write-board` arriving from an untrusted source is rejected
unless the room's host has explicitly opted into "allow scripted modifiers".
---
## Validation strategy
T3 validates structure (Zod) AND semantics (kind in registry, params satisfy
schemas). T4 needs a third layer: **static analysis of the script before
first execution**, to catch obvious abuse before we even spin up the sandbox.
Likely checks:
- **Source size cap** — reject scripts above N kB.
- **AST node count cap** — reject scripts above M AST nodes (parses big,
evaluates fast).
- **Forbidden globals** — reject any reference to `eval`, `Function`,
`WebAssembly`, `fetch`, `XMLHttpRequest`, `import`, `require`, `top`,
`parent`, `globalThis`, etc. Whitelist instead of blacklist.
- **Loop bounds** — flag any loop without a statically-determinable bound;
optionally inject a per-iteration CPU-budget check.
- **Recursion bounds** — flag any function calling itself without a
statically-determinable termination.
- **Permission match** — every host-API call must be reachable only when the
declared permission is granted.
The static analyser produces a verdict (`{ ok: true, hash } | { ok: false,
errors: [] }`) and is run on Save in the editor and again on
`custom-modifier.register` server-side.
The runtime sandbox enforces what static analysis can't:
- **CPU budget per apply call** — preempt and abort after N ms.
- **Memory budget** — hard cap on heap size.
- **Determinism** — seeded RNG, frozen `Date.now`, no setTimeout / setInterval
reaching outside the call.
---
## How T3 primitives migrate forward
Every T3 primitive is structurally a function `(ctx, params) => void`. A T4
scripted modifier can wrap an equivalent function written in the script
runtime, so a power user can:
1. Start with a T3 descriptor (data composition).
2. "Eject" to T4 — the editor generates the equivalent script wrapping each
primitive's behaviour, switches the descriptor's `type` to `"scripted"`,
and gives the user a starting point for further customisation.
3. Continue in T4, tweaking the generated source.
The reverse is NOT generally possible (a hand-written T4 script can express
behaviours that no combination of T3 primitives matches), but the eject path
gives users a smooth on-ramp.
---
## Open questions
These are the design decisions we haven't made yet. They block T4 kickoff:
1. **DSL surface** — full ECMAScript subset or a smaller language? A
restricted DSL is easier to validate but harder for users to learn.
2. **Multiplayer determinism** — every client must execute scripted modifiers
identically. A fundamental choice between (a) "server is authoritative,
clients receive deltas" (works today for built-ins) and (b) "clients run
the script themselves, must converge bit-for-bit" (requires deterministic
sandbox + identical inputs).
3. **Editor experience** — full text editor (Monaco / CodeMirror)? Visual
block editor (Scratch / Blockly style)? Both?
4. **Sharing trust model** — a profile that references a scripted modifier
travels with the descriptor's source. Recipients see the source plus the
declared permissions before importing. Do we also show a static-analysis
summary ("This script reads the board, writes 1 attribute, declares no
network access")?
5. **Rate limiting** — how many scripted modifiers can register per room per
minute? Per session?
6. **Script versioning** — when an author updates their scripted modifier,
does the new version replace the old in every saved profile that
referenced it (auto-update) or only on explicit user action (manual
update)?
7. **Failure mode** — when a scripted modifier throws or times out at apply
time, do we abort the move (strict) or skip the modifier and continue
(lenient)? T3's data primitives are infallible; T4's scripts aren't.
Each question is a small ADR worth of debate. We don't need to answer them
to ship T3, but we should answer 1-3 before any T4 implementation work
begins.

View file

@ -0,0 +1,939 @@
# 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.
---
## Implementation Retrospective
### Deviations from ADR
- **ADR-3 hot-swap timing**: Applied immediately on receipt rather than at
an explicit turn-boundary gate. WS message serialization per-socket
ensures a client's own move and profile swap cannot interleave. True
turn-boundary queue deferred to T2.
- **ADR-6 check 4 (DEADLOCK)**: Deferred to T2 — requires session
simulation which introduces a circular dependency with the engine.
- **ADR-3 host authority**: Profile swap allowed from host (white player)
only in T1. Two-player consent model deferred to T2.
- **Zod v3/v4 mismatch**: Server pins Zod v3; chess package moved to v4.
Schemas mirrored locally in the server package with a compile-time
key-parity guard.
### Discovered patterns
- `MODIFIER_REGISTRY` mirrors `PRESET_REGISTRY`/`LAYOUT_REGISTRY` exactly —
confirmed the side-effect import pattern scales cleanly.
- `__modifier-profile-integration__` pseudo-preset registered by
`applyProfileToSession` integrates `CaptureFlags` and
`DirectionAdditions` into the existing hook pipeline without modifying
`engine.ts`.
- `reconcileProfileSwap` uses retract-then-reapply semantics — idempotent
and straightforward to reason about.
---
---
## T2-ADR-1: Turn-boundary queue
### Decision
Hot-swap updates are enqueued server-side (`room.pendingProfile`) and applied at the next turn boundary (after `applyMove()` succeeds), not immediately on receipt.
### Rationale
T1 shipped an immediate-apply simplification (profile swap took effect the moment the server received `modifier-profile.update`). The retrospective noted this was acceptable because WS messages serialize per-socket — a client can't interleave its own move and swap. But:
- The **opponent** can still have a move in-flight when the swap lands, causing move validation to run against a profile they didn't agree to.
- Observers/spectators (future T3+ concern) see inconsistent ordering.
- Determinism goal: the game state at turn N is fully determined by {profile at turn N, moves 1..N}.
Enqueuing eliminates the race.
### Semantics
- On `modifier-profile.update` (or on `consent=approve` per T2-ADR-2): server validates shape + stores in `room.pendingProfile`, acks sender with `modifier-profile.queued`.
- After the next move applies successfully: server runs `validateProfile(pending, layout, session)`. If valid, `reconcileProfileSwap(session, room.profile, pending, layout)`; broadcast `modifier-profile.updated`; clear pending.
- If invalid at apply time: NACK to original sender with specific error code, clear pending, no broadcast.
- **Last-write-wins**: rapid successive updates replace the pending slot. Only the most recent pending fires on the next boundary.
### Rejected Alternatives
- Per-socket move-boundary lock — forces sender to wait synchronously for their own next move before the swap is acked. Poor UX; WS doesn't guarantee reply ordering anyway.
- Queue per sender — multiple pending profiles per room, applied in order. Nondeterministic interaction if both players queue updates simultaneously. Last-write-wins is simpler and sufficient.
---
## T2-ADR-2: Two-player consent
### Decision
In multiplayer rooms, a profile swap requires both players' agreement. Host sends `modifier-profile.propose`; opponent reviews and sends `modifier-profile.consent` with approve/reject. Approve promotes the proposal to the pending-queue (T2-ADR-1). Reject clears and notifies the host. 60s timeout → auto-reject.
### Rationale
T1 granted host unilateral authority to change rules mid-game. This is an unfair advantage model; chess variants are a social contract. Both players must opt-in to rule changes.
Solo mode and single-player "vs. bot" contexts bypass consent — the sole participant is trivially the sole consenter. `modifier-profile.update` remains valid in solo mode as a shortcut.
### Semantics
- Host → server: `{type: "modifier-profile.propose", roomCode, candidate: ModifierProfile}`.
- Server validates shape + stores `room.proposalState = {profile, proposedBy, proposedAt, timeoutHandle}`.
- Server → opponent: `{type: "modifier-profile.proposal-pending", profile, expiresAt}`.
- Opponent → server: `{type: "modifier-profile.consent", roomCode, decision: "approve" | "reject"}`.
- On approve: clear proposalState, promote to `room.pendingProfile` (T2-ADR-1), server → host: `modifier-profile.consent-received`.
- On reject or 60s timeout: clear proposalState, server → both: `modifier-profile.rejected`.
- Only the non-proposer can consent. Self-consent is rejected.
- Stale proposals (after a newer `propose` replaces them): old timeout cancelled, old state overwritten.
### Rejected Alternatives
- Majority-vote model for 3+ player scenarios — not applicable (chess is 2-player).
- Silent auto-approve with opt-out grace period — violates the social-contract principle.
- Host-only with explicit "I agree to changes" checkbox at game start — inflexible; players may change their minds after seeing how a rule plays out.
---
## T2-ADR-3: Editor undo/redo
### Decision
The Modifier Profile Editor maintains a client-side snapshot stack of working-profile states. Every meaningful user action (add/delete/edit modifier, change bound layout) pushes a snapshot. Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes. Stack capped at 50 snapshots (oldest dropped). History cleared on Save or Cancel.
### Rationale
Users compose profiles iteratively; mistakes are common (added wrong modifier, wrong color, wrong square). Manual deletion is destructive and loses intermediate states. Undo/redo is standard for structured editors.
Capped at 50 snapshots to bound memory. Cleared on Save because the saved state is the new baseline (undoing past Save would revert persisted data, which is surprising).
### Semantics
- Editor state: `{history: ModifierProfile[], historyIndex: number, clipboard: Modifier[]}`.
- `currentProfile = history[historyIndex]`.
- Every mutation: `pushSnapshot(newProfile)`:
1. Truncate history forward of `historyIndex` (redo branches lost).
2. Append newProfile.
3. If `history.length > 50`, drop the oldest entry and decrement historyIndex.
4. Set `historyIndex = history.length - 1`.
- `undo()`: `historyIndex = max(0, historyIndex - 1)`.
- `redo()`: `historyIndex = min(history.length - 1, historyIndex + 1)`.
- Toolbar buttons disabled at stack ends.
- Save or Cancel: `setHistory([currentProfile]); setHistoryIndex(0)` — fresh single-entry stack for a new session.
### Rejected Alternatives
- Infinite history — memory unbounded. 50 is generous for a single editing session.
- Per-modifier undo (like per-field undo in some editors) — over-granular for structured data edits.
- Persist history to localStorage — adds complexity for marginal benefit; users expect editor state to reset between sessions.
---
## T2 Implementation Retrospective
### T1 simplifications resolved
- **Immediate-apply hot-swap → turn-boundary queue (T2-ADR-1)**: T1's `handleModifierProfileUpdate` applied profile changes immediately on receipt; T2's implementation enqueues into `Room.pendingProfile` and applies via `applyPendingProfileIfAny` after the next successful `applyMove`. NACKs at apply time (re-validation against post-move session) route to the original proposer's socket via `findSocketByToken`.
- **Host-only authority → two-player consent (T2-ADR-2)**: T1 broadcast modifier swaps from any host message; T2 requires the opponent to send `modifier-profile.consent` with `approve` before the swap is enqueued. Solo-mode preserves the host-shortcut path (`modifier-profile.update`) since the sole participant is trivially the sole consenter.
### Discovered patterns
- **Reused `modifier-profile.queued` ack for proposer** rather than introducing a `modifier-profile.proposal-sent`. Client receipt handlers stay uniform; the observable difference is the opponent's wire traffic (`proposal-pending` vs. silence). Documented in protocol.ts.
- **Capture-phase `stopImmediatePropagation` for nested modal Esc handling**: ModifierProfileEditor's Esc handler now uses capture phase with `stopImmediatePropagation` so the RulesDrawer's own Esc handler does NOT also fire. Without this, both would close on a single keystroke, leaving the drawer's pointer-events backdrop briefly lingering. Discovered post-T1 via the solo-smoke regression test.
- **Token-keyed pending-proposer tracking**: `Room.pendingProposerToken` (and `proposalState.proposedByToken`) survive socket reconnects. NACK routing on apply-time validation failure finds the proposer by token, not socket id, so a brief disconnect doesn't lose the rejection notification.
- **Timeout stale-handle guard**: 60s proposal timeouts use `setTimeout` whose handler checks `room.proposalState !== currentProposalRef` before firing. Prevents firing rejection broadcasts on stale (already-consented or already-superseded) proposals.
- **Client-side undo/redo via `pushSnapshot`**: ModifierProfileEditor maintains its history-stack purely client-side. The 50-snapshot cap is generous for a single editing session. History clears on Save/Cancel (the saved state becomes a new baseline).
- **Component-local clipboard for copy/paste**: No OS clipboard interaction. Cross-panel sharing goes through a `clipboard` state lifted to the editor root. Dedup by `(pieceType, color, kind)` for type modifiers and `(square, kind)` for instance modifiers prevents pastes from creating additive duplicates.
- **Inline conflict resolution via `applyFix` dispatcher**: Each error/warning code routes to a specific resolution. `E_PROFILE_NO_KING` and `E_PROFILE_ATTR_LIMIT` are advisory-only (require user judgement); the rest auto-resolve.
### Deviations
- None — implementation matched ADRs as written.
---
## T3 Architecture Decisions
## T3-ADR-1: Custom modifier DSL is data-only (no runtime code execution)
### Context
T3 introduces user-authored modifier categories. The core design question is
whether user-authored behavior should be represented as executable code or as
structured data composed from engine-shipped operations.
### Decision
Custom modifiers are expressed as validated, structured JSON descriptors
composed from a fixed primitive catalog. The descriptor itself is the DSL.
T3 does not execute user-provided scripts, evaluate strings, or parse a
free-form mini-language.
### Rationale
- **Security first.** Data validation is materially safer than executing
untrusted code in the game server or client.
- **Deterministic behavior.** A finite primitive set gives explicit,
testable semantics and removes runtime ambiguity.
- **Authoring ergonomics.** A visual editor can expose primitives without
requiring users to write or debug code.
- **Operational simplicity.** No sandbox runtime, resource metering, or
script lifecycle management is required in T3.
### Rejected Alternatives
- **Embedded script runtime (e.g., QuickJS) in T3.** Too large a security and
maintenance surface for this milestone.
- **AST-backed custom language in T3.** Similar complexity class to scripting,
but with fewer ecosystem benefits.
- **String-template rule snippets.** Too weak to represent planned behavior,
yet still introduces parsing edge cases.
### Consequences
- Custom modifiers are fully serializable, inspectable, and diff-friendly.
- Validation becomes schema-driven and server-enforceable.
- T3 scope stays focused; script extensibility is intentionally deferred.
- **Example:** A "Shield" modifier is represented as two primitives,
`seed-attribute(attr="ShieldCharges", value=3)` and
`absorb-damage-with-attribute(attr="ShieldCharges", rate=1)`, with no
user code execution path.
---
## T3-ADR-2: Primitive catalog v1 contains 15 composable primitives
### Context
If the DSL is data-only (T3-ADR-1), the primitive catalog defines the
expressive boundary of T3. The catalog must cover existing modifier behavior
plus key new capabilities (events, conditionals, aura-like effects).
### Decision
T3 v1 ships exactly this 15-primitive catalog:
1. `seed-attribute`
2. `add-to-attribute`
3. `multiply-attribute`
4. `add-direction`
5. `set-capture-flag`
6. `absorb-damage-with-attribute`
7. `reflect-damage`
8. `block-move-type`
9. `add-aura`
10. `on-turn-start`
11. `on-capture`
12. `on-damaged`
13. `conditional`
14. `modify-movement-range`
15. `override-promotion`
Each primitive has a typed parameter schema, registry entry, and engine
integration path.
### Rationale
- **Coverage.** The set spans additive stats, movement controls, damage
behavior, event-driven effects, and branching logic.
- **Extensibility by addition.** New primitives can be added as isolated files
without redesigning the DSL contract.
- **Predictable validation.** Primitive-specific schemas allow clear bounds and
error reporting.
### Rejected Alternatives
- **Smaller initial catalog.** Reduced immediate utility; users would quickly
hit expressiveness gaps.
- **Open-ended primitive parameters (`Record<string, unknown>`).** Weak type
guarantees and poor editor UX.
- **Monolithic "do-everything" primitive.** Hard to validate, reason about,
or compose safely.
### Consequences
- T3 ships with a bounded but practical authoring surface.
- Engine and UI both rely on one canonical primitive registry.
- Future primitives are additive and backward-compatible.
- **Example:** A low-HP panic behavior can be authored with
`conditional(condition: hp<2, then:[modify-movement-range(+1)], else:[])`.
---
## T3-ADR-3: Authoring scope is per-room runtime, per-user library, and embedded sharing
### Context
Custom modifiers must be reusable for authors, isolated for active games, and
portable when profiles are shared.
### Decision
- **Runtime registration scope:** per-room via WebSocket payloads.
- **Author storage scope:** per-user local library at
`houserules:custom-modifiers:v1`.
- **Sharing model:** profiles embedding custom kinds include full descriptor
payloads (not by-reference ids only).
- **Versioning model:** descriptor has `version: 1`; incompatible evolution is
represented as a new modifier id/versioned descriptor.
- **Validation guards:** server validates primitive membership, parameter
bounds, nesting depth `<= 3`, and total primitive count `<= 50`.
### Rationale
- **Isolation.** Per-room runtime registration prevents cross-room leakage.
- **Usability.** Local library supports iterative authoring without server
persistence dependency.
- **Portability.** Embedding descriptors makes shared profiles self-contained.
- **Safety.** Guardrails cap complexity and reduce abuse/DoS risk.
### Rejected Alternatives
- **Global process-wide custom registry.** Risks leaking user-defined behavior
between unrelated rooms.
- **By-reference sharing only (id lookup).** Breaks when recipient does not
already have that descriptor in their library.
- **Unbounded nesting/size.** Opens denial-of-service and validation-time
blowups.
### Consequences
- Room creation/join paths must synchronize embedded custom descriptors.
- Validation errors must be protocol-visible and actionable.
- Descriptor transport payload size is larger but deterministic.
- **Example:** Profile `aggressive-pack-v1` includes `shield-v1` inline; when
imported by another user, the room can register and use `shield-v1` even if
that user had no prior local copy.
---
## T3-ADR-4: Custom descriptor registry is per-engine (room), not global
### Context
Built-in modifier descriptors are static and safe to keep in the global
registry. User-authored descriptors are dynamic and room-specific.
### Decision
Keep built-ins in module-level `MODIFIER_REGISTRY`. Add a per-engine custom
registry (`customModifiers: Map<string, CustomModifierDescriptor>`) and make
descriptor resolution consult per-engine custom entries when global built-ins
miss.
### Rationale
- **Correct scoping.** Room-specific rules remain room-specific.
- **Zero regression for built-ins.** Existing built-in lookup remains stable.
- **Compatibility with existing architecture.** Resolution still flows through
the same descriptor interface.
### Rejected Alternatives
- **Process-global custom registration.** Causes rule contamination across
sessions.
- **Separate custom-only lookup path everywhere.** Increases branching and
duplicates integration logic.
- **Namespace-prefix hacks in a single map.** Couples isolation to naming
discipline rather than architecture.
### Consequences
- Engine APIs that resolve modifiers need engine context.
- Room teardown naturally drops custom descriptors with engine lifecycle.
- Debugging remains straightforward: built-ins global, customs room-local.
- **Example:** Room A registers `berserk-v1`; Room B does not. `get("berserk-v1")`
succeeds in Room A context and fails in Room B context without any global
side effect.
---
## T3-ADR-5: T4 forward design preserves one integration seam
### Context
T3 explicitly avoids runtime scripting, but the architecture should not require
an overhaul if T4 introduces scripted modifiers.
### Decision
Define T3 custom descriptors as `type: "data"` and reserve a parallel
`type: "scripted"` branch for T4. Both forms target the same high-level
descriptor integration seam (`apply`-style engine contract), while validation
branches by descriptor type.
### Rationale
- **Future-proofing without scope creep.** T3 remains data-only while enabling
a clean T4 extension path.
- **Minimized migration risk.** Existing plumbing (registry, protocol,
profile embedding) remains reusable.
- **Separation of concerns.** Script security and sandboxing can be handled in
T4-specific validation/execution modules.
### Rejected Alternatives
- **Hardcode T3 as the only forever model.** Forces breaking redesign for T4.
- **Partially ship script hooks in T3.** Expands attack surface before policy,
sandbox, and permissions are ready.
- **Completely separate scripted architecture.** Duplicates profile and
registry plumbing.
### Consequences
- T3 descriptors and validators stay simple and strict.
- T4 can be additive via new descriptor type and validator branch.
- Docs can communicate a clear migration path from data to scripted forms.
- **Example:** A future `type:"scripted"` modifier may implement the same
combat buff as today's `add-to-attribute`, but still enters the engine via
the same descriptor-resolution and apply integration seam.
---
## T3-ADR-6: Multiple active profiles stack in explicit order
### Context
T1/T2 assume one active profile at a time. T3 primitives and custom modifiers
increase composition use cases where users want layered rule bundles.
### Decision
Engine state moves from one active profile to an ordered
`activeProfiles: readonly ModifierProfile[]`. Profile effects stack across
the list using existing per-modifier stacking semantics (additive, union,
multiplicative, or precedence-wins as defined by prior ADRs). Default order is
registration order; UI can expose explicit reordering.
### Rationale
- **Composability.** Users can combine orthogonal profile concepts without
pre-merging into a single artifact.
- **Reuse.** Small focused profiles become building blocks.
- **Determinism.** Explicit ordering eliminates ambiguity for override-style
collisions.
### Rejected Alternatives
- **Single-profile only forever.** Limits expressiveness and reusability.
- **Implicit/unordered merge.** Nondeterministic outcomes for override kinds.
- **Auto-merge into synthetic profile client-side.** Harder to debug and easy
to desync with server authority.
### Consequences
- Apply/reconcile APIs must accept arrays, not single profile objects.
- UI must communicate stack order and precedence implications clearly.
- Validator and diagnostics need to report cross-profile conflicts.
- **Example:** Profile A gives `HpBonus +2`, Profile B gives `HpBonus +1`; with
stacking active, affected pieces resolve to `+3` total HP bonus.
---
## T3-ADR-7: Aura primitive semantics are derived and recomputed
### Context
`add-aura` introduces cross-piece effects whose targets vary with board
position. Static one-time application is insufficient because piece movement
changes who is in range.
### Decision
Aura effects are treated as **derived facts**:
- Source piece declares `add-aura(radius, targetAttr, delta)`.
- Engine computes affected pieces by distance at evaluation time.
- Derived aura contributions are recomputed after each move and reconciled with
current board state.
- Aura-derived facts feed into normal effective-attribute resolution and stack
with other modifiers using existing rules.
### Rationale
- **Correctness over time.** Moving pieces in/out of range updates outcomes
immediately and deterministically.
- **Single mental model.** Aura outputs become just another attribute source in
the resolved fact set.
- **Implementation locality.** Recompute hook can live in a focused aura
integration path without mutating core descriptor contracts.
### Rejected Alternatives
- **Apply aura once at profile load.** Becomes stale as soon as pieces move.
- **Event-driven partial updates only.** Harder to guarantee correctness for
all movement/capture transitions.
- **Dedicated aura-only buff subsystem.** Duplicates stacking/resolution logic.
### Consequences
- Post-move recomputation is required for correctness.
- Performance budget must account for board-wide aura evaluation.
- Tooling/debug UI should expose which aura sources contribute to each piece.
- **Example (king aura):** A king with `add-aura(radius=2, targetAttr=HpBonus,
delta=+1)` grants `HpBonus +1` to all allied pieces within two squares. If a
knight moves from distance 1 to distance 3 from that king, the bonus is
removed on the next recomputation pass.
---
## T3 Implementation Retrospective
What changed between the T3 plan and the shipped reality, what worked, what
hurt, and the open work the team should pick up next.
### Scope delivered
All 32 implementation tasks shipped across five waves:
- **Wave 1**: ADR doc + primitive types/registry + custom descriptor types.
- **Wave 2**: 15 effect primitives (state / mechanic / advanced clusters).
- **Wave 3**: descriptor validator + Zod schema + library persistence + apply
integration + multi-profile stacking.
- **Wave 4**: server `custom-modifier.register` handler + `CustomModifierEditor`
primitive composer + panel kind-dropdown extension + lobby multi-profile
stack picker + aura recomputation hook.
- **Wave 5**: e2e suite + this retrospective + user docs + T4 forward-design.
Final test counts: 1378 unit tests + the new e2e suite, all green.
### What deviated from the plan
#### Trigger/conditional primitives seed facts but don't (yet) fire
`on-turn-start`, `on-capture`, `on-damaged`, and `conditional` all register and
seed `OnTurnStartHooks` / `OnCaptureHooks` / `OnDamagedHooks` /
`ConditionalHooks` facts on the target piece — but the engine pipeline that
SHOULD evaluate those hooks at the corresponding game phase is not wired up.
The data flows; the runtime trigger evaluation is deferred.
The fact-seeding still has user value (the inspector can show "this piece has 1
on-capture trigger"), but the primitives currently behave as data declarations,
not active behaviour. T22's `applyCustomDescriptor` walks `childPrimitives()`
recursively at apply time, so nested primitives DO run when the descriptor is
applied — what doesn't yet happen is "fire on-capture's primitives WHEN this
piece captures".
Wiring is mechanical (the engine already exposes `onAfterMove`/`onDamage` hook
points used by the modifier-integration preset for `DamageResistance`); the
follow-up should replicate that pattern for the four trigger attrs.
#### Aura contributions land in `AuraContributions` but no consumer reads them
T28 ships `computeAuraFacts` and wires it to `onAfterMove` so contributions
recompute correctly after every move. They land on each affected piece as
`AuraContributions: Record<targetAttr, delta>`. **However**, no engine
subsystem currently reads that record when computing effective attribute
values — `HpBonus` consumers see only the directly-seeded value, not the
aura-derived addition.
The infrastructure is correct (idempotent, source-move retracts contribution,
multi-source accumulation). The next step is "effective attr read points layer
both the direct fact AND the aura contribution" — a small but careful change
in HP / Range / DirectionAdditions consumers.
#### Multi-profile stacking is solo-only on the wire
T27 ships the lobby UI for ordered profile stacks. The engine
(`applyProfilesToSession`) already supports the stack natively. The wire
protocol, however, still carries one `ModifierProfile` per `room.create` /
`modifier-profile.update` payload — multiplayer rooms can use a single profile
each. Stacking on the wire would extend `RoomCreatePayloadSchema.profile?` to
`profiles?: ModifierProfile[]` and update the consent flow (T2-ADR-2) to apply
to a stack. Out of scope for T3.
#### Server-side semantic validation deferred
The server runs Zod structural validation on incoming `custom-modifier.register`
descriptors but does NOT validate primitive-kind-in-registry or per-primitive
params satisfaction. Doing so would require mirroring the entire 15-primitive
catalog into the Zod-v3 server package across the v3↔v4 schema boundary.
Instead: the client validates with `validateCustomDescriptor` BEFORE sending,
and the engine's runtime applier (`applyCustomDescriptor`) silently skips
unknown primitive kinds on the apply path. Net effect: a malicious or buggy
client can register a structurally-valid descriptor whose primitives quietly
no-op. Consequence: low-severity (no incorrect game behaviour can leak to
opponent), but a server-side semantic gate is the natural T3.1 hardening.
### What worked well
- **Per-engine `CustomModifierRegistry` (ADR-4) prevents cross-room leakage by
construction**, not by discipline. We never had to hunt down a "this
descriptor mysteriously appeared in another room" bug because the type
system makes it impossible.
- **`childPrimitives()` as the composable recursion contract** turned the
"validate depth ≤ 3" and "walk for apply" requirements into one-liner
consumers. The same callback drives the validator's tree walk and the
applier's recursive descent.
- **Zod schema as both wire validator AND structural truth** kept the
custom-descriptor shape from drifting between protocol layer and runtime
type. The single boundary cast in `parseCustomModifierDescriptor` is
documented and small.
- **15 separate primitive files (one each)** kept individual change sets
reviewable and made `git blame` informative for each primitive's evolution.
### What hurt
- **Long parallel agent runs blew the tool-call cap repeatedly.** Multiple
Wave 2 / Wave 3 / Wave 4 batches were cancelled mid-flight after the
delegated agent burned 200 tool calls on verification loops without
committing. Reconciling partial work consumed orchestrator time we wanted
to spend on Wave 5.
Mitigation for future epics: prompt agents to commit incrementally (every
2-3 files) rather than batching all commits at the end. Even better,
decompose the cluster prompts further (one primitive = one delegation)
even at the cost of more orchestration overhead.
- **`as unknown as X` in lazy schemas required a documented exception.**
T20's recursive `EffectPrimitiveNodeSchema` couldn't cleanly satisfy
`z.ZodType<EffectPrimitiveNode>` because Zod infers `kind: string` and
the type wants `kind: PrimitiveKind`. We kept a single boundary cast in
`parseCustomModifierDescriptor` rather than restructuring the type.
- **The chess-side Zod v4 vs server Zod v3 mismatch** forced us to mirror
schemas across two packages by hand. Moving the chess and server packages
onto the same Zod major would simplify a lot, but is a separate migration.
### Open follow-ups
| Item | Effort | Priority |
|---|---|---|
| Wire trigger primitives (on-turn-start/on-capture/on-damaged) into the engine pipeline | Medium | High — needed before custom modifiers feel "alive" |
| Layer `AuraContributions` into HP/Range read sites | Small | High — same |
| Server-side semantic validation of `custom-modifier.register` payloads | Medium | Medium — current degradation is silent |
| Multi-profile on the wire for multiplayer | Medium | Medium — solo-only is a clean stop-gap |
| Visual editor: drag-to-reorder primitives in the tree | Small | Low — polish |
| Visual editor: nested-tree inspector (currently JSON textarea fallback) | Medium | Low — polish |
| `CustomModifierEditor` Playwright coverage beyond happy-path | Small | Medium |

View file

@ -0,0 +1,284 @@
# Custom Modifiers — User Guide
Houserules ships with six built-in modifiers (HP Bonus, Range Bonus, Direction
Additions, Capture Flags, Promotion Override, Damage Resistance). Custom
modifiers let you author your own from a fixed catalog of 15 reusable effect
primitives — no programming required.
A custom modifier is a named, reusable bundle of primitive effects that any
modifier profile can reference, exactly the same way it references a built-in.
---
## Opening the editor
1. Open the **Modifier Profile** editor (`+ Modifier Profile` from the rules
drawer, or via the layout's modifier-profile picker).
2. In the editor's header, click **+ Custom Modifier**. The Custom Modifier
editor opens as a nested modal.
3. The Custom Modifier editor is a 3-column workspace:
- **Left**: primitive palette, grouped by category.
- **Center**: the descriptor's primitive tree (the composition you're
building).
- **Right**: parameter inspector for the selected primitive.
Each descriptor needs a **name** (1-40 chars) and an optional **description**
(0-200 chars). The header also exposes **Save**, **Load from library**, and
live validation status.
---
## The 15 effect primitives
Primitives are atomic, composable, and pure data. Click any palette entry to
add it to the current descriptor's tree.
### State primitives
These mutate facts on the piece at apply time.
#### `seed-attribute`
Seeds a fact `{ attr, value }` on the piece. Overwrites any existing value.
> **Example**: `attr=Hp, value=5` — gives the piece 5 HP regardless of the
> baseline.
#### `add-to-attribute`
Reads the existing numeric value of `attr` (treats absent as 0) and writes
`existing + delta`.
> **Example**: `attr=HpBonus, delta=2` — adds +2 to whatever HpBonus is
> already there.
#### `multiply-attribute`
Reads the existing numeric value of `attr` (no-op if absent) and writes
`existing * factor`.
> **Example**: `attr=Hp, factor=2` — doubles HP.
#### `add-direction`
Appends named directions into `DirectionAdditions`. Composes additively with
the built-in Direction Additions modifier — both write to the same fact and
deduplicate by direction name.
> **Example**: `directions=[backward]` — adds backward movement.
#### `set-capture-flag`
ORs a `CaptureFlag` bitflag into the piece's `CaptureFlags`.
> **Example**: `flag=CANNOT_BE_CAPTURED` — makes the piece untargetable.
### Mechanic primitives
These plug into the engine's existing pipelines (damage, movement, promotion).
#### `absorb-damage-with-attribute`
Seeds `{ AbsorbDamageAttr, AbsorbDamageRate }`. Each incoming damage point
consumes `rate` of `attr` instead of HP, until `attr` is exhausted.
> **Example**: `attr=ShieldCharges, rate=1` — paired with a `seed-attribute`
> for `ShieldCharges=3` produces a 3-charge shield.
#### `reflect-damage`
Seeds `ReflectDamagePercent`. When this piece takes damage, `percentage%` is
reflected back to the attacker.
> **Example**: `percentage=50` — half-reflective armour.
#### `modify-movement-range`
Composes additively with the built-in Range Bonus.
> **Example**: `delta=1` — adds +1 to the piece's range.
#### `block-move-type`
Filters out moves matching the given type (`capture` / `step` / `slide`).
> **Example**: `moveType=capture` — pacifist piece, can move but not capture.
#### `override-promotion`
Sets `PromotionOverride` to a target piece type. Mirrors the built-in
Promotion Override modifier.
> **Example**: `target=knight` — pawns promote to knights only.
### Advanced primitives
These compose other primitives.
#### `add-aura`
Seeds an `AuraSpec` entry. Every piece within `radius` (Chebyshev / king-move
distance) gets `delta` added to `targetAttr`.
> **Example**: `radius=2, targetAttr=HpBonus, delta=1` — every piece within 2
> squares gets +1 HP.
Auras recompute after every move. A piece moving INTO range picks up the
contribution; a piece moving OUT loses it on the next pass.
#### `on-turn-start`
Wraps a list of nested primitives that run when this piece's color begins a
turn.
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
> — heals 1 HP each turn.
#### `on-capture`
Wraps a list of nested primitives that run when this piece captures another.
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
> — vampire piece, heals on capture.
#### `on-damaged`
Wraps a list of nested primitives that run when this piece takes damage.
> **Example**: `primitives=[{kind: 'reflect-damage', params: {percentage: 25}}]`
> — auto-reflects on damage.
#### `conditional`
Branches on a condition and runs the matching primitive list.
Supported condition types:
- `attr-lt` / `attr-gt` — numeric comparison
- `attr-eq` — exact match (string / number / boolean / null)
- `always` — runs `then`
- `never` — runs `else` (or no-op if no else)
> **Example**:
> ```
> condition: { type: 'attr-lt', attr: 'Hp', value: 2 }
> then: [{ kind: 'set-capture-flag', params: { flag: 'CANNOT_BE_CAPTURED' }}]
> ```
> — when low on HP, becomes invulnerable.
> **Note (T3 limitation)**: Trigger primitives (`on-turn-start`, `on-capture`,
> `on-damaged`) and `conditional` currently SEED their hook facts on the piece
> but the engine pipeline that fires them at the corresponding game phase is
> not yet wired in T3. The data is correct; the runtime trigger evaluation
> ships in a follow-up.
---
## Composing primitives — simple to complex
### Simple: a "boosted pawn"
One `add-to-attribute` primitive with `attr=HpBonus, delta=2`. Save as
"Boosted Pawn". Reference from a profile's per-type entry to give every white
pawn +2 HP.
### Medium: a "shield"
Two primitives:
1. `seed-attribute`: `attr=ShieldCharges, value=3`
2. `absorb-damage-with-attribute`: `attr=ShieldCharges, rate=1`
Pieces start with 3 shield charges; each damage point depletes one charge
before HP.
### Complex: an "aura king"
One primitive:
1. `add-aura`: `radius=2, targetAttr=HpBonus, delta=1`
Apply to the king's per-type entry. Every piece within 2 squares of the king
gets +1 HP.
---
## Saving and loading
The editor's **Save** button writes the current descriptor to your local
**custom modifier library** (storage key `houserules:custom-modifiers:v1`).
**Load from library** opens a picker to recall any saved descriptor.
The library is local to your browser. To share, paste the JSON shape of a
saved descriptor — or use the modifier profile sharing flow (which embeds the
custom descriptor in the share payload).
### Library limits
- **20 descriptors per library** — same FIFO eviction rule as profiles
(oldest non-starred entry is evicted; star to protect).
- **10 descriptors per multiplayer room** — a room can register up to 10
custom descriptors. Servers reject the 11th.
---
## Using a custom modifier in a profile
Open the Modifier Profile editor, add a **Per-Type** or **Per-Instance** entry
(left or center panel), and pick your custom descriptor from the **Kind**
dropdown. Custom descriptors appear in the dropdown under a **Custom (from
library)** group.
Custom modifiers don't take a per-instance value — the descriptor's primitive
list is the entire payload. The editor displays a small summary card
("Custom modifier — N primitives") instead of a value input.
---
## Multi-profile stacking
The lobby's profile picker stays single-select for the primary profile. Below
it, a **Stacked (solo only)** list lets you append additional profiles in
order. Use the up/down arrows to reorder.
When multiple profiles are active, contributions stack across profiles per the
same per-kind rules used within a single profile. The "priority-wins" rule
(promotion override, capture flags) gives the LAST profile in the list
precedence.
> **Limitation (T3)**: Multi-profile stacking is solo-only on the wire.
> Multiplayer rooms still send one profile per `room.create`. Wire-level
> stacking is a follow-up.
---
## Aura effects in detail
`add-aura` measures distance with the **Chebyshev metric** (king-move
distance): two squares are within radius R when `max(|file_a − file_b|,
|rank_a − rank_b|) ≤ R`. Radius 1 covers the 8 neighbours; radius 2 covers a
5×5 box minus the source square.
Auras recompute after every successful move. If a source piece moves OUT of
range of a target, the contribution is retracted on the next compute. Multiple
auras to the same `targetAttr` accumulate additively.
Self-application is skipped — an aura's source piece never affects itself.
> **Limitation (T3)**: `AuraContributions` are written correctly, but most
> attribute consumers (HP/Range read points) don't yet layer the aura
> contribution on top of the directly-seeded value. The data is there;
> consumer wiring is a follow-up.
---
## Limits and DoS guards
- **50 primitives total per descriptor** (counted recursively across nested
trees).
- **Recursion depth ≤ 3** — `on-turn-start` containing `on-capture`
containing `conditional` is the maximum nesting depth allowed.
- **20 descriptors per library** with starred-aware FIFO eviction.
- **10 descriptors per multiplayer room**, server-enforced.
- **Name 1-40 chars, description 0-200 chars** — descriptor metadata.
The editor's footer shows live validation against all of these. A descriptor
that fails any check is rejected on Save with the failing rule highlighted.

View file

@ -0,0 +1,217 @@
# Modifier Profiles
Modifier Profiles let you attach rule modifiers to pieces — either globally by
piece type ("all white knights get +2 HP") or specifically by board position
("the knight that starts on b1 has +1 range"). Profiles are saved to a personal
library, shareable via URL, and can be swapped mid-game.
---
## Opening the Editor
From any game view: click the **rules drawer** (⚙ icon) → **Modifier Profiles**.
From the lobby: click **Custom…** in the Profile Picker next to the Layout Picker.
---
## Per-Type vs Per-Instance
| | Per-Type | Per-Instance |
| ---------------- | ---------------------------------------- | --------------------------------------------------- |
| Targets | All pieces of a given type + color | One specific piece at a named board square |
| Example | "All white knights: HP +3" | "The knight starting on b1: Range +1" |
| Requires layout? | No | Yes — square must exist in the chosen layout |
---
## The 6 Modifier Categories
### HP Bonus
Add or subtract hit points from a piece's base HP.
Values: integer from −10 to +10. Stacks additively from all sources.
**Example**: Knight (white) HP +3 — the knight now requires 3 more attacks to
eliminate.
---
### Range Bonus
Extend the sliding distance of rooks, bishops, and queens.
Values: integer 0–7. Capped at maximum board range.
**Example**: Rook (white) Range +1 — the rook can see one extra square per ray
when unblocked.
---
### Direction Additions
Add new movement directions to a piece (1-square step moves only).
Values: a set of directional flags: forward, backward, left, right,
diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.
**Example**: Pawn (white) + backward — pawns can also retreat one square to an
empty square.
---
### Capture Flags
Toggle special capture behavior.
Flags:
- **CAN_CAPTURE_OWN** — piece may capture friendly pieces
- **CANNOT_BE_CAPTURED** — piece is immune to direct capture (cannot be used on kings)
- **EN_PASSANT** — future flag for custom en-passant rules
**Example**: Bishop + CANNOT_BE_CAPTURED — no enemy piece can legally capture it.
---
### Promotion Override
Force a pawn to always promote to a specific piece, or disable promotion
entirely.
Values: queen, rook, bishop, knight, or disabled.
**Example**: Pawn + Promotion = bishop — any pawn reaching the back rank
auto-promotes to bishop, no dialog shown.
---
### Damage Resistance
Reduce all incoming damage by a percentage. Stacks multiplicatively across
sources.
Values: 0 (no resistance) to 1 (full immunity).
**Example**: Queen + 0.5 resistance — every attack deals half normal damage.
---
## Editor Features
### Undo / Redo
- **Cmd/Ctrl+Z** to undo, **Cmd/Ctrl+Shift+Z** to redo.
- Toolbar buttons in the editor header (↶ / ↷) with the same behaviour.
- History is capped at 50 actions per editing session.
- The stack is cleared when you **Save** or **Cancel** — the saved state
becomes the new baseline, so there is no undo across sessions.
### Copy / Paste
- **Per-instance**: select a square that already has modifiers, click **Copy**,
then select another square and click **Paste**. Pasted modifiers replace any
same-kind modifier already present on the target (so you won't end up with
two HP-Bonus entries on the same piece).
- **Per-type**: each row has a **Copy** button. Use **Paste** at the top of the
panel to apply the copied entry to a different piece type / color.
- The clipboard is editor-local (in-memory) and does **not** use your OS
clipboard — copying modifiers cannot clobber anything you already copied
outside the app.
### Conflict Resolution
When the editor detects a problem — a king carrying the `CANNOT_BE_CAPTURED`
flag, a per-instance entry pointing at an empty square, an attribute-limit
overflow — a panel appears at the top of the editor listing every issue.
- Each issue has a **Fix** button that auto-resolves it where possible
(e.g. drop the illegal flag, remove the orphan entry).
- Some issues (missing kings for a layout, attribute-limit overflow that
requires a real decision) can't be fixed automatically; the Fix button is
advisory and the panel directs you to the specific row that needs manual
attention.
- Save is blocked while any hard error remains; warnings (orphan per-instance
entries) don't block save but surface in the panel so they aren't silently
ignored.
---
## Hot-Swap
Modifier profiles can be changed mid-game. The flow:
1. **Solo mode**: Open the editor, change the profile, save. The new profile
applies after the next move.
2. **Multiplayer**: Either player can propose a profile change. The opponent
receives a notification with a 60-second window to approve or reject. If
approved, the new profile applies after the next move; if rejected or timed
out, no change occurs.
Profile changes always take effect at a **turn boundary** (after the next
completed move), never mid-move. This guarantees both players' moves resolve
under the same rule set they were planned with — the state at turn N is fully
determined by the profile active at turn N plus moves 1..N.
In a multiplayer room, rapid successive proposals follow a last-write-wins
rule: if you propose a swap and then send a second proposal before the opponent
decides, the first is superseded and the opponent sees the new candidate.
---
## Library & Sharing
- **Save** — click the Save button in the editor header. Up to 20 profiles stored locally.
- **Star** ⭐ — starred profiles are never evicted when the library is full.
- **Share** 🔗 — copies a URL containing the full profile. Profiles larger than 8 KB
cannot be URL-shared (save to library and share the room code instead).
- **Load** — open the library drawer in the editor and click any entry.
---
## In-Play Inspection
**Hover** any piece on the board to see a tooltip listing active modifiers.
**Click** a piece to pin a side panel with the full modifier breakdown. The
panel stays pinned across turns until you dismiss it (×) or click another
piece.
The pinned panel shows the **source** of each modifier so you can trace where
a value comes from at a glance:
- **per-instance: {square}** — attached to this specific board position
(survives captures of other pieces, but vanishes if this piece is captured).
- **per-type: all {color} {type}s** — applies to every piece matching this
type + color combination.
- **from {preset name}** — the modifier comes from an active rule preset
(e.g. a first-blood ruleset granting kings extra HP).
- **default** — the engine's baseline value with no modifier applied.
When multiple sources touch the same attribute, the panel lists each source
and shows how they combine (HP additive, damage-resistance multiplicative,
direction-additions unioned).
---
## Board Indicators
Pieces with one or more active modifiers display a small **fuchsia dot** in
the top-right corner of their square. The dot is visible without hover, so
you can tell at a glance which pieces are running on modified rules.
- Hover the piece to see a tooltip with modifier details.
- Click to pin the inspection panel (see above).
- The dot stays visible across moves and updates live when a profile is
hot-swapped at the next turn boundary.
---
## Known Limitations
- Maximum 20 profiles in the local library per browser.
- Profiles larger than 8 KB cannot be URL-shared.
- CANNOT_BE_CAPTURED cannot be applied to kings.
- Deadlock detection (all legal moves blocked) not yet implemented.
### Coming in T3
- Custom modifier authoring (defining new modifier categories from scratch).
- Cross-piece aura effects (e.g. "all pieces within 2 squares of this priest
get +1 HP").
- Multi-profile stacking (combining two profiles in one game).

View file

@ -8,7 +8,7 @@ export default tseslint.config(
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
// Engine RHS purity: ban impure globals in rete/src

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
import { test, expect } from '@playwright/test';
test.describe('Solo-play smoke (T2 preview tests)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
}
});
});
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game', { timeout: 5000 });
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
if (errors.length > 0) throw new Error(errors.join('; '));
});
test('drag pawn e2 to e4 resolves', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page
.locator('[data-square="e2"] [data-piece]')
.dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
});
test('solo play with modifier profile renders modifier-indicator dots', async ({ page }) => {
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
const entry = {
id: 'solo-smoke-indicator',
name: 'Solo Smoke Indicator',
profile: {
id: 'solo-smoke-indicator',
name: 'Solo Smoke Indicator',
description: '',
layoutId: 'classic',
perType: [
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
],
perInstance: [],
version: 1,
source: 'custom',
},
starred: false,
updatedAt: Date.now(),
};
await page.goto('/');
await page.evaluate(
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
{ key: LIBRARY_KEY, e: entry },
);
await page.reload();
const picker = page.getByTestId('profile-picker');
if (!(await picker.isVisible())) {
await page.locator('[data-action="open-rules-drawer"]').click();
}
await expect(picker).toBeVisible();
await picker.selectOption('solo-smoke-indicator');
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game', { timeout: 5000 });
await expect(page.locator('[data-testid^="modifier-indicator-"]').first()).toBeVisible({
timeout: 3000,
});
});
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
const drag = async (from: string, to: string) => {
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
// small wait for animation / state update
await page.waitForTimeout(100);
};
await drag('e2', 'e4');
await drag('e7', 'e5');
await drag('g1', 'f3');
await drag('b8', 'c6');
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
});
test('rules drawer opens without breaking board interaction', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(page.getByTestId('rules-drawer')).toBeVisible();
// Close drawer (esc or similar) and verify board still works
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Drag a move
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
});
test('no console errors on solo game start', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.waitForTimeout(1000); // let any async init complete
await expect(page.locator('[data-square="e2"]')).toBeVisible();
if (errors.length > 0) {
console.log('Errors:', errors);
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
}
});
// T2 regression — clicking the drawer backdrop closes the drawer AND
// leaves the board interactive afterwards. Regression guard for a
// stuck-overlay bug where the backdrop `pointer-events-none` timing
// wasn't cleared, silently blocking drag-to-move.
test('rules drawer: backdrop click closes drawer and restores board interactivity', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(page.getByTestId('rules-drawer')).toBeVisible();
// Click the far-left edge of the viewport — the drawer itself
// sits on the right (`fixed top-0 right-0 max-w-md`), so x=50 is
// guaranteed to land on the backdrop overlay, not the drawer.
await page.mouse.click(50, 300);
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({ timeout: 1000 });
// Board must be interactive: drag e2→e4 and see the pawn land on e4.
// Any stuck-overlay regression shows up here as a silent no-op drag.
await page
.locator('[data-square="e2"] [data-piece]')
.dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
timeout: 3000,
});
});
// T2 regression — nested-modal Esc ordering. With BOTH the rules
// drawer AND the modifier editor open, a single Esc must close the
// editor ONLY; the drawer stays. A second Esc then closes the
// drawer. This is enforced by ModifierProfileEditor installing its
// keydown handler in the capture phase with `stopImmediatePropagation`,
// so the drawer's window-level Esc handler never fires on the same
// keystroke. Without that guard both would close, leaving the board
// behind a briefly-lingering pointer-events-blocking backdrop.
test('modifier editor: Esc closes editor first, drawer stays open; 2nd Esc closes drawer', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.locator('[data-action="open-rules-drawer"]').click();
await page.locator('[data-testid="open-modifier-editor"]').click();
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible();
// First Esc: editor closes; drawer still visible.
//
// The editor's capture-phase keydown listener handles this event
// with stopImmediatePropagation(), so the drawer's bubble-phase
// listener is suppressed. Editor unmounts → its useEffect cleanup
// removes the listener, BUT that cleanup runs during React's
// commit phase — not synchronously after the state update. A
// too-fast second Esc can still hit the stale editor listener
// before it has fully detached. The small wait below lets
// React complete its commit so the second Esc sees only the
// drawer's listener.
await page.keyboard.press('Escape');
await expect(page.getByTestId('modifier-editor-modal')).not.toBeVisible({
timeout: 500,
});
await expect(page.getByTestId('rules-drawer')).toBeVisible();
// Second Esc: drawer closes. (See note above re: the wait.)
//
// The drawer exits via a framer-motion spring animation that
// takes ~300–500ms to fully unmount the `<aside>` — too tight a
// timeout here flakes even when the close did fire. We use 1500ms
// to match the backdrop-click test and leave headroom.
await page.waitForTimeout(50);
await page.keyboard.press('Escape');
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({
timeout: 1500,
});
// Board must be fully interactive again.
await page
.locator('[data-square="e2"] [data-piece]')
.dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
timeout: 3000,
});
});
// Regression guard for the "play-solo lands on blank screen" bug.
//
// Before the fix, a stale multiplayer session (room-code/room-token in
// sessionStorage from a previous room.create) would make Play Solo
// navigate to /game → GameRoute's Case 1 canonicalises to
// /game/<stale-code> → MultiplayerGameView mounts → WS handshake to a
// long-dead room silently fails → blank screen.
//
// The fix: handlePlaySolo() explicitly clears room-code, room-token,
// player-color, layout-name, and modifier-profile-name from
// sessionStorage before navigating, guaranteeing a fresh solo mount.
test('play-solo with stale MP creds in sessionStorage lands on /game, not /game/<stale>', async ({
page,
}) => {
// Seed sessionStorage to simulate a previous room.create or room.join
// whose creds were never cleared (e.g. user closed the tab after
// playing multiplayer, then reopened the lobby later).
await page.evaluate(() => {
sessionStorage.setItem('room-code', 'STALE1');
sessionStorage.setItem('room-token', 'stale-token-deadbeef');
sessionStorage.setItem('player-color', 'white');
sessionStorage.setItem('layout-name', 'Some Previous Layout');
sessionStorage.setItem('modifier-profile-name', 'Old Profile');
});
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
// URL must settle on /game WITHOUT a code suffix. Using a short
// timeout so a regression surfaces as a test failure rather than
// the suite hanging on the blank-screen state.
await page.waitForURL('**/game', { timeout: 3000 });
await expect(page).toHaveURL(/\/game$/);
// Board must render a real solo game (e2 pawn present, not the
// "Joining room …" placeholder).
await expect(page.locator('[data-testid="mp-joining"]')).toHaveCount(0);
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible({
timeout: 3000,
});
// All stale sessionStorage entries must be wiped so a page reload
// doesn't re-trigger the bug on the refreshed /game route.
const staleCreds = await page.evaluate(() => ({
code: sessionStorage.getItem('room-code'),
token: sessionStorage.getItem('room-token'),
color: sessionStorage.getItem('player-color'),
layout: sessionStorage.getItem('layout-name'),
profile: sessionStorage.getItem('modifier-profile-name'),
}));
expect(staleCreds).toEqual({
code: null,
token: null,
color: null,
layout: null,
profile: null,
});
if (errors.length > 0) throw new Error(errors.join('; '));
});
});

View file

@ -21,7 +21,8 @@
"motion": "^12.38.0",
"react-router-dom": "^7.14.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.2"
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",

View file

@ -69,6 +69,17 @@ import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js";
// Importing from the barrel guarantees every preset module's
// side-effect registration has run before the first engine is created.
import "./presets/index.js";
// Side-effect import: registers every modifier descriptor AND the
// `__modifier-profile-integration__` preset with PRESET_REGISTRY, so
// the engine can activate it when a profile is supplied.
import "./modifiers/index.js";
import {
applyProfileToSession,
MODIFIER_INTEGRATION_PRESET_ID,
} from "./modifiers/apply.js";
import type { ModifierProfile } from "./modifiers/types.js";
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
@ -250,13 +261,14 @@ class PresetStateImpl<T extends Record<string, unknown>>
}
set<K extends keyof T>(key: K, value: T[K]): void {
// The rete session validates that `value` is a legal FactValue
// (string | number | boolean | null). Passing objects here will
// throw — a deliberate constraint to keep facts serialization-safe.
// `FactValue` is `unknown` in the rete package, so any `T[K]`
// assigns directly. Runtime serialization guarantees (the value
// round-trips through JSON in event-log replay) are the caller's
// responsibility.
this.#session.insert(
PRESET_STATE_ENTITY,
this.#attrOf(key as string),
value as unknown as import("@paratype/rete").FactValue,
value,
);
}
@ -299,6 +311,32 @@ class PresetStateImpl<T extends Record<string, unknown>>
export interface EngineOptions {
readonly activePresets?: ActivePresetSet;
readonly layout?: StartingLayout;
/**
* Optional ModifierProfile to apply at game start. When supplied,
* the engine:
* 1. Applies the layout (as normal).
* 2. Calls `applyProfileToSession(session, profile, layout)` to
* seed all modifier facts on piece entities.
* 3. Auto-activates the `__modifier-profile-integration__` preset
* (scope=both, permanent) so CaptureFlags, DirectionAdditions,
* and DamageResistance facts drive actual gameplay.
*
* Order: profile is applied BEFORE `activePresets` activation, so
* preset `onActivate` hooks (e.g. piece-hp seeding Hp) observe any
* HpBonus the profile contributed.
*/
readonly profile?: ModifierProfile;
/**
* Optional list of user-authored CustomModifierDescriptors registered
* onto the engine's per-instance custom registry (T22). Each entry's
* `id` becomes a valid `kind` in profile entries: when
* `applyProfileToSession` encounters an unknown built-in kind, it
* falls back to this registry for resolution.
*
* Per ADR-4, custom descriptors are intentionally NOT global — each
* engine carries its own set so cross-room leakage is impossible.
*/
readonly customModifiers?: readonly CustomModifierDescriptor[];
}
export class ChessEngine {
@ -315,6 +353,26 @@ export class ChessEngine {
* move calculation with no reset.
*/
public readonly activePresets: ActivePresetSet;
/**
* The currently active ModifierProfile, if any.
* Profiles attach rule modifiers to pieces by type or layout slot.
*
* Mutable internally via `setActiveProfile()` so the client can sync
* with the server's `game.state` snapshots (the server is authoritative
* for which profile is active in a multiplayer room). Callers MUST NOT
* write directly — use `setActiveProfile()` to keep invariants intact.
*/
public activeProfile: ModifierProfile | null;
/**
* Per-engine registry of user-authored custom modifier descriptors
* (T22). Profile application consults this registry as a fallback
* when a kind isn't found in the global MODIFIER_REGISTRY.
*
* Mutable via `registerCustomModifier()` for live additions (e.g.
* server-broadcast `custom-modifier.register` messages in T24).
*/
public readonly customModifiers: CustomModifierRegistry;
/**
* Chronological log of every successful applyMove. Callers consume
@ -419,10 +477,59 @@ export class ChessEngine {
? { activePresets: arg as ActivePresetSet }
: ((arg as EngineOptions | undefined) ?? {});
this.activeProfile = opts.profile ?? null;
// Custom modifier registry — populated from opts.customModifiers
// (if any) BEFORE profile application, so profile entries that
// reference custom kinds can resolve them on first apply.
this.customModifiers = new CustomModifierRegistry();
if (opts.customModifiers) {
for (const d of opts.customModifiers) {
this.customModifiers.register(d);
}
}
const layout = opts.layout ?? CLASSIC_LAYOUT;
applyLayout(this.session, layout);
// Profile seeding runs BEFORE the position is recorded for
// threefold repetition — the modifier facts are part of the
// "initial position" from a repetition-tracking perspective, and
// are not expected to change mid-game (profiles hot-swap via
// turn-boundary replacement, which re-seeds).
if (opts.profile) {
applyProfileToSession(
this.session,
opts.profile,
layout,
this,
this.customModifiers,
);
}
recordPosition(this.session);
this.activePresets = opts.activePresets ?? new ActivePresetSet();
// When a profile is present, auto-activate the integration preset
// so its filterMoves / getExtraMoves / onDamage hooks fire. We
// prepend it to whatever the caller provided so it always runs
// FIRST in the preset-iteration order (cheap predicate — cheap to
// short-circuit).
if (opts.profile) {
const existing = this.activePresets.list();
this.activePresets.replaceAll([
{
id: MODIFIER_INTEGRATION_PRESET_ID,
scope: "both",
turnsRemaining: null,
},
...existing.map((e) => ({
id: e.id,
scope: e.scope,
turnsRemaining: e.turnsRemaining,
})),
]);
}
}
/**
@ -522,6 +629,22 @@ export class ChessEngine {
* down state cleanly before the incoming preset reads the board.
* Ordering within each phase follows the input list's natural order.
*/
/**
* Replace the active modifier profile (used by clients syncing with
* server `game.state` snapshots in multiplayer). The session facts
* carrying modifier values come from the server via `loadFacts`; this
* setter just keeps the engine's metadata view of the profile in sync
* so source-chain attribution and other profile-aware reads work.
*
* Does NOT re-seed facts or run reconcile — the server is authoritative
* for the actual modifier state. Callers should only invoke this from
* server-event handlers (game.state replay) or from a fresh local game
* setup, never as a way to mutate gameplay.
*/
setActiveProfile(profile: ModifierProfile | null): void {
this.activeProfile = profile;
}
setActivePresets(requests: readonly ActivationRequest[]): void {
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
this.activePresets.replaceAll(requests);
@ -718,8 +841,28 @@ export class ChessEngine {
.filter(p => p.type !== undefined);
for (const piece of pieces) {
const getter = lookupMoveGenerator(piece.type);
if (!getter) continue;
const baseGetter = lookupMoveGenerator(piece.type);
// Fold the transformMoveGenerator chain over all active presets
// (scope-filtered for `color`). Each preset's wrapper receives the
// OUTPUT of the previous — composing cleanly. We seed with the
// type-registry generator, or a degenerate empty-move generator if
// the piece type is unknown (keeps later wrappers well-defined).
const scopedPresets = this.activePresets.getForColor(color);
const seedGetter: MoveGetter =
baseGetter ?? ((_s: Session, _id: EntityId) => [] as LegalMove[]);
const getter: MoveGetter = scopedPresets
.filter(p => p.transformMoveGenerator)
.reduce<MoveGetter>(
(gen, preset) => preset.transformMoveGenerator!(this, piece.id, gen),
seedGetter,
);
// Skip pieces with no known type AND no transform — same semantics
// as the original "unknown type ⇒ immobile" guard, but now a
// transform preset can still produce moves for an otherwise unknown
// type.
if (!baseGetter && scopedPresets.every(p => !p.transformMoveGenerator)) {
continue;
}
let pieceMoves = getter(this.session, piece.id);
@ -759,7 +902,7 @@ export class ChessEngine {
// Order matters — `getExtraMoves` contributes to the set that
// `filterMoves` operates on, so every active preset sees the full
// aggregated set (including prior presets' additions).
const activePresets = this.activePresets.getForColor(color);
const activePresets = scopedPresets;
for (const preset of activePresets) {
if (preset.getExtraMoves) {
pieceMoves = [

View file

@ -34,8 +34,9 @@ import type { PieceType } from '../schema';
import type { GameResult } from '../engine';
import { GameClient } from '../net/client';
import { PredictionManager } from '../net/prediction';
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
import * as audio from '../audio';
import { toast } from 'sonner';
const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
@ -53,6 +54,10 @@ export interface MultiplayerGameState {
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
* after grace expiry). Null when healthy. */
error: string | null;
// Modifier proposal state
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage: string | null;
isProposer: boolean;
}
/**
@ -71,6 +76,9 @@ export function useMultiplayerGame(code: string, token: string) {
myColor: null,
loading: true,
error: null,
modifierProposal: null,
modifierRejectionMessage: null,
isProposer: false,
});
// Mount the connection exactly once per code/token pair.
@ -105,6 +113,32 @@ export function useMultiplayerGame(code: string, token: string) {
// The first game.state snapshot after (re)connect clears `loading`.
setMeta((m) => ({ ...m, loading: false }));
};
const onProposalPending = (e: { payload: ModifierProfileProposalPendingPayload }) => {
setMeta((m) => ({
...m,
modifierProposal: { profile: e.payload.profile, expiresAt: e.payload.expiresAt, proposer: e.payload.proposer },
modifierRejectionMessage: null,
isProposer: false,
}));
};
const onProposalRejected = (e: { payload: { reason: string } }) => {
setMeta((m) => ({
...m,
modifierProposal: null,
modifierRejectionMessage: e.payload.reason,
isProposer: false,
}));
setTimeout(() => {
setMeta((m) => m.modifierRejectionMessage === e.payload.reason ? { ...m, modifierRejectionMessage: null } : m);
}, 3000);
};
const onProposalConsentReceived = () => {
setMeta((m) => ({ ...m, modifierProposal: null, isProposer: false }));
toast.success("Opponent approved your profile change. It will take effect next turn.");
};
const onProposalQueued = () => {
setMeta((m) => ({ ...m, isProposer: true }));
};
const onGameDelta = (e: {
payload: {
gameOver: { winner: string; reason: string } | null;
@ -141,6 +175,16 @@ export function useMultiplayerGame(code: string, token: string) {
);
}, 2000);
}
// T3: custom modifier errors are user-facing actions (someone
// hit "Share with Room" in the editor) — surface as a toast so
// the failure is obvious even if the in-game error banner is
// styled subtly.
if (
e.payload.code === 'CUSTOM_MODIFIER_INVALID' ||
e.payload.code === 'CUSTOM_MODIFIER_LIMIT'
) {
toast.error(`Custom modifier rejected: ${e.payload.message}`);
}
};
client.on('connected', onConnected);
@ -148,6 +192,10 @@ export function useMultiplayerGame(code: string, token: string) {
client.on('room.joined', onJoined);
client.on('game.state', onGameState);
client.on('game.delta', onGameDelta);
client.on('modifier-profile.proposal-pending', onProposalPending);
client.on('modifier-profile.rejected', onProposalRejected);
client.on('modifier-profile.consent-received', onProposalConsentReceived);
client.on('modifier-profile.queued', onProposalQueued);
client.on('error', onError);
client.connect(code, token).catch((err: unknown) => {
@ -166,6 +214,10 @@ export function useMultiplayerGame(code: string, token: string) {
client.off('room.joined', onJoined);
client.off('game.state', onGameState);
client.off('game.delta', onGameDelta);
client.off('modifier-profile.proposal-pending', onProposalPending);
client.off('modifier-profile.rejected', onProposalRejected);
client.off('modifier-profile.consent-received', onProposalConsentReceived);
client.off('modifier-profile.queued', onProposalQueued);
client.off('error', onError);
client.close();
clientRef.current = null;
@ -277,5 +329,31 @@ export function useMultiplayerGame(code: string, token: string) {
connected: meta.connected,
loading: meta.loading,
error: meta.error,
modifierProposal: meta.modifierProposal,
modifierRejectionMessage: meta.modifierRejectionMessage,
isProposer: meta.isProposer,
sendConsent: (decision: 'approve' | 'reject') => {
clientRef.current?.send({
type: 'modifier-profile.consent',
payload: { roomCode: code, decision }
});
},
/**
* T3: ship a user-authored custom modifier descriptor to the
* server for room-wide registration. The server's
* custom-modifier.register handler validates structurally,
* stores in the per-room registry (capped at 10), and broadcasts
* to every connected client. The local PredictionManager
* subscriber mirrors the descriptor onto the local engine's
* customModifiers registry, so subsequent profile applies
* resolve the kind across both clients.
*/
sendRegisterCustomModifier: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => {
clientRef.current?.sendRegisterCustomModifier(
descriptor as unknown as import('../net/types.js').CustomModifierDescriptorWire,
);
},
};
}

View file

@ -18,6 +18,7 @@ export {
export {
GAME_ENTITY,
PROMOTION_PIECES,
CaptureFlag,
oppositeColor,
chessFact,
type PieceType,
@ -60,3 +61,29 @@ export {
type LayoutValidationResult,
} from "./layouts/index.js";
export { validateLayout } from "./layouts/validate.js";
// Piece modifier profiles — orthogonal to layouts and presets. Exposed
// so the server can type-check wire payloads against the authoritative
// chess-side shape. The zod schema itself is NOT re-exported here
// because the server package pins a different zod major; the server
// mirrors the schema locally (same shape) for wire validation.
export type {
ModifierProfile,
TypeModifier,
InstanceModifier,
ModifierKindId,
Direction,
} from "./modifiers/types.js";
export { validateProfile } from "./modifiers/validate.js";
export type {
ValidationError as ModifierValidationError,
ValidationWarning as ModifierValidationWarning,
ValidationResult as ModifierValidationResult,
ValidationErrorCode as ModifierValidationErrorCode,
ValidationWarningCode as ModifierValidationWarningCode,
} from "./modifiers/validate.js";
// Hot-swap reconciliation (T15). Exported so the server can apply a
// new ModifierProfile to a live session at a turn boundary without
// reaching into engine internals.
export { reconcileProfileSwap } from "./modifiers/reconcile.js";

View file

@ -0,0 +1,276 @@
/**
* Tests for applyProfileToSession.
*
* Each test builds a fresh Session, applies CLASSIC_LAYOUT (so we have
* a known piece topology), then calls applyProfileToSession with a
* targeted profile. We read the resulting facts directly from the
* session — we don't exercise the engine-level integration preset
* here; that's covered by engine-surface tests elsewhere.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
import { applyProfileToSession } from "./apply.js";
import type { ModifierProfile } from "./types.js";
import { CaptureFlag } from "../schema.js";
// Side-effect import — ensures every descriptor is registered so
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
import "./index.js";
/**
* Helper: return EntityIds of pieces matching the given filter. We
* need this in several tests to assert that the right pieces got
* seeded (and the wrong ones didn't).
*/
function findPieces(
session: Session,
filter: (type: string, color: string, square: number) => boolean,
): EntityId[] {
const facts = session.allFacts();
const out: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "PieceType") continue;
if ((f.id as number) <= 0) continue;
const type = f.value as string;
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
const posFact = facts.find((p) => p.id === f.id && p.attr === "Position");
if (!colorFact || !posFact) continue;
if (filter(type, colorFact.value as string, posFact.value as number)) {
out.push(f.id as EntityId);
}
}
return out;
}
/** Minimal profile builder — keeps test bodies focused on assertions. */
function profile(
parts: Partial<ModifierProfile>,
): ModifierProfile {
return {
id: "test",
name: "test",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
...parts,
};
}
describe("applyProfileToSession", () => {
let session: Session;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
session = new Session({ autoFire: false });
applyLayout(session, CLASSIC_LAYOUT);
// Silence expected dev-warnings for orphan / unknown-kind cases;
// individual tests that care about the warning inspect `warnSpy`.
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
it("per-type modifier seeds every matching piece", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
],
}),
CLASSIC_LAYOUT,
);
// Both white knights (b1 = square 1, g1 = square 6) should carry
// HpBonus=3; the two black knights should not.
const whiteKnights = findPieces(
session,
(t, c) => t === "knight" && c === "white",
);
expect(whiteKnights).toHaveLength(2);
for (const id of whiteKnights) {
expect(session.get(id, "HpBonus")).toBe(3);
}
const blackKnights = findPieces(
session,
(t, c) => t === "knight" && c === "black",
);
for (const id of blackKnights) {
expect(session.contains(id, "HpBonus")).toBe(false);
}
});
it("per-instance modifier targets the exact square, not other same-type pieces", () => {
applyProfileToSession(
session,
profile({
// Layout ref optional but exercised here to match intended use.
layoutId: "classic",
perInstance: [
{ kind: "range-bonus", square: "b1", value: 2 },
],
}),
CLASSIC_LAYOUT,
);
// square 1 = b1 (white knight). Look up by position.
const facts = session.allFacts();
const b1Piece = facts.find(
(f) => f.attr === "Position" && f.value === 1,
);
expect(b1Piece).toBeDefined();
expect(session.get(b1Piece!.id as EntityId, "RangeBonus")).toBe(2);
// g1 = square 6, also a white knight — must NOT have RangeBonus.
const g1Piece = facts.find(
(f) => f.attr === "Position" && f.value === 6,
);
expect(g1Piece).toBeDefined();
expect(session.contains(g1Piece!.id as EntityId, "RangeBonus")).toBe(false);
});
it("orphan per-instance entry (empty square) logs a warning and skips without throwing", () => {
expect(() =>
applyProfileToSession(
session,
profile({
perInstance: [
// z9 is not a valid square at all — invalid notation path.
{ kind: "hp-bonus", square: "z9", value: 5 },
// e4 is a valid square but empty in CLASSIC_LAYOUT — orphan path.
{ kind: "hp-bonus", square: "e4", value: 5 },
// e2 is a white pawn — this entry SHOULD still apply
// despite the earlier orphans.
{ kind: "hp-bonus", square: "e2", value: 7 },
],
}),
CLASSIC_LAYOUT,
),
).not.toThrow();
expect(warnSpy).toHaveBeenCalled();
// e2 = square 12. Confirm the valid entry still landed.
const facts = session.allFacts();
const e2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 12,
);
expect(e2Piece).toBeDefined();
expect(session.get(e2Piece!.id as EntityId, "HpBonus")).toBe(7);
});
it("additive stacking: two perType entries sum per-piece", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 3 },
],
}),
CLASSIC_LAYOUT,
);
const whitePawns = findPieces(
session,
(t, c) => t === "pawn" && c === "white",
);
expect(whitePawns).toHaveLength(8);
for (const id of whitePawns) {
expect(session.get(id, "HpBonus")).toBe(5);
}
});
it("color: 'both' targets both white and black pieces of that type", () => {
applyProfileToSession(
session,
profile({
perType: [
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
],
}),
CLASSIC_LAYOUT,
);
// All four rooks (a1, h1, a8, h8) should carry RangeBonus=1.
const rooks = findPieces(session, (t) => t === "rook");
expect(rooks).toHaveLength(4);
for (const id of rooks) {
expect(session.get(id, "RangeBonus")).toBe(1);
}
});
it("per-instance OVERRIDES per-type for priority-wins kind (PromotionOverride)", () => {
applyProfileToSession(
session,
profile({
perType: [
{
kind: "promotion-override",
pieceType: "pawn",
color: "white",
value: "knight",
},
],
perInstance: [
// e2 white pawn gets a more specific override — "rook".
{ kind: "promotion-override", square: "e2", value: "rook" },
],
}),
CLASSIC_LAYOUT,
);
const facts = session.allFacts();
// Sanity: another white pawn (a2 = square 8) gets the type-level value.
const a2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 8,
);
expect(session.get(a2Piece!.id as EntityId, "PromotionOverride")).toBe(
"knight",
);
// e2 = square 12 should see the per-instance value, not the per-type.
const e2Piece = facts.find(
(f) => f.attr === "Position" && f.value === 12,
);
expect(session.get(e2Piece!.id as EntityId, "PromotionOverride")).toBe(
"rook",
);
});
it("union stacking: CaptureFlags bitwise-OR across multiple sources", () => {
applyProfileToSession(
session,
profile({
perType: [
{
kind: "capture-flags",
pieceType: "king",
color: "white",
value: CaptureFlag.CANNOT_BE_CAPTURED,
},
{
kind: "capture-flags",
pieceType: "king",
color: "white",
value: CaptureFlag.CAN_CAPTURE_OWN,
},
],
}),
CLASSIC_LAYOUT,
);
const [whiteKing] = findPieces(
session,
(t, c) => t === "king" && c === "white",
);
expect(whiteKing).toBeDefined();
const flags = session.get(whiteKing!, "CaptureFlags");
// Both bits set.
expect(flags).toBe(
CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.CAN_CAPTURE_OWN,
);
});
});

View file

@ -0,0 +1,578 @@
/**
* Apply a ModifierProfile to a live Session at game start.
*
* Runs AFTER `applyLayout` has populated the session with piece
* entities. Walks `profile.perType` and `profile.perInstance`,
* resolves each entry to the target entity/entities, collects all
* values per (pieceId, kind), stacks them via the descriptor's
* `stackingRule`, and finally calls `descriptor.apply(...)` once per
* (pieceId, kind) pair to seed the corresponding fact.
*
* ## Stacking (ADR-4)
*
* A single piece may accumulate multiple values for the same kind
* from different sources (two perType entries matching the same
* type+color, or a perType + a perInstance for the piece on that
* square). Each descriptor declares how these combine:
*
* - `additive` — numeric sum. Used by HpBonus, RangeBonus.
* - `union` — set-union of array elements OR bitwise-OR for number
* bitflag values. Used by DirectionAdditions (string[]) and
* CaptureFlags (number).
* - `multiplicative` — 1 - ∏(1 - r_i). Used by DamageResistance.
* - `priority-wins` — last value in collection order wins. Used by
* PromotionOverride. Per ADR-4 the intended semantic is "per-
* instance beats per-type", which falls out naturally because we
* always iterate perType before perInstance.
*
* ## Orphan entries
*
* A `perInstance` entry whose square is empty (no piece placed
* there by the layout) is NOT an error — the validator already
* surfaced it as a warning. We log a dev-time `console.warn` so the
* case is visible during manual testing, then silently skip. This
* matches the validator's "benign" classification.
*
* ## Engine-level integration
*
* Seeding the fact is only half the story — three modifiers need
* runtime hooks to affect gameplay:
* - `CaptureFlags` — filter enemy captures of CANNOT_BE_CAPTURED
* pieces; allow same-color captures for CAN_CAPTURE_OWN.
* - `DirectionAdditions` — contribute extra 1-square moves.
* - `DamageResistance` — reduce incoming damage.
*
* These hooks live on a single preset (`__modifier-profile-integration__`)
* registered by this module at load time. The ChessEngine constructor
* auto-activates it when a profile is supplied — callers do NOT need
* to activate it manually.
*/
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType, Square } from "../schema.js";
import { CaptureFlag } from "../schema.js";
import { algebraicToSquare } from "../coord.js";
import type { StartingLayout } from "../layouts/types.js";
import type {
ModifierProfile,
ModifierKindId,
ModifierDescriptor,
TypeModifier,
InstanceModifier,
} from "./types.js";
import { MODIFIER_REGISTRY } from "./registry.js";
import { stackResistances, applyResistance } from "./descriptors/damage-resistance.js";
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
import { PRESET_REGISTRY } from "../presets/registry.js";
import type { LegalMove } from "../rules/types.js";
import { getPieceAt } from "../rules/board-queries.js";
import type { ChessEngine } from "../engine.js";
import type { CustomModifierRegistry } from "./custom/registry.js";
import { applyCustomDescriptor } from "./custom/apply.js";
import { computeAuraFacts } from "./auras.js";
import {
fireConditionalHooks,
fireOnCaptureHooks,
fireOnDamagedHooks,
fireOnTurnStartHooks,
snapshotHp,
} from "./triggers.js";
/**
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
* dispatcher to detect HP drops between phases. Keyed by engine
* (WeakMap) so concurrent engines (server-authoritative + client
* predicted) keep independent state. The integration preset's
* onBeforeMove takes the snapshot; onAfterMove consumes it.
*/
const PRE_MOVE_HP_SNAPSHOTS = new WeakMap<ChessEngine, Map<EntityId, number>>();
/**
* Per-engine attacker-id snapshot, set in onBeforeMove when the
* incoming move is a capture, consumed in onAfterMove to fire
* on-capture trigger primitives. The engine's `moveLog` isn't yet
* populated when onAfterMove fires, so we can't read it from there.
*/
const PRE_MOVE_CAPTURE_ATTACKERS = new WeakMap<ChessEngine, EntityId>();
/**
* Stable id for the pseudo-preset that wires modifier facts into
* engine runtime behaviour. Reserved name — userland presets must not
* use this id. The double-underscore prefix is the "internal" marker;
* the string is exported so tests and debug tooling can reference it
* without re-typing the literal.
*/
export const MODIFIER_INTEGRATION_PRESET_ID = "__modifier-profile-integration__";
/**
* Stack a list of raw values according to a descriptor's rule. Kept as
* a standalone function (not a method on the descriptor) because the
* collection logic is identical across all kinds — only the reducer
* differs. Invariants:
* - `values` is non-empty (collectors skip empty groups before calling).
* - Element types match the descriptor's schema; we do minimal
* runtime type narrowing and trust upstream validation (schema.ts
* zod-checks profiles before they hit this code path).
*/
function stackValues(
rule: ModifierDescriptor["stackingRule"],
values: readonly unknown[],
): unknown {
switch (rule) {
case "additive": {
// Sum of numbers. Non-numbers are treated as 0 defensively; in
// practice zod validation prevents them from reaching this point.
let sum = 0;
for (const v of values) if (typeof v === "number") sum += v;
return sum;
}
case "union": {
// Two shapes ride on this rule:
// 1. arrays of strings (DirectionAdditions) → dedup via Set.
// 2. numeric bitflags (CaptureFlags) → bitwise OR.
// We branch on the first non-empty value's type; mixed inputs
// are a bug in profile construction the validator should catch.
const first = values.find((v) => v !== undefined && v !== null);
if (Array.isArray(first)) {
const out = new Set<string>();
for (const v of values) {
if (Array.isArray(v)) for (const x of v) out.add(String(x));
}
return Array.from(out);
}
// Numeric bitflag path.
let bits = 0;
for (const v of values) if (typeof v === "number") bits |= v;
return bits;
}
case "multiplicative": {
// Damage-resistance stacking: 1 - ∏(1 - r_i). Reuses the helper
// exported from the descriptor module so behaviour stays in one
// place.
const rs: number[] = [];
for (const v of values) if (typeof v === "number") rs.push(v);
return stackResistances(rs);
}
case "priority-wins": {
// Last value wins. perType entries collected before perInstance,
// so a per-instance override naturally trumps per-type defaults.
// Guaranteed non-empty by the caller.
return values[values.length - 1];
}
}
}
/**
* Build a square→EntityId map from the session's current piece facts.
* Needed so `perInstance` entries (which reference squares by
* algebraic notation like "b1") can resolve to the actual EntityId
* the layout handed out.
*
* Walks `session.allFacts()` once for O(n) construction; the resulting
* Map is then queried O(1) per instance entry. Pieces without a
* Position fact are silently skipped — they aren't "on the board" in
* the sense the modifier system cares about.
*/
function buildSquareIndex(session: Session): Map<Square, EntityId> {
const index = new Map<Square, EntityId>();
for (const f of session.allFacts()) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue; // skip GAME_ENTITY etc.
index.set(f.value as Square, f.id as EntityId);
}
return index;
}
/**
* Find every piece entity whose (PieceType, Color) matches `typeMod`.
* `color === "both"` matches white AND black pieces of the given
* type. Returns EntityIds in session-fact order so repeated calls are
* deterministic (useful for stacking order of per-type entries).
*/
function findPiecesByType(
session: Session,
pieceType: PieceType,
color: PieceColor | "both",
): EntityId[] {
const facts = session.allFacts();
const out: EntityId[] = [];
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== pieceType) continue;
if ((f.id as number) <= 0) continue;
if (color !== "both") {
const cf = facts.find((c) => c.id === f.id && c.attr === "Color");
if (!cf || cf.value !== color) continue;
}
out.push(f.id as EntityId);
}
return out;
}
/**
* Apply every modifier entry in `profile` to entities in `session`,
* using `layout` as the source of truth for per-instance square →
* piece mappings.
*
* This mutates `session` directly — callers should run it exactly
* ONCE per game, after `applyLayout` and before any presets'
* `onActivate` hooks fire (so e.g. piece-hp's seeded Hp can observe
* HpBonus during its own activation scan).
*
* `layout` is accepted (not re-derived from the session) so future
* features can cross-reference placements by properties the session
* doesn't preserve (e.g. original-square ids that survive a piece
* moving).
*/
/**
* Collect every (pieceId, kind, value) contribution from a single
* profile into the supplied `collected` map. Pure with respect to
* the session — only reads `Position` and piece-type facts to resolve
* targets; never mutates. The merge step happens in the public
* `applyProfileToSession` / `applyProfilesToSession` after every
* profile has been visited.
*
* Per-type entries are pushed BEFORE per-instance to preserve T1's
* "per-instance overrides per-type" semantic for the priority-wins
* stacking rule (which picks the last-pushed value).
*/
function collectProfileContributions(
session: Session,
profile: ModifierProfile,
collected: Map<EntityId, Map<string, unknown[]>>,
): void {
const push = (id: EntityId, kind: string, value: unknown): void => {
let byKind = collected.get(id);
if (!byKind) {
byKind = new Map();
collected.set(id, byKind);
}
const arr = byKind.get(kind);
if (arr) arr.push(value);
else byKind.set(kind, [value]);
};
for (const tm of profile.perType as readonly TypeModifier[]) {
const targets = findPiecesByType(session, tm.pieceType, tm.color);
for (const id of targets) push(id, tm.kind, tm.value);
}
const squareIndex = buildSquareIndex(session);
for (const im of profile.perInstance as readonly InstanceModifier[]) {
const square = algebraicToSquare(im.square);
if (square === -1) {
console.warn(
`applyProfileToSession: invalid square notation "${im.square}" — skipping.`,
);
continue;
}
const id = squareIndex.get(square);
if (id === undefined) {
console.warn(
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
);
continue;
}
push(id, im.kind, im.value);
}
}
/**
* Stack collected (pieceId, kind, values[]) contributions and call
* each descriptor's apply(). Two registry layers:
* 1. MODIFIER_REGISTRY — engine-shipped built-ins (additive/union/...).
* 2. customRegistry — per-engine user-authored CustomModifierDescriptors
* whose primitives run via applyCustomDescriptor.
*
* Custom descriptors stack by "sequential apply per matching piece"
* (each pushed value triggers one full primitive walk) — they don't
* declare a stackingRule. If a profile produces multiple custom-modifier
* entries for the same (pieceId, kind), the primitive list runs once
* per entry; primitives that read+modify (add-to-attribute) compose
* naturally, while primitives that overwrite (seed-attribute) follow
* a last-wins semantic.
*
* Unknown kinds (in neither registry) are warned and skipped so a
* forwards-compat profile doesn't crash the apply.
*/
function applyCollectedContributions(
session: Session,
collected: ReadonlyMap<EntityId, ReadonlyMap<string, readonly unknown[]>>,
engine: ChessEngine | undefined,
customRegistry: CustomModifierRegistry | undefined,
): void {
for (const [pieceId, byKind] of collected) {
for (const [kind, values] of byKind) {
if (values.length === 0) continue;
// `kind` is `string` (the union of built-in ModifierKindId AND
// user-authored CustomModifierId). Built-in lookup narrows by
// type-asserting; an unknown kind falls through to the custom-
// registry branch below.
const builtIn = MODIFIER_REGISTRY.get(kind as ModifierKindId);
if (builtIn !== undefined) {
const effective = stackValues(builtIn.stackingRule, values);
builtIn.apply(session, pieceId, effective);
continue;
}
const custom = customRegistry?.get(kind);
if (custom !== undefined && engine !== undefined) {
// Custom descriptors apply once per collected entry — each
// value represents one logical contribution to this piece.
for (let i = 0; i < values.length; i += 1) {
applyCustomDescriptor(engine, session, pieceId, custom);
}
continue;
}
console.warn(
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
);
}
}
}
/**
* Apply a single ModifierProfile to a session (single-profile wrapper
* around `applyProfilesToSession`). Kept as the canonical entry point
* so all T1/T2 callers continue to work unchanged.
*
* `engine` and `customRegistry` are optional for backwards compatibility:
* built-in modifier kinds work without either. Pass them when the
* profile may reference user-authored custom kinds.
*/
export function applyProfileToSession(
session: Session,
profile: ModifierProfile,
layout: StartingLayout,
engine?: ChessEngine,
customRegistry?: CustomModifierRegistry,
): void {
applyProfilesToSession(session, [profile], layout, engine, customRegistry);
}
/**
* Apply multiple ModifierProfiles in order, stacking ACROSS profiles
* per the same per-kind rules used within a single profile (T23).
* Profiles are visited in the supplied order; the priority-wins
* stacking rule ("last value collected wins") therefore naturally
* gives later profiles precedence.
*
* Empty `profiles` array is a no-op.
*/
export function applyProfilesToSession(
session: Session,
profiles: readonly ModifierProfile[],
_layout: StartingLayout,
engine?: ChessEngine,
customRegistry?: CustomModifierRegistry,
): void {
if (profiles.length === 0) return;
const collected = new Map<EntityId, Map<string, unknown[]>>();
for (const profile of profiles) {
collectProfileContributions(session, profile, collected);
}
applyCollectedContributions(session, collected, engine, customRegistry);
}
// ── Engine-level integration preset ─────────────────────────────────────
//
// Registers ONCE at module load. The ChessEngine constructor activates
// it when `options.profile` is supplied. The preset holds no state of
// its own — all decisions are driven by facts seeded onto piece
// entities by `applyProfileToSession`.
/**
* Does any active modifier integration exist on this piece?
* Shortcut to avoid work when nothing relevant is seeded.
*/
function hasAnyModifierFact(session: Session, pieceId: EntityId): boolean {
return (
session.contains(pieceId, "CaptureFlags") ||
session.contains(pieceId, "DirectionAdditions") ||
session.contains(pieceId, "DamageResistance")
);
}
PRESET_REGISTRY.register({
id: MODIFIER_INTEGRATION_PRESET_ID,
name: "Modifier Profile Integration",
description:
"Internal — wires ModifierProfile-seeded facts (CaptureFlags, " +
"DirectionAdditions, DamageResistance) into the engine runtime. " +
"Auto-activated by ChessEngine when a profile is supplied.",
incompatibleWith: [],
requires: [],
/**
* CAN_CAPTURE_OWN: allow moves onto squares occupied by same-color
* pieces by introducing synthetic capture moves — the base move
* generators refuse to produce these on their own.
*
* Emitted as "extra" moves (not filter) because the base generator's
* own-occupancy check stops iteration early; retroactively turning
* an already-rejected square into a move requires re-generating.
*
* Kept minimal for now: we only emit the attacker→target square as a
* capture. Sliding pieces with the flag will still stop at own-
* blockers mid-range; fuller sliding semantics are a later task if
* we ever ship a preset that needs them.
*/
getExtraMoves(_engine, pieceId): LegalMove[] {
const extras: LegalMove[] = [];
if (!hasAnyModifierFact(_engine.session, pieceId)) return extras;
// DirectionAdditions: 1-square moves in the listed directions,
// non-capture only. Capture semantics on those directions are a
// future extension (would interact with CaptureFlags too).
if (_engine.session.contains(pieceId, "DirectionAdditions")) {
extras.push(...generateDirectionMoves(_engine.session, pieceId));
}
return extras;
},
/**
* CANNOT_BE_CAPTURED: remove any move (from ANY attacker) whose
* destination is a piece carrying the flag. Runs once per piece-move
* generation, so the O(cost) is moves×hasFlag-check. For typical
* game sizes (~40 legal moves, a handful of flagged pieces) this is
* dominated by the base movegen, not this filter.
*/
filterMoves(moves, engine, _pieceId): LegalMove[] {
return moves.filter((m) => {
if (!m.isCapture) return true;
const target = getPieceAt(engine.session, m.to);
if (target === null) return true;
// Drop the capture if the target is flagged as uncapturable.
if (hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)) {
return false;
}
return true;
});
},
/**
* DamageResistance: intercept the damage pipeline, reduce `amount`
* by the target's resistance fact, and either consume (kill) or
* fall through. We DON'T fully consume the event when the target
* lives — we want the rest of the pipeline (piece-hp, etc.) to run
* on the reduced amount. But `onDamage` doesn't expose a "reduce
* and continue" primitive. So we handle the two boundary cases:
* - resistance == 1.0 (immune) → consume, died=false, fully absorb.
* - resistance < 1.0 → leave untouched, fall through to default
* or piece-hp. Fractional reduction would require a pipeline-
* level change; documented as a known limitation for T14.
*/
onDamage(ctx): { consume: boolean; died?: boolean } | void {
// T3 absorb-damage-with-attribute: if the target carries an
// AbsorbDamageAttr/AbsorbDamageRate pair, route incoming damage
// through the named attribute first. Each damage point consumes
// `rate` of `attr`; when the attribute reaches 0, the remainder
// falls through to the rest of the pipeline (HP / kill).
const absorbAttr = ctx.engine.session.get(
ctx.target,
"AbsorbDamageAttr",
) as string | undefined;
const absorbRate = ctx.engine.session.get(
ctx.target,
"AbsorbDamageRate",
) as number | undefined;
if (
typeof absorbAttr === "string" &&
absorbAttr.length > 0 &&
typeof absorbRate === "number" &&
absorbRate > 0
) {
const charges = ctx.engine.session.get(ctx.target, absorbAttr);
const numericCharges =
typeof charges === "number" ? charges : 0;
if (numericCharges > 0) {
// Each damage point spends `rate` of attr; truncate at 0.
const totalNeeded = ctx.amount * absorbRate;
const consumed = Math.min(numericCharges, totalNeeded);
const remainingCharges = numericCharges - consumed;
ctx.engine.session.insert(ctx.target, absorbAttr, remainingCharges);
// If we absorbed every damage point, fully consume — target lives.
if (consumed >= totalNeeded) {
return { consume: true, died: false };
}
// Partial absorb: reduced damage falls through. Same limitation
// documented for partial resistance below — pipeline doesn't
// support mutation, so the next handler sees the original
// amount. Net effect: full damage still applies once charges
// can't cover it. Acceptable for the common case where charges
// are sized to absorb whole hits.
}
}
const resistance = ctx.engine.session.get(ctx.target, "DamageResistance");
if (typeof resistance !== "number" || resistance <= 0) return;
// Immunity short-circuit: fully absorb.
if (resistance >= 1) {
return { consume: true, died: false };
}
// Partial resistance: if applying it drops the amount to 0 we
// can absorb; otherwise we fall through. applyResistance clamps
// to ≥ 0 so the comparison is safe.
const reduced = applyResistance(ctx.amount, resistance);
if (reduced <= 0) return { consume: true, died: false };
// Non-zero damage after resistance — let the next handler process
// it. Note: this currently passes the ORIGINAL amount through
// because the pipeline doesn't support mutation. Documented
// limitation to revisit when HP + partial resistance both ship.
return;
},
/**
* Snapshot state BEFORE the move mutates it:
* - HP per piece (used by on-damaged hooks to detect drops).
* - Attacker id when the move is a capture (used by on-capture
* hooks; engine.moveLog isn't yet populated at onAfterMove time).
*/
onBeforeMove(ctx): void {
PRE_MOVE_HP_SNAPSHOTS.set(ctx.engine, snapshotHp(ctx.engine.session));
if (ctx.isCapture) {
PRE_MOVE_CAPTURE_ATTACKERS.set(ctx.engine, ctx.pieceId);
} else {
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
}
},
/**
* After every successful move:
* 1. Recompute aura contributions (T28).
* 2. Fire on-damaged hooks for every piece whose HP dropped during
* the move (compared against the pre-move snapshot).
* 3. Fire on-capture hooks for the mover's piece if the move was a
* capture (last move log entry's capturedId !== null).
* 4. Evaluate every conditional hook against current piece state
* and run the matching branch.
* 5. Fire on-turn-start hooks for the color whose turn is now
* beginning (the opposite of the mover).
*
* The order matters: damage / capture triggers see post-move state
* (the kill has happened, Hp facts are current), conditional hooks
* see whatever state the trigger primitives just produced, and
* turn-start runs last so it sees a fully-resolved board.
*/
onAfterMove(ctx): void {
computeAuraFacts(ctx.engine.session);
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
if (preHp !== undefined) {
fireOnDamagedHooks(ctx.engine, preHp);
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
}
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
fireOnCaptureHooks(ctx.engine, attacker);
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
fireConditionalHooks(ctx.engine);
const nextTurn: "white" | "black" =
ctx.mover === "white" ? "black" : "white";
fireOnTurnStartHooks(ctx.engine, nextTurn);
},
});

View file

@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../engine.js";
import type { ChessAttrMap } from "../schema.js";
import { computeAuraFacts } from "./auras.js";
import "./primitives/index.js";
function findPieceAtSquare(engine: ChessEngine, square: number) {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
throw new Error(`no piece at square ${square}`);
}
function getContribs(
engine: ChessEngine,
pieceId: ReturnType<typeof findPieceAtSquare>,
): Record<string, number> | undefined {
return engine.session.get(pieceId, "AuraContributions") as
| Record<string, number>
| undefined;
}
function seedAura(
engine: ChessEngine,
sourceId: ReturnType<typeof findPieceAtSquare>,
aura: ChessAttrMap["AuraSpec"][number],
): void {
const existing =
(engine.session.get(sourceId, "AuraSpec") as
| ChessAttrMap["AuraSpec"]
| undefined) ?? [];
engine.session.insert(sourceId, "AuraSpec", [...existing, aura]);
}
describe("computeAuraFacts", () => {
it("radius-1 aura contributes to 8 neighbours (king on e4 hits d3..f5)", () => {
const engine = new ChessEngine();
// Place a lone "source" conceptually on e4. Re-use the white king
// (square 4 = e1) — auras don't care about piece type. We'll move
// it to e4 (square 28) by overwriting its Position.
const kingId = findPieceAtSquare(engine, 4);
engine.session.insert(kingId, "Position", 28);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
// d3 (19), e3 (20), f3 (21), d4 (27), f4 (29), d5 (35), e5 (36), f5 (37)
// Most of those squares are empty in the classic layout (ranks 3-6
// are empty). Only d5/e5/f5 etc. are empty. But the king moved
// from e1, so e1 is now empty. We need pieces IN range to observe
// contributions. Pawns on e2 (12) = rank 2, and the kings are
// typically at the back ranks — so with the king at e4, let's
// check a PAWN that's within range. e2 pawn (sq 12) is at
// chebyshev(28, 12) = max(0, 2) = 2, OUT of radius 1. Move a pawn
// to e3 (sq 20) to test.
const e2Pawn = findPieceAtSquare(engine, 12);
engine.session.insert(e2Pawn, "Position", 20);
computeAuraFacts(engine.session);
const contribs = getContribs(engine, e2Pawn);
expect(contribs).toBeDefined();
expect(contribs!["HpBonus"]).toBe(1);
});
it("a piece outside the radius gets no contribution", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4); // e1
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
// e2 pawn (square 12) is chebyshev 1 — IN range.
const e2Pawn = findPieceAtSquare(engine, 12);
const e2Contribs = getContribs(engine, e2Pawn);
expect(e2Contribs?.["HpBonus"]).toBe(1);
// e7 pawn (square 52) is chebyshev 6 — OUT of range.
const e7Pawn = findPieceAtSquare(engine, 52);
expect(getContribs(engine, e7Pawn)).toBeUndefined();
});
it("multiple auras from different sources accumulate additively", () => {
const engine = new ChessEngine();
const whiteKing = findPieceAtSquare(engine, 4); // e1
const whiteQueen = findPieceAtSquare(engine, 3); // d1
seedAura(engine, whiteKing, {
radius: 2,
targetAttr: "HpBonus",
delta: 1,
});
seedAura(engine, whiteQueen, {
radius: 2,
targetAttr: "HpBonus",
delta: 2,
});
computeAuraFacts(engine.session);
// d2 pawn (11) is radius 1 from e1 AND radius 1 from d1 → both hit.
const d2Pawn = findPieceAtSquare(engine, 11);
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(3);
});
it("a piece's own aura does not apply to itself", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
seedAura(engine, kingId, { radius: 7, targetAttr: "HpBonus", delta: 5 });
computeAuraFacts(engine.session);
expect(getContribs(engine, kingId)).toBeUndefined();
});
it("recompute retracts stale contributions from sources that moved out of range", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4); // e1
const e2Pawn = findPieceAtSquare(engine, 12);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
computeAuraFacts(engine.session);
expect(getContribs(engine, e2Pawn)?.["HpBonus"]).toBe(1);
// Move the king to a1 (square 0) — now e2 is chebyshev 4, out of radius 1.
engine.session.insert(kingId, "Position", 0);
computeAuraFacts(engine.session);
expect(getContribs(engine, e2Pawn)).toBeUndefined();
});
it("is idempotent: calling twice without state change produces the same contributions", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 3 });
computeAuraFacts(engine.session);
const first = getContribs(engine, findPieceAtSquare(engine, 12));
computeAuraFacts(engine.session);
const second = getContribs(engine, findPieceAtSquare(engine, 12));
expect(second).toEqual(first);
});
it("multiple aura specs on ONE source (different attrs) all contribute", () => {
const engine = new ChessEngine();
const kingId = findPieceAtSquare(engine, 4);
engine.session.insert(kingId, "AuraSpec", [
{ radius: 1, targetAttr: "HpBonus", delta: 1 },
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
]);
computeAuraFacts(engine.session);
const e2Pawn = findPieceAtSquare(engine, 12);
const contribs = getContribs(engine, e2Pawn);
expect(contribs?.["HpBonus"]).toBe(1);
expect(contribs?.["RangeBonus"]).toBe(2);
});
it("no auras anywhere → no AuraContributions facts written", () => {
const engine = new ChessEngine();
computeAuraFacts(engine.session);
for (const f of engine.session.allFacts()) {
expect(f.attr).not.toBe("AuraContributions");
}
});
it("engine's onAfterMove hook recomputes auras automatically when a profile is active", async () => {
// Use a no-op modifier profile so the __modifier-profile-integration__
// preset auto-activates — that's the only way onAfterMove fires
// computeAuraFacts without any explicit caller.
const { applyProfileToSession: _apply } = await import("./apply.js");
void _apply;
const minimalProfile = {
id: "test-minimal-profile",
name: "Minimal",
description: "",
perType: [],
perInstance: [],
version: 1 as const,
source: "custom" as const,
};
const engine = new ChessEngine({ profile: minimalProfile });
const kingId = findPieceAtSquare(engine, 4); // e1
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
// Before any move → contribs not yet computed.
const e2Before = getContribs(engine, findPieceAtSquare(engine, 12));
expect(e2Before).toBeUndefined();
// Apply any legal move (e2 → e4). After the move, onAfterMove fires
// and auras get recomputed. e2 pawn moved to e4 (square 28); it's
// now at chebyshev 3 from the king on e1 — out of radius 1.
// Meanwhile, the king still emits — d2/e2 (now empty)/f2 are in
// range and d2/f2 pawns get +1 HpBonus from the king.
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
const d2Pawn = findPieceAtSquare(engine, 11);
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(1);
});
});

View file

@ -0,0 +1,117 @@
/**
* Aura engine integration (T28).
*
* The `add-aura` primitive (T14) seeds `AuraSpec` facts on a SOURCE
* piece declaring an emitted aura: `{ radius, targetAttr, delta }`.
* At runtime each aura contributes `delta` to every in-range TARGET
* piece's `targetAttr` attribute. "In-range" uses Chebyshev distance
* (king-move metric): a radius of 1 covers the 8 neighbours of the
* source square; radius 2 covers a 5×5 box minus the source.
*
* Design:
* - Auras are recomputed by `computeAuraFacts` after every successful
* move. Source or target moving OUT of / INTO range takes effect
* on the NEXT turn (no mid-move phases).
* - Contributions are written to a dedicated `AuraContributions`
* attr — a map keyed by target-attr name — so they never stomp
* attribute writes from the modifier profile itself (HpBonus from
* the profile + HpBonus from an aura compose as two separate
* records consumers can blend).
* - Sources and targets are independent. A piece can emit auras AND
* be affected by other pieces' auras. Self-application is skipped.
* - Empty contribution maps are retracted rather than written as `{}`
* so `session.get(id, "AuraContributions")` returns `undefined` on
* unaffected pieces (matches the convention for other optional
* modifier attrs).
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessAttrMap, Square } from "../schema.js";
import { fileOf, rankOf } from "../coord.js";
/**
* Recompute `AuraContributions` on every piece in the session.
* Idempotent: calling twice produces the same state.
*
* Strategy:
* 1. Retract the existing `AuraContributions` fact on every piece
* (clean slate — avoids stale contributions from sources that
* moved out of range since the last compute).
* 2. Walk every piece that has an `AuraSpec` list and, for each
* aura, iterate every in-range piece. Accumulate per-target
* per-attr deltas into a staging map.
* 3. Commit the staging map: for each affected piece, write the
* accumulated contributions as a single `AuraContributions` fact.
*/
export function computeAuraFacts(session: Session): void {
// Step 1: gather every piece on the board with its square.
const pieces = collectPiecesWithPositions(session);
// Step 2: retract stale contributions everywhere.
for (const { id } of pieces) {
if (session.contains(id, "AuraContributions")) {
session.retract(id, "AuraContributions");
}
}
// Step 3: walk aura sources and accumulate contributions.
//
// `staging` maps target pieceId → (attr → delta).
const staging = new Map<EntityId, Map<string, number>>();
for (const source of pieces) {
const auras = session.get(source.id, "AuraSpec") as
| ChessAttrMap["AuraSpec"]
| undefined;
if (auras === undefined || auras.length === 0) continue;
for (const aura of auras) {
for (const target of pieces) {
if (target.id === source.id) continue; // no self-application
if (chebyshev(source.square, target.square) > aura.radius) continue;
let byAttr = staging.get(target.id);
if (byAttr === undefined) {
byAttr = new Map();
staging.set(target.id, byAttr);
}
const prev = byAttr.get(aura.targetAttr) ?? 0;
byAttr.set(aura.targetAttr, prev + aura.delta);
}
}
}
// Step 4: commit.
for (const [targetId, byAttr] of staging) {
if (byAttr.size === 0) continue;
const record: Record<string, number> = {};
for (const [attr, delta] of byAttr) record[attr] = delta;
session.insert(targetId, "AuraContributions", record);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
interface PieceWithSquare {
readonly id: EntityId;
readonly square: Square;
}
function collectPiecesWithPositions(session: Session): PieceWithSquare[] {
const out: PieceWithSquare[] = [];
for (const f of session.allFacts()) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
out.push({ id: f.id, square: f.value as Square });
}
return out;
}
/**
* Chebyshev (chess king) distance between two squares. Range 0..7.
* Two squares are "within radius R" when chebyshev ≤ R.
*/
function chebyshev(a: Square, b: Square): number {
const df = Math.abs(fileOf(a) - fileOf(b));
const dr = Math.abs(rankOf(a) - rankOf(b));
return df > dr ? df : dr;
}

View file

@ -0,0 +1,332 @@
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { CLASSIC_LAYOUT } from "../../layouts/classic.js";
import { applyProfileToSession, applyProfilesToSession } from "../apply.js";
import type { ModifierProfile } from "../types.js";
import { CustomModifierRegistry } from "./registry.js";
import { applyCustomDescriptor } from "./apply.js";
import type { PrimitiveKind } from "../primitives/types.js";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
import type { EntityId } from "@paratype/rete";
import "../primitives/index.js";
function customDescriptor(
overrides: Partial<CustomModifierDescriptor> = {},
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(`custom:apply-${Math.random().toString(36).slice(2, 8)}`),
name: "Test",
description: "",
version: 1,
primitives: [],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
function profileWithCustom(
customKind: string,
overrides: Partial<ModifierProfile> = {},
): ModifierProfile {
return {
id: "test-profile",
name: "Test",
description: "",
perType: [
{
kind: customKind as ModifierProfile["perType"][number]["kind"],
pieceType: "pawn",
color: "white",
value: 1,
},
],
perInstance: [],
version: 1,
source: "custom",
...overrides,
};
}
describe("applyCustomDescriptor — primitive walking", () => {
it("applies a single seed-attribute primitive to a piece", () => {
const engine = new ChessEngine();
const desc = customDescriptor({
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
],
});
// Pick the pawn at e2 (white pawn in classic layout).
const pawnId = findPieceAtSquare(engine, 12); // e2 = file 4 + rank 1*8
applyCustomDescriptor(engine, engine.session, pawnId, desc);
expect(engine.session.get(pawnId, "HpBonus")).toBe(7);
});
it("applies multiple primitives in order", () => {
const engine = new ChessEngine();
const pawnId = findPieceAtSquare(engine, 12);
const desc = customDescriptor({
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } },
],
});
applyCustomDescriptor(engine, engine.session, pawnId, desc);
expect(engine.session.get(pawnId, "HpBonus")).toBe(3);
});
it("descends into nested children via childPrimitives()", () => {
const engine = new ChessEngine();
const pawnId = findPieceAtSquare(engine, 12);
// on-turn-start nests an inner seed-attribute. The outer primitive
// seeds the OnTurnStartHooks fact AND its inner children are
// visited by the depth walker.
const desc = customDescriptor({
primitives: [
{
kind: "on-turn-start",
params: {
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 4 } },
],
},
},
],
});
applyCustomDescriptor(engine, engine.session, pawnId, desc);
// The outer primitive seeds the hook fact:
expect(engine.session.get(pawnId, "OnTurnStartHooks")).toBeDefined();
// The inner child also runs (visible via the side-effect on RangeBonus):
expect(engine.session.get(pawnId, "RangeBonus")).toBe(4);
});
it("silently skips an unknown primitive kind", () => {
const engine = new ChessEngine();
const pawnId = findPieceAtSquare(engine, 12);
const desc = customDescriptor({
primitives: [
// Cast to bypass the literal-union check — we WANT to test that
// the runtime walker tolerates unknown kinds gracefully (the
// validator catches this at the static layer; the runtime path
// is the defensive backstop).
{ kind: "totally-not-a-real-primitive" as PrimitiveKind, params: {} },
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } },
],
});
expect(() =>
applyCustomDescriptor(engine, engine.session, pawnId, desc),
).not.toThrow();
// The known primitive after the unknown one still ran.
expect(engine.session.get(pawnId, "HpBonus")).toBe(5);
});
});
describe("applyProfileToSession — custom-registry fallback", () => {
it("resolves a profile entry against the engine's custom registry", () => {
const customId = "custom:hp-+5";
const desc = customDescriptor({
id: asCustomModifierId(customId),
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } },
],
});
const engine = new ChessEngine();
engine.customModifiers.register(desc);
const profile = profileWithCustom(customId);
applyProfileToSession(
engine.session,
profile,
CLASSIC_LAYOUT,
engine,
engine.customModifiers,
);
// Every white pawn (8 of them) got HpBonus = 5.
const pawnIds = whiteWhitePawnIds(engine);
expect(pawnIds).toHaveLength(8);
for (const id of pawnIds) {
expect(engine.session.get(id, "HpBonus")).toBe(5);
}
});
it("custom registries are per-engine — a descriptor in engineA is invisible to engineB", () => {
const customId = "custom:visible-only-to-A";
const desc = customDescriptor({
id: asCustomModifierId(customId),
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
],
});
const engineA = new ChessEngine({ customModifiers: [desc] });
const engineB = new ChessEngine();
expect(engineA.customModifiers.has(customId)).toBe(true);
expect(engineB.customModifiers.has(customId)).toBe(false);
});
it("constructor pre-registers customModifiers from EngineOptions", () => {
const desc = customDescriptor({
id: asCustomModifierId("custom:bootstrap"),
});
const engine = new ChessEngine({ customModifiers: [desc] });
expect(engine.customModifiers.has("custom:bootstrap")).toBe(true);
expect(engine.customModifiers.size()).toBe(1);
});
});
describe("applyProfilesToSession — multi-profile stacking (T23)", () => {
it("stacks two profiles' HpBonus additively across pieces", () => {
const engine = new ChessEngine();
const p1 = builtInHpBonusProfile("p1", 2);
const p2 = builtInHpBonusProfile("p2", 3);
applyProfilesToSession(
engine.session,
[p1, p2],
CLASSIC_LAYOUT,
engine,
engine.customModifiers,
);
const pawnId = findPieceAtSquare(engine, 12);
// 2 + 3 = 5 from additive stacking.
expect(engine.session.get(pawnId, "HpBonus")).toBe(5);
});
it("a single-profile call goes through the multi-profile path", () => {
const engine = new ChessEngine();
applyProfileToSession(
engine.session,
builtInHpBonusProfile("solo", 4),
CLASSIC_LAYOUT,
engine,
engine.customModifiers,
);
const pawnId = findPieceAtSquare(engine, 12);
expect(engine.session.get(pawnId, "HpBonus")).toBe(4);
});
it("empty profile array is a no-op", () => {
const engine = new ChessEngine();
const before = engine.session.allFacts().length;
applyProfilesToSession(
engine.session,
[],
CLASSIC_LAYOUT,
engine,
engine.customModifiers,
);
expect(engine.session.allFacts().length).toBe(before);
});
it("mixes built-in + custom profile entries on the same piece", () => {
const customId = "custom:adds-range";
const desc = customDescriptor({
id: asCustomModifierId(customId),
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 2 } },
],
});
const engine = new ChessEngine({ customModifiers: [desc] });
const builtInProfile = builtInHpBonusProfile("hp", 3);
const customProfile = profileWithCustom(customId);
applyProfilesToSession(
engine.session,
[builtInProfile, customProfile],
CLASSIC_LAYOUT,
engine,
engine.customModifiers,
);
const pawnId = findPieceAtSquare(engine, 12);
expect(engine.session.get(pawnId, "HpBonus")).toBe(3);
expect(engine.session.get(pawnId, "RangeBonus")).toBe(2);
});
});
describe("CustomModifierRegistry — basic API", () => {
it("registers, looks up, and lists descriptors", () => {
const registry = new CustomModifierRegistry();
const desc = customDescriptor({ id: asCustomModifierId("custom:abc") });
registry.register(desc);
expect(registry.has("custom:abc")).toBe(true);
expect(registry.get("custom:abc")).toBe(desc);
expect(registry.list()).toEqual([desc]);
expect(registry.size()).toBe(1);
});
it("clear() empties the registry", () => {
const registry = new CustomModifierRegistry();
registry.register(customDescriptor());
registry.clear();
expect(registry.size()).toBe(0);
});
it("register() with the same id replaces the existing descriptor", () => {
const registry = new CustomModifierRegistry();
const v1 = customDescriptor({
id: asCustomModifierId("custom:dup"),
name: "v1",
});
const v2 = customDescriptor({
id: asCustomModifierId("custom:dup"),
name: "v2",
});
registry.register(v1);
registry.register(v2);
expect(registry.size()).toBe(1);
expect(registry.get("custom:dup")).toBe(v2);
});
});
// ── Helpers ───────────────────────────────────────────────────────────
function findPieceAtSquare(engine: ChessEngine, square: number): EntityId {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
throw new Error(`no piece at square ${square}`);
}
function whiteWhitePawnIds(engine: ChessEngine): EntityId[] {
const out: EntityId[] = [];
const facts = engine.session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
if ((f.id as number) <= 0) continue;
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
if (colorFact?.value !== "white") continue;
out.push(f.id);
}
return out;
}
function builtInHpBonusProfile(id: string, value: number): ModifierProfile {
return {
id,
name: id,
description: "",
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value },
],
perInstance: [],
version: 1,
source: "custom",
};
}

View file

@ -0,0 +1,126 @@
/**
* Apply a CustomModifierDescriptor's primitive list to a single piece (T22).
*
* Walks `descriptor.primitives` in order, looks each one up in the
* global `PRIMITIVE_REGISTRY`, runs the registered primitive's
* `apply(ctx, params)`. Recursion depth tracking mirrors T19's static
* guard at runtime — `ctx.depth` increments when we enter a nested
* primitive list discovered via `childPrimitives()`.
*
* The registry-walk fallthrough (`unknown` kind silently skipped) is
* intentional: `applyProfileToSession` is the orchestrator that emits
* a dev warning for unknown kinds; `applyCustomDescriptor` trusts the
* caller has already validated the descriptor.
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "../primitives/types.js";
import type { CustomModifierDescriptor } from "./types.js";
/**
* Hard runtime cap that mirrors the validator's MAX_RECURSION_DEPTH (3).
* Defensive — should never trigger if T19 passed; set higher than the
* static cap so a passing descriptor never hits this throw.
*/
const RUNTIME_DEPTH_HARD_CAP = 8;
export function applyCustomDescriptor(
engine: ChessEngine,
session: Session,
pieceId: EntityId,
descriptor: CustomModifierDescriptor,
): void {
walkAndApply({
engine,
session,
pieceId,
descriptor,
nodes: descriptor.primitives,
depth: 0,
});
}
function walkAndApply(input: {
engine: ChessEngine;
session: Session;
pieceId: EntityId;
descriptor: CustomModifierDescriptor;
nodes: readonly EffectPrimitiveNode[];
depth: number;
}): void {
const { engine, session, pieceId, descriptor, nodes, depth } = input;
if (depth > RUNTIME_DEPTH_HARD_CAP) {
throw new Error(
`applyCustomDescriptor: nesting depth ${depth} exceeds hard cap ${RUNTIME_DEPTH_HARD_CAP} ` +
`(descriptor "${descriptor.id}" should have failed T19's validator)`,
);
}
for (const node of nodes) {
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive === undefined) {
// Unknown kind — skip silently. The validator should have caught
// this; tolerating it at runtime keeps a half-validated descriptor
// from crashing the entire profile apply.
continue;
}
const ctx: PrimitiveApplyContext = {
engine,
session,
pieceId,
depth,
descriptor: {
id: String(descriptor.id),
type: descriptor.type,
version: descriptor.version,
},
};
runPrimitive(primitive, ctx, node.params);
// If the primitive owns nested children, recurse. Errors during
// childPrimitives() introspection terminate the recursion for this
// node but don't bubble up — the validator's depth/count checks
// are the user-facing guard.
if (primitive.childPrimitives === undefined) continue;
let children: readonly EffectPrimitiveNode[] = [];
try {
children = primitive.childPrimitives(node.params);
} catch {
children = [];
}
if (children.length === 0) continue;
walkAndApply({
engine,
session,
pieceId,
descriptor,
nodes: children,
depth: depth + 1,
});
}
}
/**
* Wrapper that erases the `EffectPrimitive<P>` generic so we can call
* apply() with `node.params: unknown`. The registry stores descriptors
* with their generic erased; we cast the apply function's first param
* to `unknown` here. Any narrowing the primitive does internally
* (typically via its own paramsSchema) is the primitive's contract.
*/
function runPrimitive(
primitive: EffectPrimitive,
ctx: PrimitiveApplyContext,
params: unknown,
): void {
primitive.apply(ctx, params);
}

View file

@ -0,0 +1,3 @@
export * from "./types.js";
// Persistence, validation, Zod schema, and apply are added by Wave 3 (T19-T22).

View file

@ -0,0 +1,205 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
import {
__test__,
loadCustomModifierLibrary,
removeFromCustomModifierLibrary,
saveToCustomModifierLibrary,
setCustomModifierStarred,
} from "./library.js";
// Same Map-backed Storage shim used by the T1 library tests
// (see ../library.test.ts) — happy-dom's bundled localStorage doesn't
// survive every destructuring pattern reliably across vitest workers.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
beforeEach(() => {
localStorage.clear();
});
function descriptor(
overrides: Partial<CustomModifierDescriptor> = {},
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(`custom:${Math.random().toString(36).slice(2, 10)}`),
name: "Test",
description: "",
version: 1,
primitives: [],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
describe("custom modifier library — basic round-trip", () => {
it("loadCustomModifierLibrary returns [] when storage is empty", () => {
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("loadCustomModifierLibrary returns [] on malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "{not json[");
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("save then load returns the entry", () => {
const desc = descriptor({ name: "Roundtrip" });
const saved = saveToCustomModifierLibrary(desc);
expect(saved.ok).toBe(true);
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(1);
expect(loaded[0]!.id).toBe(desc.id);
expect(loaded[0]!.descriptor.name).toBe("Roundtrip");
expect(loaded[0]!.starred).toBe(false);
expect(typeof loaded[0]!.updatedAt).toBe("number");
});
it("auto-populates createdAt on first save when absent", () => {
const desc = descriptor();
expect(desc.createdAt).toBeUndefined();
const saved = saveToCustomModifierLibrary(desc);
expect(saved.ok).toBe(true);
if (!saved.ok) return;
expect(typeof saved.entry.descriptor.createdAt).toBe("number");
});
it("preserves createdAt on subsequent saves", async () => {
const id = asCustomModifierId("custom:stable");
const first = saveToCustomModifierLibrary(descriptor({ id, name: "A" }));
expect(first.ok).toBe(true);
if (!first.ok) return;
const originalCreatedAt = first.entry.descriptor.createdAt;
expect(originalCreatedAt).toBeDefined();
// Wait a tick so updatedAt definitely changes.
await new Promise((r) => setTimeout(r, 2));
const second = saveToCustomModifierLibrary(
descriptor({ id, name: "B" }),
);
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.entry.descriptor.createdAt).toBe(originalCreatedAt);
expect(second.entry.descriptor.name).toBe("B");
});
});
describe("custom modifier library — update, remove, star", () => {
it("update by id replaces the descriptor in place", () => {
const id = asCustomModifierId("custom:update");
saveToCustomModifierLibrary(descriptor({ id, name: "v1" }));
saveToCustomModifierLibrary(descriptor({ id, name: "v2" }));
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(1);
expect(loaded[0]!.descriptor.name).toBe("v2");
});
it("removeFromCustomModifierLibrary deletes by id and returns true", () => {
const id = asCustomModifierId("custom:remove");
saveToCustomModifierLibrary(descriptor({ id }));
expect(removeFromCustomModifierLibrary(id)).toBe(true);
expect(loadCustomModifierLibrary()).toEqual([]);
});
it("removeFromCustomModifierLibrary returns false when id absent", () => {
expect(
removeFromCustomModifierLibrary(asCustomModifierId("custom:nope")),
).toBe(false);
});
it("setCustomModifierStarred toggles the flag", () => {
const id = asCustomModifierId("custom:star");
saveToCustomModifierLibrary(descriptor({ id }));
setCustomModifierStarred(id, true);
expect(loadCustomModifierLibrary()[0]!.starred).toBe(true);
setCustomModifierStarred(id, false);
expect(loadCustomModifierLibrary()[0]!.starred).toBe(false);
});
});
describe("custom modifier library — capacity and validation", () => {
it("rejects descriptors that fail T19 validation", () => {
// Empty name fails the validator's name-length rule.
const bad = descriptor({ name: "" });
const result = saveToCustomModifierLibrary(bad);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.code === "descriptor.name.length")).toBe(
true,
);
});
it("evicts the oldest non-starred entry when at capacity", async () => {
// Fill with MAX_ENTRIES non-starred entries with strictly-increasing
// updatedAt timestamps.
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
saveToCustomModifierLibrary(
descriptor({
id: asCustomModifierId(`custom:fill-${i}`),
name: `fill-${i}`,
}),
);
// Tiny stagger so updatedAt timestamps differ.
await new Promise((r) => setTimeout(r, 1));
}
expect(loadCustomModifierLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Add one more — the oldest (fill-0) should be evicted.
const overflow = descriptor({
id: asCustomModifierId("custom:overflow"),
name: "overflow",
});
const result = saveToCustomModifierLibrary(overflow);
expect(result.ok).toBe(true);
const loaded = loadCustomModifierLibrary();
expect(loaded).toHaveLength(__test__.MAX_ENTRIES);
expect(loaded.find((e) => e.descriptor.name === "fill-0")).toBeUndefined();
expect(loaded.find((e) => e.descriptor.name === "overflow")).toBeDefined();
});
it("refuses save when the library is full of starred entries", async () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
const id = asCustomModifierId(`custom:starred-${i}`);
saveToCustomModifierLibrary(descriptor({ id, name: `s-${i}` }));
setCustomModifierStarred(id, true);
await new Promise((r) => setTimeout(r, 1));
}
const result = saveToCustomModifierLibrary(
descriptor({ id: asCustomModifierId("custom:rejected") }),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toMatch(/library full/i);
});
});

View file

@ -0,0 +1,189 @@
/**
* Custom modifier library — localStorage-backed store of user-authored
* custom modifier descriptors (T21).
*
* Mirrors the T1 modifier-profile library at `../library.ts`:
* - Same MAX_ENTRIES (20) capacity rule with starred-aware FIFO eviction.
* - Same `loadLibrary` / `saveToLibrary` / `deleteFromLibrary` /
* `setStarred` / `duplicateEntry` API surface.
* - Different storage key: `houserules:custom-modifiers:v1`.
*
* On `saveToLibrary` we run T19's validator (`validateCustomDescriptor`)
* before persisting. Invalid descriptors are rejected with the validator's
* error list so the caller can surface inline editor feedback. The library
* never persists a structurally-broken descriptor.
*
* `createdAt` on the descriptor is auto-populated on first save (when the
* descriptor doesn't already carry one); subsequent saves preserve the
* original timestamp so library order stays stable.
*/
import {
parseCustomModifierDescriptor,
safeParseCustomModifierDescriptor,
} from "./schema.js";
import type { CustomModifierDescriptor, CustomModifierId } from "./types.js";
import {
validateCustomDescriptor,
type ValidationError,
} from "./validate.js";
const STORAGE_KEY = "houserules:custom-modifiers:v1";
const MAX_ENTRIES = 20;
export interface SavedCustomModifier {
readonly id: CustomModifierId;
readonly descriptor: CustomModifierDescriptor;
readonly starred: boolean;
readonly updatedAt: number;
}
export type SaveResult =
| { ok: true; entry: SavedCustomModifier }
| { ok: false; reason: string; errors?: ValidationError[] };
/**
* Read every saved custom modifier from storage. Empty/missing/corrupt
* → `[]`. Per-entry shape failures are silently dropped so a single
* bad row never blocks the whole library.
*/
export function loadCustomModifierLibrary(): SavedCustomModifier[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSavedCustomModifier);
} catch {
return [];
}
}
/** Replace the entire library array on disk. Best-effort on quota error. */
function writeLibrary(entries: readonly SavedCustomModifier[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new descriptor or update an existing one (matched by descriptor
* id). Runs T19's structural+semantic validator first; rejects with
* the error list on failure. Auto-populates `descriptor.createdAt` on
* first save and preserves it on subsequent saves.
*
* Capacity: when adding a NEW descriptor would exceed MAX_ENTRIES, the
* oldest non-starred entry is evicted. If every entry is starred, the
* save is refused with a human-friendly reason.
*/
export function saveToCustomModifierLibrary(
descriptor: CustomModifierDescriptor,
): SaveResult {
const validation = validateCustomDescriptor(descriptor);
if (!validation.ok) {
return {
ok: false,
reason: "Descriptor failed validation",
errors: validation.errors,
};
}
const library = loadCustomModifierLibrary();
const existingIdx = library.findIndex((e) => e.id === descriptor.id);
const now = Date.now();
// Preserve the original createdAt if this is an update; populate it
// for first-time saves.
const existingCreatedAt =
existingIdx >= 0 ? library[existingIdx]?.descriptor.createdAt : undefined;
const createdAt =
descriptor.createdAt ?? existingCreatedAt ?? now;
const stampedDescriptor: CustomModifierDescriptor = {
...descriptor,
createdAt,
};
const entry: SavedCustomModifier = {
id: descriptor.id,
descriptor: stampedDescriptor,
starred: existingIdx >= 0 ? library[existingIdx]!.starred : false,
updatedAt: now,
};
if (existingIdx >= 0) {
const updated = library.slice();
updated[existingIdx] = entry;
writeLibrary(updated);
return { ok: true, entry };
}
if (library.length >= MAX_ENTRIES) {
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 custom modifiers). Unstar one to make room, or delete an entry.",
};
}
const oldest = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldest.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true, entry };
}
writeLibrary([...library, entry]);
return { ok: true, entry };
}
/** Remove a custom modifier by id. No-op if the id is unknown. Returns true if removed. */
export function removeFromCustomModifierLibrary(
id: CustomModifierId,
): boolean {
const library = loadCustomModifierLibrary();
const next = library.filter((e) => e.id !== id);
if (next.length === library.length) return false;
writeLibrary(next);
return true;
}
/** Toggle the starred flag on a saved custom modifier. */
export function setCustomModifierStarred(
id: CustomModifierId,
starred: boolean,
): void {
const library = loadCustomModifierLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated = library.slice();
updated[idx] = { ...library[idx]!, starred, updatedAt: Date.now() };
writeLibrary(updated);
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedCustomModifier(value: unknown): value is SavedCustomModifier {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
if (
typeof v["id"] !== "string" ||
typeof v["starred"] !== "boolean" ||
typeof v["updatedAt"] !== "number"
) {
return false;
}
// Validate the nested descriptor structurally via Zod.
const parseResult = safeParseCustomModifierDescriptor(v["descriptor"]);
if (!parseResult.success) return false;
// Discard the parse output — we keep the original raw shape so the
// entry round-trips identically (createdAt preserved, etc.).
void parseCustomModifierDescriptor;
return true;
}
// Exported for tests only.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };

View file

@ -0,0 +1,41 @@
/**
* Per-engine registry for user-authored CustomModifierDescriptors (T22).
*
* The global MODIFIER_REGISTRY holds engine-shipped built-in descriptors
* (hp-bonus, range-bonus, etc.). Custom descriptors are intentionally NOT
* in that registry — per ADR-4, they live on a per-engine instance so a
* descriptor authored in one room never leaks into another. Each
* `ChessEngine` owns one `CustomModifierRegistry`; profile application
* consults both registries (built-ins first, custom as fallback).
*/
import type { CustomModifierDescriptor, CustomModifierId } from "./types.js";
export class CustomModifierRegistry {
readonly #byId = new Map<CustomModifierId, CustomModifierDescriptor>();
/** Register or REPLACE a descriptor by id. */
register(descriptor: CustomModifierDescriptor): void {
this.#byId.set(descriptor.id, descriptor);
}
/** Look up by branded id OR raw string (matches the kind on a profile entry). */
get(id: string): CustomModifierDescriptor | undefined {
return this.#byId.get(id as CustomModifierId);
}
has(id: string): boolean {
return this.#byId.has(id as CustomModifierId);
}
list(): readonly CustomModifierDescriptor[] {
return [...this.#byId.values()];
}
size(): number {
return this.#byId.size;
}
clear(): void {
this.#byId.clear();
}
}

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
EffectPrimitiveNodeSchema,
parseCustomModifierDescriptor,
safeParseCustomModifierDescriptor,
serializeCustomModifierDescriptor,
} from "./schema.js";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
function validDescriptor(
overrides: Partial<CustomModifierDescriptor> = {},
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId("custom:test"),
name: "Test",
description: "",
version: 1,
primitives: [],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
describe("EffectPrimitiveNodeSchema", () => {
it("accepts a leaf node", () => {
const node = { kind: "seed-attribute", params: { attr: "Hp", value: 5 } };
expect(EffectPrimitiveNodeSchema.parse(node)).toEqual(node);
});
it("accepts a nested-tree node (params holds more nodes)", () => {
const nested = {
kind: "on-turn-start",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
};
expect(EffectPrimitiveNodeSchema.parse(nested)).toEqual(nested);
});
it("rejects a node missing the kind field", () => {
expect(() =>
EffectPrimitiveNodeSchema.parse({ params: {} }),
).toThrow();
});
it("rejects a node with an empty kind string", () => {
expect(() =>
EffectPrimitiveNodeSchema.parse({ kind: "", params: {} }),
).toThrow();
});
});
describe("CustomModifierDescriptorSchema — happy path", () => {
it("round-trips a minimal valid descriptor", () => {
const desc = validDescriptor();
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed).toEqual(desc);
});
it("round-trips a descriptor with primitives + author + createdAt", () => {
const desc = validDescriptor({
name: "Shield",
description: "Absorbs damage.",
primitives: [
{ kind: "seed-attribute", params: { attr: "ShieldCharges", value: 3 } },
],
targetAttrs: ["AbsorbDamageAttr", "AbsorbDamageRate"],
author: "alice",
createdAt: 1700000000000,
});
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed.author).toBe("alice");
expect(parsed.createdAt).toBe(1700000000000);
});
it("safeParse returns success on a valid descriptor", () => {
const result = safeParseCustomModifierDescriptor(validDescriptor());
expect(result.success).toBe(true);
});
it("serialize round-trips the same shape", () => {
const desc = validDescriptor({ name: "Roundtrip" });
const out = serializeCustomModifierDescriptor(desc) as CustomModifierDescriptor;
expect(out).toEqual(desc);
});
});
describe("CustomModifierDescriptorSchema — rejections", () => {
it("rejects type !== 'data'", () => {
const bad = { ...validDescriptor(), type: "scripted" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects version !== 1", () => {
const bad = { ...validDescriptor(), version: 2 };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects name longer than 40 chars", () => {
const bad = validDescriptor({ name: "x".repeat(41) });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects description longer than 200 chars", () => {
const bad = validDescriptor({ description: "x".repeat(201) });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects empty id", () => {
const bad = { ...validDescriptor(), id: "" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects negative createdAt", () => {
const bad = validDescriptor({ createdAt: -1 });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects uiForm !== 'primitive-composer'", () => {
const bad = { ...validDescriptor(), uiForm: "number" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects source !== 'custom'", () => {
const bad = { ...validDescriptor(), source: "premade" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects when primitives is missing", () => {
const { primitives: _drop, ...rest } = validDescriptor();
expect(() => parseCustomModifierDescriptor(rest)).toThrow();
});
});
describe("CustomModifierDescriptorSchema — optional fields", () => {
it("accepts a descriptor without author / createdAt", () => {
const desc = validDescriptor();
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed.author).toBeUndefined();
expect(parsed.createdAt).toBeUndefined();
});
});

View file

@ -0,0 +1,94 @@
/**
* Zod schemas for CustomModifierDescriptor serialization (T20).
*
* Validates STRUCTURE only — kind-in-registry and per-primitive params
* checks live in `validate.ts` (T19), which is the semantic layer that
* runs after a clean structural parse.
*
* The `EffectPrimitiveNode` schema is recursive: a primitive's `params`
* may embed more nodes (e.g. `on-turn-start` carries a `primitives: []`
* inside its params). We model this with `z.lazy()` and treat `params`
* as `z.unknown()` at the schema layer — the validator drills into it
* per-kind.
*/
import { z } from "zod";
import {
asCustomModifierId,
type CustomModifierDescriptor,
} from "./types.js";
// ---------------------------------------------------------------------------
// Primitive node — recursive
// ---------------------------------------------------------------------------
/**
* Structural shape of a primitive node. `kind` is `string` here (not the
* `PrimitiveKind` literal union) because the schema is structure-only;
* the validator (T19) checks `kind ∈ PRIMITIVE_REGISTRY`. `params` is
* `unknown` so nested trees pass through this schema cleanly.
*
* Inferred output: `{ kind: string; params: unknown }`. Consumers that
* want the branded `PrimitiveKind` shape go through `parseCustomModifierDescriptor`
* which performs the narrowing at the boundary.
*/
export const EffectPrimitiveNodeSchema = z.lazy(() =>
z.object({
kind: z.string().min(1),
params: z.unknown(),
}),
);
// ---------------------------------------------------------------------------
// CustomModifierDescriptor
// ---------------------------------------------------------------------------
const CustomModifierIdSchema = z
.string()
.min(1)
.transform((s) => asCustomModifierId(s));
export const CustomModifierDescriptorSchema = z.object({
type: z.literal("data"),
id: CustomModifierIdSchema,
name: z.string().min(1).max(40),
description: z.string().max(200),
version: z.literal(1),
primitives: z.array(EffectPrimitiveNodeSchema),
targetAttrs: z.array(z.string()),
uiForm: z.literal("primitive-composer"),
source: z.literal("custom"),
author: z.string().optional(),
createdAt: z.number().int().nonnegative().optional(),
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse an unknown value as a CustomModifierDescriptor. Throws ZodError.
*
* The inferred Zod output type is structurally compatible but Zod's
* representation of optional fields and `string`-typed `kind` doesn't
* exactly match the `CustomModifierDescriptor` interface (which has
* `kind: PrimitiveKind` and uses `?` not `| undefined`). The single
* boundary cast below is the pragmatic way to bridge this — every
* narrowing it implies is enforced separately by T19's validator.
*/
export function parseCustomModifierDescriptor(
raw: unknown,
): CustomModifierDescriptor {
return CustomModifierDescriptorSchema.parse(raw) as CustomModifierDescriptor;
}
/** Non-throwing variant, returns Zod's discriminated SafeParseReturnType. */
export function safeParseCustomModifierDescriptor(raw: unknown) {
return CustomModifierDescriptorSchema.safeParse(raw);
}
/** Serialize a CustomModifierDescriptor to a JSON-safe plain object. */
export function serializeCustomModifierDescriptor(
descriptor: CustomModifierDescriptor,
): unknown {
return CustomModifierDescriptorSchema.parse(descriptor);
}

View file

@ -0,0 +1,52 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ChessAttrKey } from "../../schema.js";
import {
asCustomModifierId,
type EffectPrimitiveNode,
type CustomModifierDescriptor,
type CustomModifierId,
} from "./types.js";
describe("custom modifier descriptor types", () => {
it("asCustomModifierId round-trips the same runtime string", () => {
const raw = "custom:hp-plus";
const id = asCustomModifierId(raw);
expect(id).toBe(raw);
expectTypeOf(id).toEqualTypeOf<CustomModifierId>();
});
it("accepts the expected descriptor structure", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("custom:rook-boost"),
name: "Rook boost",
description: "Adds a small directional bonus for rooks.",
version: 1,
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
],
targetAttrs: ["RangeBonus"],
uiForm: "primitive-composer",
source: "custom",
author: "Test Author",
createdAt: Date.now(),
};
expect(descriptor.type).toBe("data");
expect(descriptor.version).toBe(1);
expect(descriptor.source).toBe("custom");
expect(descriptor.uiForm).toBe("primitive-composer");
});
it("exposes targetAttrs as a readonly ChessAttrKey array type", () => {
expectTypeOf<CustomModifierDescriptor["targetAttrs"]>().toEqualTypeOf<
readonly ChessAttrKey[]
>();
});
it("exposes primitives as a readonly array type", () => {
expectTypeOf<CustomModifierDescriptor["primitives"]>().toEqualTypeOf<
readonly EffectPrimitiveNode[]
>();
});
});

View file

@ -0,0 +1,52 @@
import type { ChessAttrKey } from "../../schema.js";
import type { EffectPrimitiveNode } from "../primitives/types.js";
export type { EffectPrimitiveNode };
/**
* Compile-time branded identifier for user-authored custom modifiers.
*
* At runtime this is a plain `string`. The brand exists only in the type
* system to prevent accidental interchange with other string ids.
*/
export type CustomModifierId = string & { readonly __brand: "CustomModifierId" };
/**
* Coerce a raw string to the branded CustomModifierId type. Use ONLY at trust
* boundaries where you've already established that `s` is the canonical
* custom-modifier identifier (e.g. persisted library payloads or validated
* user input). Prefer passing `CustomModifierId` through end-to-end when
* possible; this helper is the single legitimate cast site.
*/
export const asCustomModifierId = (s: string): CustomModifierId => s as CustomModifierId;
/**
* User-authored custom modifier descriptor.
*
* `type` is the forward-compatibility discriminator for the custom-modifier
* family. T3 ships only data descriptors (`"data"`); T4 introduces
* `"scripted"` descriptors while preserving the shared trunk fields
* (`id`/`name`/`description`/`version`).
*/
export interface CustomModifierDescriptor {
readonly type: "data";
readonly id: CustomModifierId;
/** Human-readable title (1-40 chars, validated in Wave 3). */
readonly name: string;
/** Optional explanatory text (0-200 chars, validated in Wave 3). */
readonly description: string;
/** Descriptor schema version; v2+ must use a new id. */
readonly version: 1;
/** Primitive composition tree/list for this custom modifier. */
readonly primitives: readonly EffectPrimitiveNode[];
/** Attr keys this modifier reads/writes for UI conflict surfacing. */
readonly targetAttrs: readonly ChessAttrKey[];
/** Routes editing to the custom-modifier primitive composer UI. */
readonly uiForm: "primitive-composer";
/** Distinguishes this descriptor family from premade built-ins. */
readonly source: "custom";
/** Optional cosmetic attribution string; never server-validated. */
readonly author?: string;
/** Optional unix timestamp auto-populated by library persistence. */
readonly createdAt?: number;
}

View file

@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import "../primitives/index.js";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
import { validateCustomDescriptor } from "./validate.js";
function makeDescriptor(): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId("custom:validated"),
name: "Validated modifier",
description: "A descriptor used for validator tests.",
version: 1,
primitives: [
{
kind: "add-to-attribute",
params: {
attr: "HpBonus",
delta: 2,
},
},
],
targetAttrs: ["HpBonus"],
uiForm: "primitive-composer",
source: "custom",
};
}
describe("validateCustomDescriptor", () => {
it("accepts a valid descriptor", () => {
const result = validateCustomDescriptor(makeDescriptor());
expect(result).toEqual({ ok: true });
});
it("rejects empty descriptor id", () => {
const descriptor = {
...makeDescriptor(),
id: asCustomModifierId(""),
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.id.empty")).toBe(true);
}
});
it("rejects empty descriptor name", () => {
const descriptor = {
...makeDescriptor(),
name: "",
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true);
}
});
it("rejects descriptor name longer than 40 characters", () => {
const descriptor = {
...makeDescriptor(),
name: "x".repeat(41),
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true);
}
});
it("rejects descriptor description longer than 200 characters", () => {
const descriptor = {
...makeDescriptor(),
description: "x".repeat(201),
};
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.description.length")).toBe(
true,
);
}
});
it("rejects non-v1 version descriptors", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "version", { value: 2 });
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.version.unsupported")).toBe(
true,
);
}
});
it("rejects non-data descriptor type", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "type", { value: "scripted" });
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.type.invalid")).toBe(true);
}
});
it("rejects unknown primitive kinds", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "primitives", {
value: [{ kind: "not-real", params: {} }],
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "primitive.kind.unknown")).toBe(true);
}
});
it("collects paramsSchema validation errors", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "primitives", {
value: [
{
kind: "add-to-attribute",
params: {
attr: "HpBonus",
delta: "wrong-type",
},
},
],
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "primitive.params.invalid")).toBe(true);
}
});
it("enforces max nesting depth of 3 container levels", () => {
const deeplyNested = {
kind: "on-turn-start",
params: {
primitives: [
{
kind: "conditional",
params: {
condition: { type: "always" },
then: [
{
kind: "on-capture",
params: {
primitives: [
{
kind: "on-damaged",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
},
],
},
},
],
},
},
],
},
},
],
},
};
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "primitives", {
value: [deeplyNested],
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.primitives.depth.exceeded")).toBe(
true,
);
}
});
it("enforces max primitive count of 50", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "primitives", {
value: Array.from({ length: 51 }, () => ({
kind: "seed-attribute",
params: { attr: "HpBonus", value: 1 },
})),
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "descriptor.primitives.count.exceeded")).toBe(
true,
);
}
});
it("rejects nested params that reference the parent descriptor id", () => {
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "id", {
value: asCustomModifierId("custom:self-ref"),
});
Object.defineProperty(descriptor, "primitives", {
value: [
{
kind: "seed-attribute",
params: {
attr: "Hp",
value: {
nested: {
id: "custom:self-ref",
},
},
},
},
],
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "primitive.params.self-reference")).toBe(
true,
);
}
});
it("rejects structural circular params", () => {
const circular: { self?: unknown } = {};
circular.self = circular;
const descriptor = makeDescriptor();
Object.defineProperty(descriptor, "primitives", {
value: [
{
kind: "seed-attribute",
params: {
attr: "Hp",
value: circular,
},
},
],
});
const result = validateCustomDescriptor(descriptor);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.code === "primitive.params.circular")).toBe(true);
}
});
});

View file

@ -0,0 +1,250 @@
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
import type { EffectPrimitiveNode } from "../primitives/types.js";
import "../primitives/index.js";
import type { CustomModifierDescriptor } from "./types.js";
export type ValidationError = {
code: string;
path: (string | number)[];
message: string;
};
export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] };
const MAX_DESCRIPTOR_NAME_LENGTH = 40;
const MAX_DESCRIPTOR_DESCRIPTION_LENGTH = 200;
const MAX_RECURSION_DEPTH = 3;
const MAX_PRIMITIVE_COUNT = 50;
export function validateCustomDescriptor(
descriptor: CustomModifierDescriptor,
): ValidationResult {
const errors: ValidationError[] = [];
const descriptorId = String(descriptor.id);
if (descriptorId.length === 0) {
errors.push({
code: "descriptor.id.empty",
path: ["id"],
message: "descriptor.id must be non-empty",
});
}
if (descriptor.name.length < 1 || descriptor.name.length > MAX_DESCRIPTOR_NAME_LENGTH) {
errors.push({
code: "descriptor.name.length",
path: ["name"],
message: "descriptor.name must be between 1 and 40 characters",
});
}
if (descriptor.description.length > MAX_DESCRIPTOR_DESCRIPTION_LENGTH) {
errors.push({
code: "descriptor.description.length",
path: ["description"],
message: "descriptor.description must be between 0 and 200 characters",
});
}
if (descriptor.version !== 1) {
errors.push({
code: "descriptor.version.unsupported",
path: ["version"],
message: "descriptor.version must be 1",
});
}
if (descriptor.type !== "data") {
errors.push({
code: "descriptor.type.invalid",
path: ["type"],
message: 'descriptor.type must be "data"',
});
}
const walkState = {
totalPrimitiveCount: 0,
emittedPrimitiveCountError: false,
};
walkPrimitiveNodes({
nodes: descriptor.primitives,
errors,
descriptorId,
containerDepth: 0,
basePath: ["primitives"],
walkState,
});
if (errors.length === 0) {
return { ok: true };
}
return { ok: false, errors };
}
function walkPrimitiveNodes(input: {
nodes: readonly EffectPrimitiveNode[];
errors: ValidationError[];
descriptorId: string;
containerDepth: number;
basePath: (string | number)[];
walkState: {
totalPrimitiveCount: number;
emittedPrimitiveCountError: boolean;
};
}): void {
const { nodes, errors, descriptorId, containerDepth, basePath, walkState } = input;
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node === undefined) continue;
const nodePath = [...basePath, index] as (string | number)[];
const paramsPath = [...nodePath, "params"] as (string | number)[];
walkState.totalPrimitiveCount += 1;
if (
walkState.totalPrimitiveCount > MAX_PRIMITIVE_COUNT &&
!walkState.emittedPrimitiveCountError
) {
errors.push({
code: "descriptor.primitives.count.exceeded",
path: ["primitives"],
message: `descriptor primitives cannot exceed ${MAX_PRIMITIVE_COUNT} nodes`,
});
walkState.emittedPrimitiveCountError = true;
}
scanParamsForCyclesAndSelfReference({
value: node.params,
descriptorId,
errors,
basePath: paramsPath,
stack: new Set<object>(),
seen: new Set<object>(),
});
const primitiveDescriptor = PRIMITIVE_REGISTRY.get(node.kind);
if (primitiveDescriptor === undefined) {
errors.push({
code: "primitive.kind.unknown",
path: [...nodePath, "kind"],
message: `Unknown primitive kind: ${node.kind}`,
});
continue;
}
const parsedParams = primitiveDescriptor.paramsSchema.safeParse(node.params);
if (!parsedParams.success) {
for (const issue of parsedParams.error.issues) {
errors.push({
code: "primitive.params.invalid",
path: [...paramsPath, ...normalizeIssuePath(issue.path)],
message: issue.message,
});
}
}
if (primitiveDescriptor.childPrimitives === undefined) {
continue;
}
const nextDepth = containerDepth + 1;
if (nextDepth > MAX_RECURSION_DEPTH) {
errors.push({
code: "descriptor.primitives.depth.exceeded",
path: nodePath,
message: `primitive nesting depth cannot exceed ${MAX_RECURSION_DEPTH}`,
});
continue;
}
let children: readonly EffectPrimitiveNode[] = [];
try {
const paramsForChildren = parsedParams.success ? parsedParams.data : node.params;
children = primitiveDescriptor.childPrimitives(paramsForChildren);
} catch {
children = [];
}
walkPrimitiveNodes({
nodes: children,
errors,
descriptorId,
containerDepth: nextDepth,
basePath: [...nodePath, "children"],
walkState,
});
}
}
function normalizeIssuePath(path: readonly (string | number | symbol)[]): (string | number)[] {
return path.map((part) => (typeof part === "number" ? part : String(part)));
}
function scanParamsForCyclesAndSelfReference(input: {
value: unknown;
descriptorId: string;
errors: ValidationError[];
basePath: (string | number)[];
stack: Set<object>;
seen: Set<object>;
}): void {
const { value, descriptorId, errors, basePath, stack, seen } = input;
if (value === null || typeof value !== "object") {
return;
}
if (stack.has(value)) {
errors.push({
code: "primitive.params.circular",
path: basePath,
message: "primitive params contain a structural circular reference",
});
return;
}
if (seen.has(value)) {
return;
}
seen.add(value);
stack.add(value);
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
scanParamsForCyclesAndSelfReference({
value: value[index],
descriptorId,
errors,
basePath: [...basePath, index],
stack,
seen,
});
}
stack.delete(value);
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === "id" && typeof nested === "string" && nested === descriptorId) {
errors.push({
code: "primitive.params.self-reference",
path: [...basePath, key],
message: "primitive params must not reference the parent descriptor id",
});
}
scanParamsForCyclesAndSelfReference({
value: nested,
descriptorId,
errors,
basePath: [...basePath, key],
stack,
seen,
});
}
stack.delete(value);
}

View file

@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import { CaptureFlag } from "../../schema.js";
import "./capture-flags.js";
import { CAPTURE_FLAGS_DESCRIPTOR, hasCaptureFlag } from "./capture-flags.js";
describe("capture-flags descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'capture-flags'", () => {
expect(MODIFIER_REGISTRY.has("capture-flags")).toBe(true);
expect(MODIFIER_REGISTRY.get("capture-flags")).toBe(CAPTURE_FLAGS_DESCRIPTOR);
});
});
describe("capture-flags descriptor — apply()", () => {
it("seeds CaptureFlags fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED);
expect(session.contains(pieceId, "CaptureFlags")).toBe(true);
expect(session.get(pieceId, "CaptureFlags")).toBe(CaptureFlag.CANNOT_BE_CAPTURED);
});
});
describe("capture-flags descriptor — describe()", () => {
it("returns 'no flags' for 0", () => {
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(0)).toBe("no flags");
});
it("describes combined CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED flags", () => {
const combined = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED;
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(combined)).toBe("can-capture-own, cannot-be-captured");
});
it("describes all three flags", () => {
const allFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(allFlags)).toBe(
"can-capture-own, cannot-be-captured, en-passant",
);
});
it("describes a single flag", () => {
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(CaptureFlag.EN_PASSANT)).toBe("en-passant");
});
});
describe("capture-flags descriptor — hasCaptureFlag()", () => {
it("returns true when the flag is set", () => {
const session = new Session();
const pieceId = session.nextId();
CAPTURE_FLAGS_DESCRIPTOR.apply(
session,
pieceId,
CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED,
);
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(true);
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN)).toBe(true);
});
it("returns false when the flag is not set", () => {
const session = new Session();
const pieceId = session.nextId();
CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN);
expect(hasCaptureFlag(session, pieceId, CaptureFlag.EN_PASSANT)).toBe(false);
});
it("returns false when CaptureFlags fact is absent", () => {
const session = new Session();
const pieceId = session.nextId();
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(false);
});
});
describe("capture-flags descriptor — valueSchema", () => {
const schema = CAPTURE_FLAGS_DESCRIPTOR.valueSchema;
it("accepts 0 (no flags)", () => {
expect(() => schema.parse(0)).not.toThrow();
});
it("accepts MAX_FLAGS (all flags OR'd together = 7)", () => {
const maxFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
expect(() => schema.parse(maxFlags)).not.toThrow();
});
it("rejects -1 (below min)", () => {
expect(() => schema.parse(-1)).toThrow();
});
it("rejects 8 (above MAX_FLAGS = 7)", () => {
expect(() => schema.parse(8)).toThrow();
});
it("rejects non-integer values", () => {
expect(() => schema.parse(1.5)).toThrow();
});
});

View file

@ -0,0 +1,39 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
import { CaptureFlag } from "../../schema.js";
// Bitflag validation: valid values are any combination of the 3 flags
const MAX_FLAGS = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
const schema = z.number().int().min(0).max(MAX_FLAGS);
type Value = z.infer<typeof schema>;
function describeFlags(value: Value): string {
const parts: string[] = [];
if (value & CaptureFlag.CAN_CAPTURE_OWN) parts.push("can-capture-own");
if (value & CaptureFlag.CANNOT_BE_CAPTURED) parts.push("cannot-be-captured");
if (value & CaptureFlag.EN_PASSANT) parts.push("en-passant");
return parts.length > 0 ? parts.join(", ") : "no flags";
}
export const CAPTURE_FLAGS_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "capture-flags",
attrName: "CaptureFlags",
label: "Capture Behavior",
valueSchema: schema,
stackingRule: "union", // bitwise OR for stacking
uiForm: "capture-flags",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "CaptureFlags", effectiveValue);
},
describe: describeFlags,
};
MODIFIER_REGISTRY.register(CAPTURE_FLAGS_DESCRIPTOR);
/** Check if a piece has a specific capture flag set. */
export function hasCaptureFlag(session: Session, pieceId: EntityId, flag: number): boolean {
const flags = session.get(pieceId, "CaptureFlags");
return typeof flags === "number" && (flags & flag) !== 0;
}

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./damage-resistance.js";
import {
DAMAGE_RESISTANCE_DESCRIPTOR,
applyResistance,
stackResistances,
} from "./damage-resistance.js";
describe("damage-resistance descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'damage-resistance'", () => {
expect(MODIFIER_REGISTRY.has("damage-resistance")).toBe(true);
expect(MODIFIER_REGISTRY.get("damage-resistance")).toBe(DAMAGE_RESISTANCE_DESCRIPTOR);
});
});
describe("damage-resistance descriptor — apply()", () => {
it("seeds DamageResistance fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
DAMAGE_RESISTANCE_DESCRIPTOR.apply(session, pieceId, 0.5);
expect(session.contains(pieceId, "DamageResistance")).toBe(true);
expect(session.get(pieceId, "DamageResistance")).toBe(0.5);
});
});
describe("damage-resistance descriptor — describe()", () => {
it("formats 0.5 as '50% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0.5)).toBe("50% damage resistance");
});
it("formats 0 as '0% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0)).toBe("0% damage resistance");
});
it("formats 1 as '100% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(1)).toBe("100% damage resistance");
});
});
describe("applyResistance()", () => {
it("halves damage at 0.5 resistance", () => {
expect(applyResistance(4, 0.5)).toBe(2);
});
it("passes full damage through at 0 resistance", () => {
expect(applyResistance(4, 0)).toBe(4);
});
it("reduces damage to 0 at 1 (full immunity)", () => {
expect(applyResistance(4, 1)).toBe(0);
});
it("clamps negative results to 0", () => {
// Resistance > 1 is schema-invalid, but applyResistance is defensive
expect(applyResistance(4, 1.5)).toBe(0);
});
});
describe("stackResistances()", () => {
it("returns 0 for an empty array", () => {
expect(stackResistances([])).toBe(0);
});
it("stacks two 0.5 resistances multiplicatively to 0.75", () => {
// 1 - (0.5 * 0.5) = 0.75
expect(stackResistances([0.5, 0.5])).toBe(0.75);
});
it("stacks three 0.5 resistances to 0.875", () => {
// 1 - (0.5 * 0.5 * 0.5) = 0.875
expect(stackResistances([0.5, 0.5, 0.5])).toBe(0.875);
});
it("returns 0 for a single 0 resistance", () => {
expect(stackResistances([0])).toBe(0);
});
it("returns 1 (clamped) for a single full immunity", () => {
expect(stackResistances([1])).toBe(1);
});
});

View file

@ -0,0 +1,49 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
// Resistance: 0 = no resistance, 1 = immune (100% damage reduction)
const schema = z.number().min(0).max(1);
type Value = z.infer<typeof schema>;
export const DAMAGE_RESISTANCE_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "damage-resistance",
attrName: "DamageResistance",
label: "Damage Resistance",
valueSchema: schema,
stackingRule: "multiplicative",
uiForm: "percentage",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "DamageResistance", effectiveValue);
},
describe(value: Value): string {
return `${Math.round(value * 100)}% damage resistance`;
},
};
MODIFIER_REGISTRY.register(DAMAGE_RESISTANCE_DESCRIPTOR);
/**
* Apply damage resistance to an incoming damage amount.
* Returns the effective damage after applying resistance.
* Result is always ≥ 0.
*
* Formula (from ADR-4 stacking): effective resistance already stacked
* multiplicatively before being seeded in DamageResistance fact.
* So: effectiveDamage = amount * (1 - resistance), clamped to [0, ∞).
*/
export function applyResistance(amount: number, resistance: number): number {
return Math.max(0, amount * (1 - resistance));
}
/**
* Compute stacked resistance from multiple sources using multiplicative formula.
* ADR-4: effectiveResistance = 1 - ∏(1 - r_i)
* Result clamped to [0, 1].
*/
export function stackResistances(resistances: readonly number[]): number {
if (resistances.length === 0) return 0;
const product = resistances.reduce((acc, r) => acc * (1 - r), 1);
return Math.max(0, Math.min(1, 1 - product));
}

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./direction-additions.js";
import {
DIRECTION_ADDITIONS_DESCRIPTOR,
generateDirectionMoves,
} from "./direction-additions.js";
// Square encoding: square = rank * 8 + file
// e4 = rank 3, file 4 → 3*8+4 = 28
// e3 = rank 2, file 4 → 2*8+4 = 20
// e5 = rank 4, file 4 → 4*8+4 = 36
// d4 = rank 3, file 3 → 3*8+3 = 27
// f4 = rank 3, file 5 → 3*8+5 = 29
describe("direction-additions descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'direction-additions'", () => {
expect(MODIFIER_REGISTRY.has("direction-additions")).toBe(true);
expect(MODIFIER_REGISTRY.get("direction-additions")).toBe(DIRECTION_ADDITIONS_DESCRIPTOR);
});
});
describe("direction-additions descriptor — apply()", () => {
it("seeds DirectionAdditions fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
expect(session.contains(pieceId, "DirectionAdditions")).toBe(true);
expect(session.get(pieceId, "DirectionAdditions")).toEqual(["backward"]);
});
});
describe("direction-additions descriptor — describe()", () => {
it("formats a single direction", () => {
expect(DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward"])).toBe("+Directions: forward");
});
it("contains all direction names when multiple directions given (union stacking)", () => {
const result = DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward", "backward"]);
expect(result).toContain("forward");
expect(result).toContain("backward");
});
it("formats all eight directions", () => {
const all = DIRECTION_ADDITIONS_DESCRIPTOR.describe([
"forward", "backward", "left", "right",
"diagonal-fl", "diagonal-fr", "diagonal-bl", "diagonal-br",
]);
expect(all).toContain("diagonal-fl");
expect(all).toContain("diagonal-br");
});
});
describe("direction-additions — generateDirectionMoves()", () => {
it("returns backward move for white pawn at e4 when e3 is empty", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(1);
expect(moves[0]).toMatchObject({ from: 28, to: 20, isCapture: false });
});
it("returns no move when target square is occupied (blocked backward)", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
// Blocker at e3 (square 20)
const blockerId = session.nextId();
session.insert(blockerId, "PieceType", "pawn");
session.insert(blockerId, "Color", "black");
session.insert(blockerId, "Position", 20); // e3
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
it("returns union of moves for two directions, no duplication", () => {
// white pawn at e4: forward → e5 (36), backward → e3 (20)
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward", "backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(2);
const targets = moves.map(m => m.to);
expect(targets).toContain(36); // e5 (forward)
expect(targets).toContain(20); // e3 (backward)
});
it("returns no moves when no DirectionAdditions fact is set", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 28); // e4
// DirectionAdditions NOT seeded
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
it("uses color-relative forward direction: black pawn at e5 forward goes to e4", () => {
// black forward = dr=-1; e5 = square 36, e4 = square 28
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "black");
session.insert(pieceId, "Position", 36); // e5
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(1);
expect(moves[0]).toMatchObject({ from: 36, to: 28, isCapture: false });
});
it("skips out-of-board targets (e.g. backward from rank 1)", () => {
// white pawn at e1 (square 4, rank 0), backward would go to rank -1
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
session.insert(pieceId, "Color", "white");
session.insert(pieceId, "Position", 4); // e1
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
const moves = generateDirectionMoves(session, pieceId);
expect(moves).toHaveLength(0);
});
});

View file

@ -0,0 +1,102 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor, Direction } from "../types.js";
import type { PieceColor, Square } from "../../schema.js";
import { fileOf, rankOf, isOnBoard, squareOf } from "../../coord.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
} from "../../rules/board-queries.js";
import type { LegalMove } from "../../rules/types.js";
const schema = z.array(
z.enum([
"forward",
"backward",
"left",
"right",
"diagonal-fl",
"diagonal-fr",
"diagonal-bl",
"diagonal-br",
]),
);
type Value = z.infer<typeof schema>;
/**
* Compute file+rank delta for a named direction from a piece's perspective.
* forward/backward are color-relative (toward/away from opponent's back rank).
* left/right are board-absolute (toward a-file / h-file respectively).
* Diagonals combine the two axes accordingly.
*/
function directionDelta(dir: Direction, color: PieceColor): { df: number; dr: number } {
// +1 = white advances up ranks; -1 = black advances down ranks
const forward = color === "white" ? 1 : -1;
switch (dir) {
case "forward": return { df: 0, dr: forward };
case "backward": return { df: 0, dr: -forward };
case "left": return { df: -1, dr: 0 }; // always toward a-file
case "right": return { df: 1, dr: 0 }; // always toward h-file
case "diagonal-fl": return { df: -1, dr: forward }; // forward + toward a-file
case "diagonal-fr": return { df: 1, dr: forward }; // forward + toward h-file
case "diagonal-bl": return { df: -1, dr: -forward }; // backward + toward a-file
case "diagonal-br": return { df: 1, dr: -forward }; // backward + toward h-file
}
}
export const DIRECTION_ADDITIONS_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "direction-additions",
attrName: "DirectionAdditions",
label: "Direction Additions",
valueSchema: schema,
stackingRule: "union",
uiForm: "direction-set",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "DirectionAdditions", effectiveValue);
},
describe(value: Value): string {
return `+Directions: ${value.join(", ")}`;
},
};
MODIFIER_REGISTRY.register(DIRECTION_ADDITIONS_DESCRIPTOR);
/**
* Generate 1-square non-capture moves in all directions listed in the
* piece's `DirectionAdditions` fact.
*
* Semantics:
* - Step-piece only: exactly 1 square per direction (no sliding).
* - Non-capture only: skips squares occupied by any piece.
* Capture semantics are handled separately by CaptureFlags.
* - Out-of-board targets are silently skipped.
*
* Called by the engine integration layer (T14) after the modifier profile
* has seeded `DirectionAdditions` on the piece.
*/
export function generateDirectionMoves(
session: Session,
pieceId: EntityId,
): LegalMove[] {
const directions = session.get(pieceId, "DirectionAdditions") as readonly string[] | undefined;
if (!directions || directions.length === 0) return [];
const from = getPiecePosition(session, pieceId);
const color = getPieceColor(session, pieceId);
if (from === null || color === null) return [];
const moves: LegalMove[] = [];
for (const dir of directions as Direction[]) {
const { df, dr } = directionDelta(dir, color);
const toFile = fileOf(from as Square) + df;
const toRank = rankOf(from as Square) + dr;
if (!isOnBoard(toFile, toRank)) continue;
const to = squareOf(toFile, toRank);
if (!isPieceAt(session, to)) {
moves.push({ pieceId, from: from as Square, to, isCapture: false });
}
}
return moves;
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./hp-bonus.js";
import { HP_BONUS_DESCRIPTOR } from "./hp-bonus.js";
describe("hp-bonus descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'hp-bonus'", () => {
expect(MODIFIER_REGISTRY.has("hp-bonus")).toBe(true);
expect(MODIFIER_REGISTRY.get("hp-bonus")).toBe(HP_BONUS_DESCRIPTOR);
});
});
describe("hp-bonus descriptor — describe()", () => {
it("formats positive values with a leading '+'", () => {
expect(HP_BONUS_DESCRIPTOR.describe(3)).toBe("HP +3");
});
it("formats negative values without an extra sign", () => {
expect(HP_BONUS_DESCRIPTOR.describe(-1)).toBe("HP -1");
});
it("formats zero as '+'", () => {
expect(HP_BONUS_DESCRIPTOR.describe(0)).toBe("HP +0");
});
});
describe("hp-bonus descriptor — apply()", () => {
it("seeds HpBonus fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
// Seed a PieceType fact so it's a "real" piece entity.
session.insert(pieceId, "PieceType", "pawn");
HP_BONUS_DESCRIPTOR.apply(session, pieceId, 3);
expect(session.contains(pieceId, "HpBonus")).toBe(true);
expect(session.get(pieceId, "HpBonus")).toBe(3);
});
});
describe("hp-bonus descriptor — valueSchema", () => {
const schema = HP_BONUS_DESCRIPTOR.valueSchema;
it("accepts values within range [-10, 10]", () => {
expect(() => schema.parse(10)).not.toThrow();
expect(() => schema.parse(-10)).not.toThrow();
expect(() => schema.parse(0)).not.toThrow();
});
it("rejects values above 10", () => {
expect(() => schema.parse(11)).toThrow();
});
it("rejects non-integer values", () => {
expect(() => schema.parse(1.5)).toThrow();
});
});

View file

@ -0,0 +1,26 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
const schema = z.number().int().min(-10).max(10);
type Value = z.infer<typeof schema>;
const descriptor: ModifierDescriptor<Value> = {
id: "hp-bonus",
attrName: "HpBonus",
baseAttr: "Hp",
label: "HP Bonus",
valueSchema: schema,
stackingRule: "additive",
uiForm: "number",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "HpBonus", effectiveValue);
},
describe(value: Value): string {
return `HP ${value >= 0 ? "+" : ""}${value}`;
},
};
MODIFIER_REGISTRY.register(descriptor);
export { descriptor as HP_BONUS_DESCRIPTOR };

View file

@ -0,0 +1,245 @@
import { describe, it, expect } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./promotion-override.js";
import { PROMOTION_OVERRIDE_DESCRIPTOR } from "./promotion-override.js";
import type { PieceType, PieceColor, Square } from "../../schema.js";
import { getPromotionMoves, applyPromotion } from "../../rules/promotion.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function setupSession(): Session {
return new Session({ autoFire: false });
}
function insertPiece(
session: Session,
id: number,
type: PieceType,
color: PieceColor,
square: Square,
): EntityId {
const eid = id as EntityId;
session.insert(eid, "PieceType", type);
session.insert(eid, "Color", color);
session.insert(eid, "Position", square);
return eid;
}
// ─── Registry ────────────────────────────────────────────────────────────────
describe("promotion-override descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'promotion-override'", () => {
expect(MODIFIER_REGISTRY.has("promotion-override")).toBe(true);
expect(MODIFIER_REGISTRY.get("promotion-override")).toBe(PROMOTION_OVERRIDE_DESCRIPTOR);
});
it("has the correct id, attrName, and uiForm", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.id).toBe("promotion-override");
expect(PROMOTION_OVERRIDE_DESCRIPTOR.attrName).toBe("PromotionOverride");
expect(PROMOTION_OVERRIDE_DESCRIPTOR.uiForm).toBe("promotion-target");
expect(PROMOTION_OVERRIDE_DESCRIPTOR.stackingRule).toBe("priority-wins");
});
});
// ─── describe() ──────────────────────────────────────────────────────────────
describe("promotion-override descriptor — describe()", () => {
it("returns 'Cannot promote' for 'disabled'", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("disabled")).toBe("Cannot promote");
});
it("returns 'Promotes to queen' for 'queen'", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("queen")).toBe("Promotes to queen");
});
it("returns 'Promotes to rook' for 'rook'", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("rook")).toBe("Promotes to rook");
});
it("returns 'Promotes to bishop' for 'bishop'", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("bishop")).toBe("Promotes to bishop");
});
it("returns 'Promotes to knight' for 'knight'", () => {
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("knight")).toBe("Promotes to knight");
});
});
// ─── apply() ─────────────────────────────────────────────────────────────────
describe("promotion-override descriptor — apply()", () => {
it("seeds PromotionOverride='bishop' fact on piece entity", () => {
const session = new Session();
const eid = session.nextId();
session.insert(eid, "PieceType", "pawn");
PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "bishop");
expect(session.contains(eid, "PromotionOverride")).toBe(true);
expect(session.get(eid, "PromotionOverride")).toBe("bishop");
});
it("seeds PromotionOverride='disabled' fact on piece entity", () => {
const session = new Session();
const eid = session.nextId();
session.insert(eid, "PieceType", "pawn");
PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "disabled");
expect(session.get(eid, "PromotionOverride")).toBe("disabled");
});
});
// ─── valueSchema ─────────────────────────────────────────────────────────────
describe("promotion-override descriptor — valueSchema", () => {
const schema = PROMOTION_OVERRIDE_DESCRIPTOR.valueSchema;
it("accepts all 4 promotion piece types", () => {
expect(() => schema.parse("queen")).not.toThrow();
expect(() => schema.parse("rook")).not.toThrow();
expect(() => schema.parse("bishop")).not.toThrow();
expect(() => schema.parse("knight")).not.toThrow();
});
it("accepts 'disabled'", () => {
expect(() => schema.parse("disabled")).not.toThrow();
});
it("rejects 'king' (not a valid promotion target)", () => {
expect(() => schema.parse("king")).toThrow();
});
it("rejects 'pawn' (cannot promote to pawn)", () => {
expect(() => schema.parse("pawn")).toThrow();
});
it("rejects arbitrary strings", () => {
expect(() => schema.parse("dragon")).toThrow();
expect(() => schema.parse("")).toThrow();
});
});
// ─── Integration: getPromotionMoves ──────────────────────────────────────────
describe("promotion-override — integration with getPromotionMoves", () => {
it("PromotionOverride='bishop': returns only bishop promotion move", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 → e8
session.insert(pawn, "PromotionOverride", "bishop");
const moves = getPromotionMoves(session, pawn);
expect(moves).toHaveLength(1);
const move = moves[0]!;
expect(move.promoteTo).toBe("bishop");
expect(move.to).toBe(60); // e8
expect(move.isCapture).toBe(false);
});
it("PromotionOverride='disabled': returns empty array (pawn cannot promote)", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
session.insert(pawn, "PromotionOverride", "disabled");
const moves = getPromotionMoves(session, pawn);
expect(moves).toEqual([]);
});
it("PromotionOverride='queen': returns only queen promotion move", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
session.insert(pawn, "PromotionOverride", "queen");
const moves = getPromotionMoves(session, pawn);
expect(moves).toHaveLength(1);
expect(moves[0]!.promoteTo).toBe("queen");
});
it("PromotionOverride='disabled' also suppresses capture-promotions", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7
insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy
session.insert(pawn, "PromotionOverride", "disabled");
const moves = getPromotionMoves(session, pawn);
expect(moves).toEqual([]);
});
it("PromotionOverride='knight': capture-promotion returns only knight captures", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7
insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy
insertPiece(session, 3, "queen", "white", 59); // d8 — ally blocks push
session.insert(pawn, "PromotionOverride", "knight");
const moves = getPromotionMoves(session, pawn);
// Only capture to e8 remains (d8 push blocked); override limits to knight
expect(moves).toHaveLength(1);
const knightMove = moves[0]!;
expect(knightMove.promoteTo).toBe("knight");
expect(knightMove.isCapture).toBe(true);
});
it("no PromotionOverride: returns all 4 standard promotion variants (baseline)", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
const moves = getPromotionMoves(session, pawn);
expect(moves).toHaveLength(4);
const types = moves.map((m) => m.promoteTo).sort();
expect(types).toEqual(["bishop", "knight", "queen", "rook"]);
});
});
// ─── Integration: applyPromotion ─────────────────────────────────────────────
describe("promotion-override — integration with applyPromotion", () => {
it("PromotionOverride='bishop': promotes to bishop regardless of promoteTo arg", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 60);
session.insert(pawn, "PromotionOverride", "bishop");
applyPromotion(session, pawn, "queen"); // caller requests queen, override wins
expect(session.get(pawn, "PieceType")).toBe("bishop");
});
it("PromotionOverride='rook': promotes to rook regardless of default promoteTo", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 60);
session.insert(pawn, "PromotionOverride", "rook");
applyPromotion(session, pawn); // no promoteTo arg — default is queen, override wins
expect(session.get(pawn, "PieceType")).toBe("rook");
});
it("PromotionOverride='disabled': falls back to promoteTo arg (pawn stays pawn only if not promoted)", () => {
// When override is "disabled", no promotion moves are generated so
// applyPromotion shouldn't be called. If it is called anyway, it falls
// back to the caller's promoteTo arg (normal behaviour).
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 60);
session.insert(pawn, "PromotionOverride", "disabled");
applyPromotion(session, pawn, "knight");
expect(session.get(pawn, "PieceType")).toBe("knight");
});
it("no PromotionOverride: applyPromotion uses promoteTo arg normally", () => {
const session = setupSession();
const pawn = insertPiece(session, 1, "pawn", "white", 60);
applyPromotion(session, pawn, "rook");
expect(session.get(pawn, "PieceType")).toBe("rook");
});
});

View file

@ -0,0 +1,27 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
// Valid override: any standard promotion target, or "disabled" (no promotion).
// "king" and "pawn" are intentionally excluded — king is not a valid promotion
// target and pawn would be a no-op / non-sensical promotion.
const promotionTargetSchema = z.enum(["queen", "rook", "bishop", "knight", "disabled"]);
type Value = z.infer<typeof promotionTargetSchema>;
export const PROMOTION_OVERRIDE_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "promotion-override",
attrName: "PromotionOverride",
label: "Promotion Override",
valueSchema: promotionTargetSchema,
stackingRule: "priority-wins",
uiForm: "promotion-target",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "PromotionOverride", effectiveValue);
},
describe(value: Value): string {
return value === "disabled" ? "Cannot promote" : `Promotes to ${value}`;
},
};
MODIFIER_REGISTRY.register(PROMOTION_OVERRIDE_DESCRIPTOR);

View file

@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { RANGE_BONUS_DESCRIPTOR } from "./range-bonus.js";
import { MODIFIER_REGISTRY } from "../registry.js";
function setupSession(): Session {
return new Session({ autoFire: false });
}
describe("RANGE_BONUS_DESCRIPTOR", () => {
it("registers in MODIFIER_REGISTRY with id 'range-bonus'", () => {
expect(MODIFIER_REGISTRY.has("range-bonus")).toBe(true);
expect(MODIFIER_REGISTRY.get("range-bonus")).toBe(RANGE_BONUS_DESCRIPTOR);
});
it("has correct metadata", () => {
expect(RANGE_BONUS_DESCRIPTOR.id).toBe("range-bonus");
expect(RANGE_BONUS_DESCRIPTOR.attrName).toBe("RangeBonus");
expect(RANGE_BONUS_DESCRIPTOR.stackingRule).toBe("additive");
expect(RANGE_BONUS_DESCRIPTOR.uiForm).toBe("number");
});
describe("describe()", () => {
it("returns 'Range +N' for non-negative values", () => {
expect(RANGE_BONUS_DESCRIPTOR.describe(2)).toBe("Range +2");
expect(RANGE_BONUS_DESCRIPTOR.describe(0)).toBe("Range +0");
expect(RANGE_BONUS_DESCRIPTOR.describe(7)).toBe("Range +7");
});
it("returns 'Range -N' for negative values", () => {
expect(RANGE_BONUS_DESCRIPTOR.describe(-1)).toBe("Range -1");
expect(RANGE_BONUS_DESCRIPTOR.describe(-7)).toBe("Range -7");
});
});
describe("apply()", () => {
it("inserts RangeBonus fact on the piece entity", () => {
const session = setupSession();
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 3);
expect(session.get(1 as EntityId, "RangeBonus")).toBe(3);
});
it("clamps effectiveValue above RANGE_MAX (7) down to 7", () => {
const session = setupSession();
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 10 as number);
expect(session.get(1 as EntityId, "RangeBonus")).toBe(7);
});
it("clamps negative effectiveValue to 0", () => {
const session = setupSession();
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, -5 as number);
expect(session.get(1 as EntityId, "RangeBonus")).toBe(0);
});
it("applies exactly RANGE_MAX (7) without clamping", () => {
const session = setupSession();
RANGE_BONUS_DESCRIPTOR.apply(session, 2 as EntityId, 7);
expect(session.get(2 as EntityId, "RangeBonus")).toBe(7);
});
it("applies zero without clamping", () => {
const session = setupSession();
RANGE_BONUS_DESCRIPTOR.apply(session, 3 as EntityId, 0);
expect(session.get(3 as EntityId, "RangeBonus")).toBe(0);
});
});
});

View file

@ -0,0 +1,25 @@
import { z } from "zod";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
const RANGE_MAX = 7;
const schema = z.number().int().min(-7).max(7);
type Value = z.infer<typeof schema>;
export const RANGE_BONUS_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "range-bonus",
attrName: "RangeBonus",
label: "Range Bonus",
valueSchema: schema,
stackingRule: "additive",
uiForm: "number",
apply(session, pieceId, effectiveValue) {
const clamped = Math.max(0, Math.min(RANGE_MAX, effectiveValue));
session.insert(pieceId, "RangeBonus", clamped);
},
describe(value) {
return `Range ${value >= 0 ? "+" : ""}${value}`;
},
};
MODIFIER_REGISTRY.register(RANGE_BONUS_DESCRIPTOR);

View file

@ -0,0 +1,28 @@
/**
* Barrel for the piece modifier profile system.
*
* Importing this module ensures every T1 modifier descriptor is
* registered in `MODIFIER_REGISTRY` via side-effect imports (ADR-8).
* Descriptor imports are added by T6-T11 as each modifier is
* implemented; until then the registry starts empty.
*/
// Re-export registry and types for consumers.
export { MODIFIER_REGISTRY } from "./registry.js";
export type {
ModifierKindId,
Direction,
TypeModifier,
InstanceModifier,
ModifierProfile,
ModifierDescriptor,
} from "./types.js";
export { typeModifier, instanceModifier } from "./types.js";
// Descriptor side-effect imports — all 6 T1 modifiers (ADR-8):
import "./descriptors/hp-bonus.js";
import "./descriptors/range-bonus.js";
import "./descriptors/direction-additions.js";
import "./descriptors/capture-flags.js";
import "./descriptors/promotion-override.js";
import "./descriptors/damage-resistance.js";

View file

@ -0,0 +1,261 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import {
loadLibrary,
saveToLibrary,
deleteFromLibrary,
setStarred,
duplicateEntry,
makeId,
__test__,
type SavedModifierProfile,
} from "./library.js";
import type { ModifierProfile } from "./types.js";
// happy-dom provides a localStorage object but its methods are
// bound to a prototype that doesn't survive certain destructuring
// patterns; we install a simple Map-backed shim unconditionally so
// the tests have predictable behavior.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
// Clear between tests so each one starts fresh.
beforeEach(() => {
localStorage.clear();
});
const emptyProfile: ModifierProfile = {
id: "test",
name: "Test Profile",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
function seed(entry: Partial<SavedModifierProfile> = {}): SavedModifierProfile {
return {
id: makeId(),
name: "Test",
profile: emptyProfile,
starred: false,
updatedAt: Date.now(),
...entry,
};
}
describe("loadLibrary()", () => {
it("returns [] when no key is set", () => {
expect(loadLibrary()).toEqual([]);
});
it("returns [] when storage contains malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "not json");
expect(loadLibrary()).toEqual([]);
});
it("filters out entries that fail shape validation", () => {
const validEntry: SavedModifierProfile = {
id: "ok",
name: "ok",
profile: emptyProfile,
starred: false,
updatedAt: 0,
};
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
validEntry,
{ not: "a valid entry" },
]),
);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.id).toBe("ok");
});
it("filters out entries with invalid nested profile", () => {
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
{
id: "bad-profile",
name: "bad",
profile: { version: 99, source: "unknown" },
starred: false,
updatedAt: 0,
},
]),
);
expect(loadLibrary()).toHaveLength(0);
});
});
describe("saveToLibrary()", () => {
it("appends a new entry", () => {
const result = saveToLibrary(seed({ name: "First" }));
expect(result.ok).toBe(true);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("First");
});
it("updates in place when id matches", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
saveToLibrary({ ...entry, name: "Renamed" });
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("Renamed");
});
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Save one more — oldest (E0) should be evicted.
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
const library = loadLibrary();
expect(library).toHaveLength(__test__.MAX_ENTRIES);
expect(library.map((e) => e.name)).not.toContain("E0");
expect(library.some((e) => e.name === "newest")).toBe(true);
});
it("refuses save when every entry is starred and library is full", () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
}
const result = saveToLibrary(seed({ name: "newest" }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/unstar/i);
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
});
});
describe("deleteFromLibrary()", () => {
it("removes the matching entry", () => {
const entry = seed();
saveToLibrary(entry);
deleteFromLibrary(entry.id);
expect(loadLibrary()).toHaveLength(0);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
deleteFromLibrary("not-real");
expect(loadLibrary()).toHaveLength(1);
});
});
describe("setStarred()", () => {
it("toggles starred and updates updatedAt", () => {
const entry = seed({ starred: false, updatedAt: 100 });
saveToLibrary(entry);
const before = Date.now();
setStarred(entry.id, true);
const library = loadLibrary();
expect(library[0]?.starred).toBe(true);
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
setStarred("not-real", true);
expect(loadLibrary()[0]?.starred).toBe(false);
});
});
describe("duplicateEntry()", () => {
it("creates a copy with a new id and '(copy)' name suffix", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
expect(newId).toBeDefined();
expect(newId).not.toBe(entry.id);
const library = loadLibrary();
expect(library).toHaveLength(2);
const copy = library.find((e) => e.id === newId);
expect(copy?.name).toBe("Original (copy)");
expect(copy?.starred).toBe(false);
});
it("preserves the profile on duplication", () => {
const profileWithData: ModifierProfile = {
id: "p1",
name: "Buff Profile",
description: "has modifiers",
perType: [{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }],
perInstance: [],
version: 1,
source: "custom",
};
const entry = seed({ name: "Source", profile: profileWithData });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
const library = loadLibrary();
const copy = library.find((e) => e.id === newId);
expect(copy?.profile).toEqual(profileWithData);
});
it("returns undefined for unknown id", () => {
expect(duplicateEntry("not-real")).toBeUndefined();
});
});
describe("makeId()", () => {
it("produces unique ids", () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(makeId());
expect(ids.size).toBe(100);
});
it("falls back when crypto.randomUUID is unavailable", () => {
const original = crypto.randomUUID;
// @ts-expect-error — intentional override for test
crypto.randomUUID = undefined;
try {
const id = makeId();
expect(id).toMatch(/^layout-/);
} finally {
crypto.randomUUID = original;
}
});
});
// Silence React testing-library warnings if this file runs in a mixed env.
vi.mock("react", async () => await vi.importActual("react"));

View file

@ -0,0 +1,188 @@
/**
* Modifier-profile library — localStorage-backed store of user-authored
* modifier profiles.
*
* Each entry:
* - `id` — local-only UUID. Used to identify the entry for
* update/delete/star operations.
* - `name` — user-provided label, shown in the library drawer.
* - `profile` — the full ModifierProfile.
* - `starred` — true when the user has pinned this profile. Starred
* entries are exempt from FIFO eviction and sort first.
* - `updatedAt` — unix ms of last write. Drives display order for
* non-starred entries (newest first) and FIFO eviction (oldest
* non-starred entry is removed when capacity is hit).
*
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
* entry is evicted. If every entry is starred, we refuse the save
* and the caller surfaces a "library full — unstar something" error.
*
* Storage key is versioned (`houserules:modifier-profiles:v1`). A schema
* bump would ship a new key + migration; v1 entries are kept on best-
* effort and re-hydrated read-only if they can't be migrated.
*/
import { parseModifierProfile } from "./schema.js";
import type { ModifierProfile } from "./types.js";
const STORAGE_KEY = "houserules:modifier-profiles:v1";
const MAX_ENTRIES = 20;
export interface SavedModifierProfile {
readonly id: string;
readonly name: string;
readonly profile: ModifierProfile;
readonly starred: boolean;
readonly updatedAt: number;
}
/**
* Read every saved modifier profile from storage. Returns an empty array on
* empty/missing/corrupt storage — silently discarding unparseable
* data is preferable to blocking the UI.
*/
export function loadLibrary(): SavedModifierProfile[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
// Shallow shape validation — anything that fails is dropped.
return parsed.filter(isSavedModifierProfile);
} catch {
return [];
}
}
/** Write the full library array back to storage. */
function writeLibrary(entries: SavedModifierProfile[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new modifier profile or update an existing one (matched by `id`).
*
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
* when the library is full of starred entries — caller surfaces a
* message telling the user to unstar something.
*/
export function saveToLibrary(
entry: SavedModifierProfile,
): { ok: true } | { ok: false; reason: string } {
const library = loadLibrary();
const existingIdx = library.findIndex((e) => e.id === entry.id);
if (existingIdx >= 0) {
// Update in place — no capacity check needed.
library[existingIdx] = entry;
writeLibrary(library);
return { ok: true };
}
// New entry — enforce capacity.
if (library.length >= MAX_ENTRIES) {
// Find the oldest non-starred entry and evict it.
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 profiles). Unstar one to make room, or delete an entry.",
};
}
const oldestNonStarred = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true };
}
library.push(entry);
writeLibrary(library);
return { ok: true };
}
/** Remove a modifier profile by id. No-op if the id is unknown. */
export function deleteFromLibrary(id: string): void {
const library = loadLibrary();
writeLibrary(library.filter((e) => e.id !== id));
}
/** Toggle the starred flag on a modifier profile. */
export function setStarred(id: string, starred: boolean): void {
const library = loadLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated: SavedModifierProfile = {
...library[idx]!,
starred,
updatedAt: Date.now(),
};
library[idx] = updated;
writeLibrary(library);
}
/**
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
* appended to the name, and starred=false regardless of the
* original's state. Returns the new entry's id so the caller can
* select it.
*/
export function duplicateEntry(id: string): string | undefined {
const library = loadLibrary();
const entry = library.find((e) => e.id === id);
if (entry === undefined) return undefined;
const newId = makeId();
const copy: SavedModifierProfile = {
id: newId,
name: `${entry.name} (copy)`,
profile: entry.profile,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(copy);
if (!result.ok) return undefined;
return newId;
}
/** Generate a local-only id for a library entry. */
export function makeId(): string {
// crypto.randomUUID is available in every browser we target (and
// in Node 19+). Fall back to a Math.random-based id only on
// ancient runtimes.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `layout-${Math.random().toString(36).slice(2, 12)}`;
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedModifierProfile(value: unknown): value is SavedModifierProfile {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
if (
typeof v["id"] !== "string" ||
typeof v["name"] !== "string" ||
typeof v["starred"] !== "boolean" ||
typeof v["updatedAt"] !== "number"
) {
return false;
}
// Validate the nested profile using the Zod schema.
try {
parseModifierProfile(v["profile"]);
return true;
} catch {
return false;
}
}
// Exported for tests only. Prefer the high-level helpers above.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE } from "./absorb-damage-with-attribute.js";
function makeContext(session: Session, pieceId: EntityId) {
return {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "test-descriptor",
type: "data" as const,
version: 1 as const,
},
};
}
describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => {
it("registers in PRIMITIVE_REGISTRY", () => {
expect(PRIMITIVE_REGISTRY.has("absorb-damage-with-attribute")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("absorb-damage-with-attribute")).toBe(
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE,
);
});
it("writes both AbsorbDamageAttr and AbsorbDamageRate facts", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
attr: "ShieldCharges",
rate: 2,
});
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges");
expect(session.get(pieceId, "AbsorbDamageRate")).toBe(2);
});
it("rejects non-positive rate values", () => {
expect(() => {
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
attr: "ShieldCharges",
rate: 0,
});
}).toThrow();
expect(() => {
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
attr: "ShieldCharges",
rate: -1,
});
}).toThrow();
});
it("is idempotent when applying the same params twice", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
attr: "ShieldCharges",
rate: 3,
});
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges");
expect(session.get(pieceId, "AbsorbDamageRate")).toBe(3);
});
});

View file

@ -0,0 +1,26 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
attr: z.string(),
rate: z.number().int().positive(),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "absorb-damage-with-attribute",
label: "Absorb Damage with Attribute",
description:
"Seed absorb-damage facts so damage can consume an attribute before HP.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr);
ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE };

View file

@ -0,0 +1,87 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ADD_AURA_PRIMITIVE } from "./add-aura.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./add-aura.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-add-aura",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("add-aura primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'add-aura'", () => {
expect(PRIMITIVE_REGISTRY.has("add-aura")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("add-aura")).toBe(ADD_AURA_PRIMITIVE);
});
});
describe("add-aura primitive — apply()", () => {
it("seeds one aura spec entry", () => {
const { ctx, session } = makeContext();
ADD_AURA_PRIMITIVE.apply(ctx, {
radius: 2,
targetAttr: "HpBonus",
delta: 1,
});
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
{ radius: 2, targetAttr: "HpBonus", delta: 1 },
]);
});
it("composes multiple aura specs into an array", () => {
const { ctx, session } = makeContext();
ADD_AURA_PRIMITIVE.apply(ctx, {
radius: 1,
targetAttr: "RangeBonus",
delta: 2,
});
ADD_AURA_PRIMITIVE.apply(ctx, {
radius: 3,
targetAttr: "DamageResistance",
delta: -0.25,
});
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
{ radius: 3, targetAttr: "DamageResistance", delta: -0.25 },
]);
});
});
describe("add-aura primitive — paramsSchema", () => {
it("rejects radii outside the [1, 7] range", () => {
expect(() =>
ADD_AURA_PRIMITIVE.paramsSchema.parse({
radius: 0,
targetAttr: "HpBonus",
delta: 1,
}),
).toThrow();
expect(() =>
ADD_AURA_PRIMITIVE.paramsSchema.parse({
radius: 8,
targetAttr: "HpBonus",
delta: 1,
}),
).toThrow();
});
});

View file

@ -0,0 +1,37 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
radius: z.number().int().min(1).max(7),
targetAttr: z.string(),
delta: z.number(),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "add-aura",
label: "Add Aura",
description:
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "AuraSpec") as
| ChessAttrMap["AuraSpec"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "AuraSpec", [
...existing,
{
radius: params.radius,
targetAttr: params.targetAttr,
delta: params.delta,
},
]);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ADD_AURA_PRIMITIVE };

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { Session } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./add-direction.js";
import { ADD_DIRECTION_PRIMITIVE } from "./add-direction.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-add-direction",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("add-direction primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'add-direction'", () => {
expect(PRIMITIVE_REGISTRY.has("add-direction")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("add-direction")).toBe(ADD_DIRECTION_PRIMITIVE);
});
});
describe("add-direction primitive — apply()", () => {
it("adds one direction to empty state", () => {
const { ctx, session } = makeContext();
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual(["forward"]);
});
it("concatenates with existing directions", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "DirectionAdditions", ["right"]);
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["left"] });
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
"right",
"left",
]);
});
it("deduplicates overlapping directions by name", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "DirectionAdditions", ["forward"]);
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
directions: ["forward", "diagonal-fr"],
});
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
"forward",
"diagonal-fr",
]);
});
it("composes additively with the T1 direction-additions descriptor's writes", () => {
// The T1 modifier writes `string[]` of named directions. This primitive
// must read+merge that exact shape so the engine's existing
// `generateDirectionMoves` walker handles both contributions.
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "DirectionAdditions", ["forward", "left"]);
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
directions: ["right", "diagonal-fl"],
});
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
"forward",
"left",
"right",
"diagonal-fl",
]);
});
it("rejects unknown direction names at the schema layer", () => {
const parsed = ADD_DIRECTION_PRIMITIVE.paramsSchema.safeParse({
directions: ["northwest"],
});
expect(parsed.success).toBe(false);
});
it("throws when the existing DirectionAdditions fact has a wrong shape", () => {
// If a stale or upstream-buggy fact seeds DirectionAdditions with a
// non-string entry, the primitive must fail loudly rather than silently
// mixing two incompatible shapes into the same array. We bypass the
// chess-typed insert by going through the underlying rete session API
// (which accepts `unknown` values).
const { ctx, session } = makeContext();
const factId = ctx.pieceId;
// Insert via the untyped rete path so we can plant a wrong-shape value
// without triggering the chess attr-map type check.
session.insert(factId, "DirectionAdditions", [{ dx: 1, dy: 0 }]);
expect(() => {
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
}).toThrow();
});
});

View file

@ -0,0 +1,66 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
/**
* `DirectionAdditions` is the existing T1 modifier attribute (see
* `descriptors/direction-additions.ts`). Its value is a `readonly string[]`
* of named, color-relative directions interpreted by `generateDirectionMoves`.
* This primitive composes additively with that descriptor — appending names
* into the same array — so the engine's existing direction-walker handles
* both T1-built-in and T3-DSL contributions identically.
*/
const directionNameSchema = z.enum([
"forward",
"backward",
"left",
"right",
"diagonal-fl",
"diagonal-fr",
"diagonal-bl",
"diagonal-br",
]);
const schema = z.object({
directions: z.array(directionNameSchema).min(1),
});
type DirectionName = z.infer<typeof directionNameSchema>;
type Params = z.infer<typeof schema>;
function dedupe(directions: readonly DirectionName[]): DirectionName[] {
const seen = new Set<string>();
const out: DirectionName[] = [];
for (const d of directions) {
if (seen.has(d)) continue;
seen.add(d);
out.push(d);
}
return out;
}
const descriptor: EffectPrimitive<Params> = {
kind: "add-direction",
label: "Add Direction",
description: "Appends movement directions into DirectionAdditions with dedupe.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
const existingDirections = existing ?? [];
if (!Array.isArray(existingDirections)) {
throw new Error("add-direction expected DirectionAdditions to be an array");
}
const parsed = z.array(directionNameSchema).safeParse(existingDirections);
if (!parsed.success) {
throw new Error(
"add-direction expected DirectionAdditions to be a string[] of named directions",
);
}
const merged = [...parsed.data, ...params.directions];
ctx.session.insert(ctx.pieceId, "DirectionAdditions", dedupe(merged));
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ADD_DIRECTION_PRIMITIVE };

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { Session } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./add-to-attribute.js";
import { ADD_TO_ATTRIBUTE_PRIMITIVE } from "./add-to-attribute.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-add-to-attribute",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("add-to-attribute primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'add-to-attribute'", () => {
expect(PRIMITIVE_REGISTRY.has("add-to-attribute")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("add-to-attribute")).toBe(
ADD_TO_ATTRIBUTE_PRIMITIVE,
);
});
});
describe("add-to-attribute primitive — apply()", () => {
it("adds delta to an existing number", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "HpBonus", 2);
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "HpBonus", delta: 3 });
expect(session.get(ctx.pieceId, "HpBonus")).toBe(5);
});
it("treats an absent fact as 0", () => {
const { ctx, session } = makeContext();
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", delta: 4 });
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
});
it("throws when existing value is non-numeric", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "PieceType", "pawn");
expect(() => {
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", delta: 1 });
}).toThrow(/expected numeric value/);
});
});

View file

@ -0,0 +1,29 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
attr: z.string(),
delta: z.number(),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "add-to-attribute",
label: "Add To Attribute",
description: "Adds delta to the current attribute value, treating missing as 0.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.get(ctx.pieceId, params.attr);
const baseValue = existing === undefined ? 0 : existing;
if (typeof baseValue !== "number" || Number.isNaN(baseValue)) {
throw new Error(
`add-to-attribute expected numeric value for attr "${params.attr}" but got ${typeof baseValue}`,
);
}
ctx.session.insert(ctx.pieceId, params.attr, baseValue + params.delta);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ADD_TO_ATTRIBUTE_PRIMITIVE };

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { BLOCK_MOVE_TYPE_PRIMITIVE } from "./block-move-type.js";
function makeContext(session: Session, pieceId: EntityId) {
return {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "test-descriptor",
type: "data" as const,
version: 1 as const,
},
};
}
describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => {
it("registers in PRIMITIVE_REGISTRY", () => {
expect(PRIMITIVE_REGISTRY.has("block-move-type")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("block-move-type")).toBe(BLOCK_MOVE_TYPE_PRIMITIVE);
});
it("blocks one move type", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "rook");
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
makeContext(session, pieceId),
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" }),
);
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]);
});
it("composes unique types across multiple applies", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "bishop");
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
makeContext(session, pieceId),
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "step" }),
);
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
makeContext(session, pieceId),
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "slide" }),
);
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["step", "slide"]);
});
it("is idempotent when the same move type is applied repeatedly", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "queen");
const params = BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" });
BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params);
BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params);
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]);
});
});

View file

@ -0,0 +1,32 @@
import { z } from "zod";
import type { MoveType } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const moveTypeSchema = z.enum(["capture", "step", "slide"]);
const schema = z.object({
moveType: moveTypeSchema,
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "block-move-type",
label: "Block Move Type",
description: "Seed blocked move types for deferred move-filter integration.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes")
? ctx.session.get(ctx.pieceId, "BlockedMoveTypes")
: [];
const existing: MoveType[] = Array.isArray(existingRaw)
? existingRaw.filter((v): v is MoveType => moveTypeSchema.safeParse(v).success)
: [];
const next = existing.includes(params.moveType) ? existing : [...existing, params.moveType];
ctx.session.insert(ctx.pieceId, "BlockedMoveTypes", next);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as BLOCK_MOVE_TYPE_PRIMITIVE };

View file

@ -0,0 +1,123 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { CONDITIONAL_PRIMITIVE } from "./conditional.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./conditional.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-conditional",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("conditional primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'conditional'", () => {
expect(PRIMITIVE_REGISTRY.has("conditional")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("conditional")).toBe(CONDITIONAL_PRIMITIVE);
});
});
describe("conditional primitive — apply()", () => {
it("seeds one conditional hook", () => {
const { ctx, session } = makeContext();
const thenBranch: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
];
const elseBranch: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "attr-lt", attr: "Hp", value: 3 },
then: thenBranch,
else: elseBranch,
});
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
{
condition: { type: "attr-lt", attr: "Hp", value: 3 },
then: thenBranch,
else: elseBranch,
},
]);
});
it("appends to existing ConditionalHooks", () => {
const { ctx, session } = makeContext();
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "always" },
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
});
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "never" },
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
});
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
{
condition: { type: "always" },
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
else: undefined,
},
{
condition: { type: "never" },
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
},
]);
});
});
describe("conditional primitive — childPrimitives()", () => {
it("returns then + else primitives combined", () => {
const thenBranch: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "forward" } },
];
const elseBranch: EffectPrimitiveNode[] = [
{ kind: "block-move-type", params: { moveType: "capture" } },
];
const children = CONDITIONAL_PRIMITIVE.childPrimitives?.({
condition: { type: "always" },
then: thenBranch,
else: elseBranch,
});
expect(children).toEqual([...thenBranch, ...elseBranch]);
});
it("accepts conditional params with no else branch", () => {
const parsed = CONDITIONAL_PRIMITIVE.paramsSchema.parse({
condition: { type: "attr-gt", attr: "Hp", value: 2 },
then: [{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }],
});
expect(parsed.else).toBeUndefined();
});
});
describe("conditional primitive — paramsSchema", () => {
it("rejects unknown condition.type values", () => {
expect(() =>
CONDITIONAL_PRIMITIVE.paramsSchema.parse({
condition: { type: "attr-between", attr: "Hp", min: 1, max: 3 },
then: [],
}),
).toThrow();
});
});

View file

@ -0,0 +1,86 @@
import { z } from "zod";
import type { ChessAttrMap, ConditionSpec } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const ConditionSchema: z.ZodType<ConditionSpec> = z.discriminatedUnion("type", [
z.object({
type: z.literal("attr-lt"),
attr: z.string(),
value: z.number(),
}),
z.object({
type: z.literal("attr-gt"),
attr: z.string(),
value: z.number(),
}),
z.object({
type: z.literal("attr-eq"),
attr: z.string(),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
}),
z.object({
type: z.literal("always"),
}),
z.object({
type: z.literal("never"),
}),
]);
const schema = z.object({
condition: ConditionSchema,
then: z.array(NodeSchema),
else: z.array(NodeSchema).optional(),
});
type Params = z.infer<typeof schema>;
type ConditionalHook = ChessAttrMap["ConditionalHooks"][number];
const descriptor: EffectPrimitive<Params> = {
kind: "conditional",
label: "Conditional",
description:
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "ConditionalHooks") as
| ChessAttrMap["ConditionalHooks"]
| undefined) ?? [];
let next: ConditionalHook;
if (params.else !== undefined) {
next = {
condition: params.condition,
then: [...params.then],
else: [...params.else],
};
} else {
next = {
condition: params.condition,
then: [...params.then],
};
}
ctx.session.insert(ctx.pieceId, "ConditionalHooks", [...existing, next]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.then, ...(params.else ?? [])];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as CONDITIONAL_PRIMITIVE };

View file

@ -0,0 +1,30 @@
export { PrimitiveRegistryClass, PRIMITIVE_REGISTRY } from "./registry.js";
export type {
PrimitiveKind,
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
CustomModifierDescriptorRef,
Session,
} from "./types.js";
// State primitives (Wave 2 batch A — T4–T8):
import "./seed-attribute.js";
import "./add-to-attribute.js";
import "./multiply-attribute.js";
import "./add-direction.js";
import "./set-capture-flag.js";
// Mechanic primitives (Wave 2 batch B — T9–T13):
import "./absorb-damage-with-attribute.js";
import "./reflect-damage.js";
import "./modify-movement-range.js";
import "./block-move-type.js";
import "./override-promotion.js";
// Advanced primitives (Wave 2 batch C — T14–T18):
import "./add-aura.js";
import "./on-turn-start.js";
import "./on-capture.js";
import "./on-damaged.js";
import "./conditional.js";

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { MODIFY_MOVEMENT_RANGE_PRIMITIVE } from "./modify-movement-range.js";
function makeContext(session: Session, pieceId: EntityId) {
return {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "test-descriptor",
type: "data" as const,
version: 1 as const,
},
};
}
describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => {
it("registers in PRIMITIVE_REGISTRY", () => {
expect(PRIMITIVE_REGISTRY.has("modify-movement-range")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("modify-movement-range")).toBe(
MODIFY_MOVEMENT_RANGE_PRIMITIVE,
);
});
it("treats absent RangeBonus as 0 before applying delta", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "bishop");
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
makeContext(session, pieceId),
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }),
);
expect(session.get(pieceId, "RangeBonus")).toBe(2);
});
it("adds delta to existing RangeBonus", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "rook");
session.insert(pieceId, "RangeBonus", 3);
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
makeContext(session, pieceId),
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: -1 }),
);
expect(session.get(pieceId, "RangeBonus")).toBe(2);
});
it("accumulates when applied multiple times", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "queen");
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
makeContext(session, pieceId),
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 1 }),
);
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
makeContext(session, pieceId),
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }),
);
expect(session.get(pieceId, "RangeBonus")).toBe(3);
});
});

View file

@ -0,0 +1,27 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
delta: z.number().int().min(-7).max(7),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "modify-movement-range",
label: "Modify Movement Range",
description: "Additively contributes to the existing RangeBonus attribute.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.contains(ctx.pieceId, "RangeBonus")
? ctx.session.get(ctx.pieceId, "RangeBonus")
: 0;
const baseline = typeof existing === "number" ? existing : 0;
ctx.session.insert(ctx.pieceId, "RangeBonus", baseline + params.delta);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as MODIFY_MOVEMENT_RANGE_PRIMITIVE };

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { Session } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./multiply-attribute.js";
import { MULTIPLY_ATTRIBUTE_PRIMITIVE } from "./multiply-attribute.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-multiply-attribute",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("multiply-attribute primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'multiply-attribute'", () => {
expect(PRIMITIVE_REGISTRY.has("multiply-attribute")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("multiply-attribute")).toBe(
MULTIPLY_ATTRIBUTE_PRIMITIVE,
);
});
});
describe("multiply-attribute primitive — apply()", () => {
it("multiplies an existing number", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "Hp", 6);
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", factor: 1.5 });
expect(session.get(ctx.pieceId, "Hp")).toBe(9);
});
it("is a no-op when the attribute is absent", () => {
const { ctx, session } = makeContext();
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", factor: 2 });
expect(session.get(ctx.pieceId, "RangeBonus")).toBeUndefined();
});
it("throws when existing value is non-numeric", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "PieceType", "rook");
expect(() => {
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", factor: 2 });
}).toThrow(/expected numeric value/);
});
});

View file

@ -0,0 +1,31 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
attr: z.string(),
factor: z.number(),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "multiply-attribute",
label: "Multiply Attribute",
description: "Multiplies an existing attribute value by the provided factor.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.get(ctx.pieceId, params.attr);
if (existing === undefined) {
return;
}
if (typeof existing !== "number" || Number.isNaN(existing)) {
throw new Error(
`multiply-attribute expected numeric value for attr "${params.attr}" but got ${typeof existing}`,
);
}
ctx.session.insert(ctx.pieceId, params.attr, existing * params.factor);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as MULTIPLY_ATTRIBUTE_PRIMITIVE };

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_CAPTURE_PRIMITIVE } from "./on-capture.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-capture.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-capture",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-capture primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-capture'", () => {
expect(PRIMITIVE_REGISTRY.has("on-capture")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-capture")).toBe(ON_CAPTURE_PRIMITIVE);
});
});
describe("on-capture primitive — apply()", () => {
it("seeds one capture hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
];
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnCaptureHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 2 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } },
];
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: first });
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([first, second]);
});
});
describe("on-capture primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "left" } },
{ kind: "set-capture-flag", params: { flag: 2 } },
];
const children = ON_CAPTURE_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-capture",
label: "On Capture",
description: "Seeds OnCaptureHooks entries consumed during capture events.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
| ChessAttrMap["OnCaptureHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnCaptureHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_CAPTURE_PRIMITIVE };

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_DAMAGED_PRIMITIVE } from "./on-damaged.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-damaged.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-damaged",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-damaged primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-damaged'", () => {
expect(PRIMITIVE_REGISTRY.has("on-damaged")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-damaged")).toBe(ON_DAMAGED_PRIMITIVE);
});
});
describe("on-damaged primitive — apply()", () => {
it("seeds one damaged hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnDamagedHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 1 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "reflect-damage", params: { percent: 0.5 } },
];
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: first });
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([first, second]);
});
});
describe("on-damaged primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "absorb-damage-with-attribute", params: { attr: "Hp", rate: 1 } },
{ kind: "set-capture-flag", params: { flag: 4 } },
];
const children = ON_DAMAGED_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-damaged",
label: "On Damaged",
description: "Seeds OnDamagedHooks entries consumed during damage events.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
| ChessAttrMap["OnDamagedHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnDamagedHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_DAMAGED_PRIMITIVE };

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_TURN_START_PRIMITIVE } from "./on-turn-start.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-turn-start.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-turn-start",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-turn-start primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-turn-start'", () => {
expect(PRIMITIVE_REGISTRY.has("on-turn-start")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-turn-start")).toBe(ON_TURN_START_PRIMITIVE);
});
});
describe("on-turn-start primitive — apply()", () => {
it("seeds one turn-start hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
];
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnTurnStartHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 3 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "set-capture-flag", params: { flag: 1 } },
];
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: first });
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([first, second]);
});
});
describe("on-turn-start primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "backward" } },
{ kind: "block-move-type", params: { moveType: "slide" } },
];
const children = ON_TURN_START_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,48 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-turn-start",
label: "On Turn Start",
description:
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
| ChessAttrMap["OnTurnStartHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnTurnStartHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_TURN_START_PRIMITIVE };

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { OVERRIDE_PROMOTION_PRIMITIVE } from "./override-promotion.js";
function makeContext(session: Session, pieceId: EntityId) {
return {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "test-descriptor",
type: "data" as const,
version: 1 as const,
},
};
}
describe("OVERRIDE_PROMOTION_PRIMITIVE", () => {
it("registers in PRIMITIVE_REGISTRY", () => {
expect(PRIMITIVE_REGISTRY.has("override-promotion")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("override-promotion")).toBe(OVERRIDE_PROMOTION_PRIMITIVE);
});
it("writes PromotionOverride with the requested target", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
OVERRIDE_PROMOTION_PRIMITIVE.apply(
makeContext(session, pieceId),
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" }),
);
expect(session.get(pieceId, "PromotionOverride")).toBe("queen");
});
it("is last-wins when re-applied", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
OVERRIDE_PROMOTION_PRIMITIVE.apply(
makeContext(session, pieceId),
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "knight" }),
);
OVERRIDE_PROMOTION_PRIMITIVE.apply(
makeContext(session, pieceId),
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "bishop" }),
);
expect(session.get(pieceId, "PromotionOverride")).toBe("bishop");
});
it("accepts canonical PieceType values from schema", () => {
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "pawn" })).not.toThrow();
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "king" })).not.toThrow();
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" })).not.toThrow();
});
});

View file

@ -0,0 +1,25 @@
import { z } from "zod";
import type { PieceType } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
target: z.enum(["pawn", "knight", "bishop", "rook", "queen", "king"]),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "override-promotion",
label: "Override Promotion",
description: "Write PromotionOverride directly to enforce a promotion target.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const target: PieceType = params.target;
ctx.session.insert(ctx.pieceId, "PromotionOverride", target);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as OVERRIDE_PROMOTION_PRIMITIVE };

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { Session, type EntityId } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { REFLECT_DAMAGE_PRIMITIVE } from "./reflect-damage.js";
function makeContext(session: Session, pieceId: EntityId) {
return {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "test-descriptor",
type: "data" as const,
version: 1 as const,
},
};
}
describe("REFLECT_DAMAGE_PRIMITIVE", () => {
it("registers in PRIMITIVE_REGISTRY", () => {
expect(PRIMITIVE_REGISTRY.has("reflect-damage")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("reflect-damage")).toBe(REFLECT_DAMAGE_PRIMITIVE);
});
it("writes ReflectDamagePercent fact", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
const params = REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 35 });
REFLECT_DAMAGE_PRIMITIVE.apply(makeContext(session, pieceId), params);
expect(session.get(pieceId, "ReflectDamagePercent")).toBe(35);
});
it("rejects percentage outside 0..100", () => {
expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: -1 })).toThrow();
expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 101 })).toThrow();
});
it("overwrites existing value on re-apply", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
REFLECT_DAMAGE_PRIMITIVE.apply(
makeContext(session, pieceId),
REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 20 }),
);
REFLECT_DAMAGE_PRIMITIVE.apply(
makeContext(session, pieceId),
REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 60 }),
);
expect(session.get(pieceId, "ReflectDamagePercent")).toBe(60);
});
});

View file

@ -0,0 +1,23 @@
import { z } from "zod";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
percentage: z.number().int().min(0).max(100),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "reflect-damage",
label: "Reflect Damage",
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as REFLECT_DAMAGE_PRIMITIVE };

View file

@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
PrimitiveRegistryClass,
type EffectPrimitive,
type PrimitiveKind,
} from "./index.js";
function primitive<P>(args: {
kind: PrimitiveKind;
label?: string;
paramsSchema: EffectPrimitive<P>["paramsSchema"];
}): EffectPrimitive<P> {
return {
kind: args.kind,
label: args.label ?? args.kind,
description: `${args.kind} primitive`,
paramsSchema: args.paramsSchema,
apply: () => {
// no-op in registry tests
},
};
}
describe("PrimitiveRegistryClass", () => {
it("register() + get() round-trip returns the same descriptor", () => {
const registry = new PrimitiveRegistryClass();
const descriptor = primitive({
kind: "seed-attribute",
paramsSchema: z.object({ key: z.string(), value: z.number() }),
});
registry.register(descriptor);
expect(registry.get("seed-attribute")).toBe(descriptor);
});
it("register() throws on duplicate primitive kind", () => {
const registry = new PrimitiveRegistryClass();
registry.register(
primitive({
kind: "add-to-attribute",
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
}),
);
expect(() => {
registry.register(
primitive({
kind: "add-to-attribute",
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
}),
);
}).toThrow(/add-to-attribute/);
});
it("list() preserves registration order", () => {
const registry = new PrimitiveRegistryClass();
const first = primitive({
kind: "set-capture-flag",
paramsSchema: z.object({ flag: z.string() }),
});
const second = primitive({
kind: "reflect-damage",
paramsSchema: z.object({ ratio: z.number() }),
});
const third = primitive({
kind: "override-promotion",
paramsSchema: z.object({ to: z.string() }),
});
registry.register(first);
registry.register(second);
registry.register(third);
expect(registry.list()).toEqual([first, second, third]);
});
it("has() is false before registration and true after", () => {
const registry = new PrimitiveRegistryClass();
expect(registry.has("add-direction")).toBe(false);
registry.register(
primitive({
kind: "add-direction",
paramsSchema: z.object({ direction: z.string() }),
}),
);
expect(registry.has("add-direction")).toBe(true);
});
it("get() returns undefined for an unregistered known kind", () => {
const registry = new PrimitiveRegistryClass();
expect(registry.get("conditional")).toBeUndefined();
});
it("generic params type is preserved at register call site", () => {
const registry = new PrimitiveRegistryClass();
const typed = primitive({
kind: "modify-movement-range",
paramsSchema: z.object({
mode: z.literal("set"),
value: z.number(),
}),
});
function registerAndReturn<P>(
localRegistry: PrimitiveRegistryClass,
descriptor: EffectPrimitive<P>,
): EffectPrimitive<P> {
localRegistry.register(descriptor);
return descriptor;
}
const registered = registerAndReturn(registry, typed);
const parsed = registered.paramsSchema.parse({ mode: "set", value: 3 });
expect(parsed.value).toBe(3);
expect(registry.get("modify-movement-range")).toBe(typed);
});
});

View file

@ -0,0 +1,30 @@
import type { EffectPrimitive, PrimitiveKind } from "./types.js";
class PrimitiveRegistryClass {
readonly #byId = new Map<PrimitiveKind, EffectPrimitive>();
register<P>(primitive: EffectPrimitive<P>): void {
if (this.#byId.has(primitive.kind)) {
throw new Error(
`PrimitiveRegistry: duplicate primitive kind "${primitive.kind}". ` +
`Each primitive descriptor must have a unique kind.`,
);
}
this.#byId.set(primitive.kind, primitive as EffectPrimitive);
}
get(kind: PrimitiveKind): EffectPrimitive | undefined {
return this.#byId.get(kind);
}
list(): readonly EffectPrimitive[] {
return [...this.#byId.values()];
}
has(kind: PrimitiveKind): boolean {
return this.#byId.has(kind);
}
}
export { PrimitiveRegistryClass };
export const PRIMITIVE_REGISTRY = new PrimitiveRegistryClass();

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { Session } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./seed-attribute.js";
import { SEED_ATTRIBUTE_PRIMITIVE } from "./seed-attribute.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-seed-attribute",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("seed-attribute primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'seed-attribute'", () => {
expect(PRIMITIVE_REGISTRY.has("seed-attribute")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("seed-attribute")).toBe(SEED_ATTRIBUTE_PRIMITIVE);
});
});
describe("seed-attribute primitive — apply()", () => {
it("writes a string attribute", () => {
const { ctx, session } = makeContext();
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
attr: "PieceType",
value: "queen",
});
expect(session.get(ctx.pieceId, "PieceType")).toBe("queen");
});
it("writes a number attribute", () => {
const { ctx, session } = makeContext();
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
attr: "Hp",
value: 12,
});
expect(session.get(ctx.pieceId, "Hp")).toBe(12);
});
it("overwrites an existing fact", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "RangeBonus", 1);
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
attr: "RangeBonus",
value: 4,
});
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
});
});

View file

@ -0,0 +1,51 @@
import { z } from "zod";
import type { ChessAttrKey } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const CHESS_ATTR_KEYS: ReadonlySet<ChessAttrKey> = new Set([
"PieceType",
"Color",
"Position",
"HasMoved",
"Turn",
"HalfmoveClock",
"FullmoveNumber",
"EnPassantTarget",
"GameStatus",
"Winner",
"Hp",
"PoisonedSquare",
"HpBonus",
"RangeBonus",
"DirectionAdditions",
"CaptureFlags",
"PromotionOverride",
"DamageResistance",
]);
function isChessAttrKey(attr: string): attr is ChessAttrKey {
return CHESS_ATTR_KEYS.has(attr as ChessAttrKey);
}
const schema = z.object({
attr: z.string(),
value: z.unknown(),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "seed-attribute",
label: "Seed Attribute",
description: "Seeds a fact on the target piece, overwriting existing value.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
if (!isChessAttrKey(params.attr)) {
return;
}
ctx.session.insert(ctx.pieceId, params.attr, params.value);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as SEED_ATTRIBUTE_PRIMITIVE };

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { Session } from "@paratype/rete";
import { ChessEngine } from "../../engine.js";
import { CaptureFlag } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { PrimitiveApplyContext } from "./types.js";
import "./set-capture-flag.js";
import { SET_CAPTURE_FLAG_PRIMITIVE } from "./set-capture-flag.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-set-capture-flag",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("set-capture-flag primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'set-capture-flag'", () => {
expect(PRIMITIVE_REGISTRY.has("set-capture-flag")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("set-capture-flag")).toBe(
SET_CAPTURE_FLAG_PRIMITIVE,
);
});
});
describe("set-capture-flag primitive — apply()", () => {
it("sets a flag from a clean state", () => {
const { ctx, session } = makeContext();
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
flag: CaptureFlag.CANNOT_BE_CAPTURED,
});
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
CaptureFlag.CANNOT_BE_CAPTURED,
);
});
it("preserves existing flags when adding a new one", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "CaptureFlags", CaptureFlag.CAN_CAPTURE_OWN);
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
flag: CaptureFlag.EN_PASSANT,
});
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.EN_PASSANT,
);
});
it("is idempotent when setting the same flag twice", () => {
const { ctx, session } = makeContext();
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
flag: CaptureFlag.CAN_CAPTURE_OWN,
});
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
flag: CaptureFlag.CAN_CAPTURE_OWN,
});
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
CaptureFlag.CAN_CAPTURE_OWN,
);
});
});

View file

@ -0,0 +1,36 @@
import { z } from "zod";
import {
CaptureFlag,
type CaptureFlag as CaptureFlagValue,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
flag: z.union([
z.literal(CaptureFlag.CAN_CAPTURE_OWN),
z.literal(CaptureFlag.CANNOT_BE_CAPTURED),
z.literal(CaptureFlag.EN_PASSANT),
]),
});
type Params = {
flag: CaptureFlagValue;
};
const descriptor: EffectPrimitive<Params> = {
kind: "set-capture-flag",
label: "Set Capture Flag",
description: "Bitwise-ORs one capture flag into CaptureFlags.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
const baseFlags = existing === undefined ? 0 : existing;
if (typeof baseFlags !== "number" || Number.isNaN(baseFlags)) {
throw new Error("set-capture-flag expected CaptureFlags to be numeric");
}
ctx.session.insert(ctx.pieceId, "CaptureFlags", baseFlags | params.flag);
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as SET_CAPTURE_FLAG_PRIMITIVE };

View file

@ -0,0 +1,71 @@
import type { EntityId, Session } from "@paratype/rete";
import type { ZodType } from "zod";
import type { ChessEngine } from "../../engine.js";
/**
* T3 primitive ids (ADR-2).
*/
export type PrimitiveKind =
| "seed-attribute"
| "add-to-attribute"
| "multiply-attribute"
| "add-direction"
| "set-capture-flag"
| "absorb-damage-with-attribute"
| "reflect-damage"
| "block-move-type"
| "modify-movement-range"
| "override-promotion"
| "add-aura"
| "on-turn-start"
| "on-capture"
| "on-damaged"
| "conditional";
/**
* Forward-declared shape of the back-reference passed to primitive
* `apply()` calls. The full descriptor lives in `../custom/types.js`
* — declaring only the trunk here keeps the import graph one-way
* (custom imports primitives, never the reverse).
*
* Adding a structurally-compatible field here is fine, but the
* canonical shape is owned by `../custom/types.ts`.
*/
export interface CustomModifierDescriptorRef {
readonly id: string;
readonly type: "data";
readonly version: 1;
}
/** Runtime primitive node embedded in custom descriptor trees. */
export interface EffectPrimitiveNode {
readonly kind: PrimitiveKind;
readonly params: unknown;
}
/** Context passed to primitive apply functions. */
export interface PrimitiveApplyContext {
readonly engine: ChessEngine;
readonly session: Session;
readonly pieceId: EntityId;
readonly depth: number;
readonly descriptor: CustomModifierDescriptorRef;
}
/**
* Primitive descriptor contract implemented by each T3 primitive.
*
* `childPrimitives` is provided by primitives that own nested primitive lists
* (e.g. trigger/conditional primitives) so validators can walk the tree.
*/
export interface EffectPrimitive<Params = unknown> {
readonly kind: PrimitiveKind;
readonly label: string;
readonly description: string;
readonly paramsSchema: ZodType<Params>;
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
readonly maxDepth?: number;
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
}
export type { Session };

View file

@ -0,0 +1,294 @@
/**
* Tests for reconcileProfileSwap.
*
* Setup pattern: build a session via `applyLayout(CLASSIC_LAYOUT)`,
* optionally seed `Hp` on pieces (simulating `piece-hp` active), run
* reconcile, assert the post-state.
*
* We don't actually activate `piece-hp` as a preset here — these
* tests are scoped to reconcile's own contract, so we write `Hp`
* directly. Higher-level integration with `piece-hp` is covered by
* engine-surface tests elsewhere.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
import { applyProfileToSession } from "./apply.js";
import { reconcileProfileSwap } from "./reconcile.js";
import type { ModifierProfile } from "./types.js";
import { CaptureFlag } from "../schema.js";
// Side-effect imports so MODIFIER_REGISTRY is populated for apply /
// reconcile to resolve descriptors.
import "./index.js";
function profile(parts: Partial<ModifierProfile>): ModifierProfile {
return {
id: "p",
name: "p",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
...parts,
};
}
/** Locate the white knight entity on b1 (square 1). */
function findB1Knight(session: Session): EntityId {
const facts = session.allFacts();
const pos = facts.find((f) => f.attr === "Position" && f.value === 1);
expect(pos).toBeDefined();
return pos!.id as EntityId;
}
/** Locate the white pawn on e2 (square 12). */
function findE2Pawn(session: Session): EntityId {
const facts = session.allFacts();
const pos = facts.find((f) => f.attr === "Position" && f.value === 12);
expect(pos).toBeDefined();
return pos!.id as EntityId;
}
describe("reconcileProfileSwap", () => {
let session: Session;
beforeEach(() => {
session = new Session({ autoFire: false });
applyLayout(session, CLASSIC_LAYOUT);
// Silence orphan warnings; individual tests that care spy directly.
vi.spyOn(console, "warn").mockImplementation(() => {});
});
it("is idempotent: reconcile(A, A) twice produces identical session state", () => {
const P = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 },
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
],
perInstance: [
{ kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED },
],
});
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
const after1 = session.allFacts().map((f) => ({ ...f }));
reconcileProfileSwap(session, P, P, CLASSIC_LAYOUT);
const after2 = session.allFacts().map((f) => ({ ...f }));
expect(after2).toEqual(after1);
});
it("HpBonus shrinks: clamps current Hp down to new max, never below", () => {
// Seed an HpBonus=+3 profile, then simulate `piece-hp` by writing
// Hp=max=5 on b1 knight (BASELINE_HP=2 + bonus=3).
const big = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
],
});
reconcileProfileSwap(session, null, big, CLASSIC_LAYOUT);
const knight = findB1Knight(session);
expect(session.get(knight, "HpBonus")).toBe(3);
// Simulate the knight took 0 damage — full HP = 5.
session.insert(knight, "Hp", 5);
// Swap to a profile with HpBonus=0. new max = 2, Hp clamps to 2.
const small = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 },
],
});
reconcileProfileSwap(session, big, small, CLASSIC_LAYOUT);
// HpBonus fact may be 0 (applied) or absent depending on stacking —
// either way the effective max is baseline + bonus = 2.
expect(session.get(knight, "Hp")).toBe(2);
});
it("HpBonus grows: Hp does NOT grow (damaged pieces stay damaged)", () => {
// Piece starts under a zero-bonus profile, with Hp=1 (simulating
// it took 1 damage).
const small = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 },
],
});
reconcileProfileSwap(session, null, small, CLASSIC_LAYOUT);
const knight = findB1Knight(session);
session.insert(knight, "Hp", 1);
// Upgrade to HpBonus=+5. New max=7 but current Hp should stay 1.
const big = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 5 },
],
});
reconcileProfileSwap(session, small, big, CLASSIC_LAYOUT);
expect(session.get(knight, "HpBonus")).toBe(5);
// Crucially: not rewritten to 7.
expect(session.get(knight, "Hp")).toBe(1);
});
it("full swap across all kinds: old facts gone, new facts seeded", () => {
// Old profile populates HpBonus, RangeBonus, DirectionAdditions.
const old = profile({
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
{ kind: "range-bonus", pieceType: "rook", color: "white", value: 1 },
{
kind: "direction-additions",
pieceType: "pawn",
color: "white",
value: ["backward"],
},
],
});
reconcileProfileSwap(session, null, old, CLASSIC_LAYOUT);
// New profile targets DIFFERENT kinds on different pieces entirely.
// After swap, none of the old facts should remain, only the new.
const next = profile({
perType: [
{
kind: "capture-flags",
pieceType: "bishop",
color: "white",
value: CaptureFlag.CAN_CAPTURE_OWN,
},
{
kind: "damage-resistance",
pieceType: "queen",
color: "white",
value: 0.5,
},
],
perInstance: [
{ kind: "promotion-override", square: "e2", value: "rook" },
],
});
reconcileProfileSwap(session, old, next, CLASSIC_LAYOUT);
// Old-profile facts ALL gone.
const pawn = findE2Pawn(session);
expect(session.contains(pawn, "HpBonus")).toBe(false);
expect(session.contains(pawn, "DirectionAdditions")).toBe(false);
// Even other pieces that got old-profile facts are clean.
const facts = session.allFacts();
const anyRangeBonus = facts.some((f) => f.attr === "RangeBonus");
expect(anyRangeBonus).toBe(false);
// New-profile facts are present where expected.
expect(session.get(pawn, "PromotionOverride")).toBe("rook");
const bishops = facts.filter(
(f) => f.attr === "PieceType" && f.value === "bishop",
);
for (const b of bishops) {
const colorFact = facts.find(
(c) => c.id === b.id && c.attr === "Color",
);
if (colorFact?.value === "white") {
expect(session.get(b.id as EntityId, "CaptureFlags")).toBe(
CaptureFlag.CAN_CAPTURE_OWN,
);
} else {
expect(session.contains(b.id as EntityId, "CaptureFlags")).toBe(false);
}
}
});
it("null → newProfile: equivalent to a fresh applyProfileToSession", () => {
const P = profile({
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 },
],
});
// Reference run: plain apply on a separate session.
const ref = new Session({ autoFire: false });
applyLayout(ref, CLASSIC_LAYOUT);
applyProfileToSession(ref, P, CLASSIC_LAYOUT);
const refFacts = ref
.allFacts()
.filter((f) => f.attr === "HpBonus")
.map((f) => `${f.id as number}:${String(f.value)}`)
.sort();
// Our run: null → P.
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
const ourFacts = session
.allFacts()
.filter((f) => f.attr === "HpBonus")
.map((f) => `${f.id as number}:${String(f.value)}`)
.sort();
expect(ourFacts).toEqual(refFacts);
});
it("oldProfile → null: every modifier fact retracted, no new facts seeded", () => {
const P = profile({
perType: [
{ kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 },
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 2 },
],
perInstance: [
{ kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED },
],
});
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
// Pre-check: modifier facts exist.
const preFacts = session.allFacts();
expect(preFacts.some((f) => f.attr === "HpBonus")).toBe(true);
expect(preFacts.some((f) => f.attr === "RangeBonus")).toBe(true);
expect(preFacts.some((f) => f.attr === "CaptureFlags")).toBe(true);
// Swap to null.
reconcileProfileSwap(session, P, null, CLASSIC_LAYOUT);
const postFacts = session.allFacts();
for (const attr of [
"HpBonus",
"RangeBonus",
"DirectionAdditions",
"CaptureFlags",
"PromotionOverride",
"DamageResistance",
] as const) {
expect(postFacts.some((f) => f.attr === attr)).toBe(false);
}
// Core piece facts still intact (we didn't touch PieceType / Color / Position / HasMoved).
expect(postFacts.some((f) => f.attr === "PieceType")).toBe(true);
expect(postFacts.some((f) => f.attr === "Position")).toBe(true);
});
it("Hp without piece-hp active: no-op on Hp (only HpBonus is touched)", () => {
// piece-hp isn't active so no Hp fact exists on any piece. Swap
// still works without throwing, and leaves the non-existent Hp
// untouched (no spurious inserts).
const P1 = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
],
});
const P2 = profile({
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 1 },
],
});
reconcileProfileSwap(session, null, P1, CLASSIC_LAYOUT);
const knight = findB1Knight(session);
expect(session.contains(knight, "Hp")).toBe(false);
reconcileProfileSwap(session, P1, P2, CLASSIC_LAYOUT);
// Hp still absent, HpBonus updated.
expect(session.contains(knight, "Hp")).toBe(false);
expect(session.get(knight, "HpBonus")).toBe(1);
});
});

View file

@ -0,0 +1,247 @@
/**
* Reconcile a ModifierProfile hot-swap at a turn boundary.
*
* The problem: `applyProfileToSession` assumes a clean slate — running
* it twice on the same session would double-add values for `additive`
* kinds (HpBonus, RangeBonus) and silently clobber others. To support
* mid-game profile changes (ADR-3 "hot swap at turn boundary"), we
* need to:
*
* 1. Retract the old profile's modifier facts so stale values don't
* leak into the new effective state.
* 2. Apply the new profile via the normal `applyProfileToSession`
* path (which handles stacking from scratch).
* 3. Special-case `Hp` (current HP): when `HpBonus` shrinks, a
* piece's effective max HP shrinks too, and any current HP above
* the new max gets clamped. HP NEVER grows on swap — a piece that
* lost 1 HP in combat shouldn't magically heal because the new
* profile bumped max.
*
* ## Reconciliation rules per kind (ADR-3)
*
* | Kind | Rule |
* |----------------------|------------------------------------------|
* | HpBonus | Recompute max; clamp current Hp down |
* | RangeBonus | Overwrite (or retract if removed) |
* | DirectionAdditions | Overwrite (or retract if removed) |
* | CaptureFlags | Overwrite (or retract if removed) |
* | PromotionOverride | Overwrite (or retract if removed) |
* | DamageResistance | Overwrite (or retract if removed) |
*
* "Overwrite" falls out naturally from the retract-then-apply flow:
* after retraction the fact is gone; the new apply pass re-inserts
* only if the new profile contributed a value.
*
* ## Idempotency
*
* `reconcileProfileSwap(s, P, P)` produces the same session state on
* the second call as the first (modulo HP — see below). We retract
* every modifier fact the profile system owns, then re-seed from
* scratch. Hp clamping is idempotent too: `min(current, max)` called
* twice returns the same value.
*
* The one subtle case: `Hp` is only managed by the `piece-hp` preset.
* If `piece-hp` is not active, there is no `Hp` fact and the clamp
* step is a no-op. The baseline max HP comes from `piece-hp` itself
* (its `DEFAULT_HP = 2` constant), imported here as a single source
* of truth.
*/
import type { Session, EntityId } from "@paratype/rete";
import type { ChessAttrKey } from "../schema.js";
import type { StartingLayout } from "../layouts/types.js";
import type { ModifierProfile } from "./types.js";
import { MODIFIER_REGISTRY } from "./registry.js";
import { applyProfilesToSession } from "./apply.js";
import type { ChessEngine } from "../engine.js";
import type { CustomModifierRegistry } from "./custom/registry.js";
/**
* The attribute name every modifier descriptor writes to. Mirrors the
* six registered descriptors' `attrName` values; collected once at
* module load so we don't walk the registry per call.
*
* We deliberately consume MODIFIER_REGISTRY here rather than
* hard-coding the list. If a future descriptor is added (or renamed),
* reconciliation picks it up automatically as long as the barrel
* import fires first. The runtime `Array.from` captures a snapshot at
* call time — the registry is append-only after module load, so this
* is safe.
*/
function modifierAttrNames(): readonly ChessAttrKey[] {
return MODIFIER_REGISTRY.list().map((d) => d.attrName);
}
/**
* Default baseline HP, matching `piece-hp`'s `DEFAULT_HP` constant.
* Duplicated (rather than imported) because importing the preset file
* here would trigger its `PRESET_REGISTRY.register` side-effect from
* this otherwise preset-agnostic module. When `piece-hp` changes its
* default, update this literal too — documented in the preset file.
*
* A future refactor could expose this via a public API on the preset
* module; that's a wider change than T15's scope.
*/
const BASELINE_HP = 2;
/**
* Find every piece entity (id > 0 with a PieceType fact). Separate
* helper because both the retraction pass and the HP-clamp pass need
* it, and walking `session.allFacts()` twice differently-filtered is
* cheaper than projecting it twice.
*/
function pieceIds(session: Session): EntityId[] {
const ids = new Set<EntityId>();
for (const f of session.allFacts()) {
if (f.attr === "PieceType" && (f.id as number) > 0) ids.add(f.id);
}
return [...ids];
}
/**
* Retract every modifier-owned attribute from every piece. Called
* before re-applying the new profile so stacking starts clean.
*
* Does NOT touch `Hp` — that's a preset-owned attribute (piece-hp),
* not a modifier-owned one. `Hp` clamping is handled separately in
* `reconcileProfileSwap`.
*/
function retractAllModifierFacts(session: Session): void {
const attrs = modifierAttrNames();
for (const id of pieceIds(session)) {
for (const attr of attrs) {
if (session.contains(id, attr)) {
session.retract(id, attr);
}
}
}
}
/**
* Snapshot `(pieceId → old HpBonus)` BEFORE we retract. Needed because
* after retraction we've lost the old bonus, and after re-apply we
* have only the new one — we never have both at the same time
* otherwise. The diff is what drives the Hp clamp.
*
* Pieces without an HpBonus fact default to 0 — no bonus → max HP is
* just the baseline. Same convention as `piece-hp` when no HpBonus is
* present.
*/
function snapshotHpBonuses(session: Session): Map<EntityId, number> {
const snapshot = new Map<EntityId, number>();
for (const id of pieceIds(session)) {
const b = session.get(id, "HpBonus");
snapshot.set(id, typeof b === "number" ? b : 0);
}
return snapshot;
}
/**
* For each piece that has a live `Hp` fact, clamp it to the new
* effective max (baseline + newHpBonus). Never grows Hp — a piece
* below the new max stays where it is.
*
* The invariant enforced: Hp ≤ BASELINE_HP + HpBonus (post-swap).
*
* Pieces without `Hp` are untouched: `piece-hp` isn't active, HP
* isn't being tracked, clamping is meaningless.
*/
function clampHpToNewMax(
session: Session,
oldBonuses: Map<EntityId, number>,
): void {
for (const id of pieceIds(session)) {
if (!session.contains(id, "Hp")) continue;
const current = session.get(id, "Hp") as number;
const newBonus =
typeof session.get(id, "HpBonus") === "number"
? (session.get(id, "HpBonus") as number)
: 0;
const newMax = BASELINE_HP + newBonus;
// Only act when the new max is below current HP. If HP is already
// at or below max (including the equal case), leave it alone —
// rewriting the same value is a wasted write.
if (current > newMax) {
session.insert(id, "Hp", Math.max(0, newMax));
}
// Unused-variable shimmy: oldBonuses is here to document intent
// and allow a future "only clamp when bonus SHRANK" optimization.
// Right now we always compare against newMax, which is equivalent
// for the correctness argument (if oldBonus == newBonus then
// newMax hasn't changed, and current <= oldMax == newMax so we
// skip naturally).
void oldBonuses;
}
}
/**
* Swap one profile for another on a live session.
*
* Callers (ChessEngine / server) should invoke this at a turn
* boundary so the effective rule set is constant within any one
* move's legal-move generation. Mid-move swaps are not supported —
* the engine's move validation assumes stable modifiers between
* `getAllLegalMoves` and `applyMove`.
*
* `oldProfile === null` is the initial-apply case (equivalent to
* calling `applyProfileToSession` directly; provided here so callers
* can funnel everything through a single API).
*
* `newProfile === null` strips every modifier fact and leaves the
* session with base-rules pieces only.
*
* `oldProfile === newProfile` (or structurally equal profiles) is a
* valid idempotent call; session state converges to the fully-applied
* shape.
*/
export function reconcileProfileSwap(
session: Session,
oldProfile: ModifierProfile | null,
newProfile: ModifierProfile | null,
layout: StartingLayout,
engine?: ChessEngine,
customRegistry?: CustomModifierRegistry,
): void {
reconcileProfilesSwap(
session,
oldProfile === null ? [] : [oldProfile],
newProfile === null ? [] : [newProfile],
layout,
engine,
customRegistry,
);
}
/**
* Multi-profile variant of `reconcileProfileSwap` (T23). Treats both
* old and new as ordered profile stacks; the retract-then-apply
* algorithm composes naturally because the retract step wipes ALL
* modifier-owned facts regardless of which profile contributed them.
*
* - `oldProfiles` empty: equivalent to first-time apply.
* - `newProfiles` empty: equivalent to retract-only (modifier-free
* session afterwards).
* - `oldProfiles === newProfiles`: idempotent.
*/
export function reconcileProfilesSwap(
session: Session,
_oldProfiles: readonly ModifierProfile[],
newProfiles: readonly ModifierProfile[],
layout: StartingLayout,
engine?: ChessEngine,
customRegistry?: CustomModifierRegistry,
): void {
// Step 1: snapshot HpBonus BEFORE we mutate anything (see
// single-profile variant for rationale).
const oldHpBonuses = snapshotHpBonuses(session);
// Step 2: retract every modifier-owned attribute from every piece.
retractAllModifierFacts(session);
// Step 3: reapply the new profile stack from scratch.
if (newProfiles.length > 0) {
applyProfilesToSession(session, newProfiles, layout, engine, customRegistry);
}
// Step 4: clamp current Hp against the new effective max.
clampHpToNewMax(session, oldHpBonuses);
}

View file

@ -0,0 +1,87 @@
/**
* Unit tests for MODIFIER_REGISTRY.
*
* Deliberately does NOT import `./index.js` — that would pull in every
* T1 descriptor via side-effects and pre-populate the registry, which
* would break the "starts empty" assertion. Once descriptors land
* (T6-T11), those tests live in their own files.
*
* IMPORTANT: these tests mutate the shared `MODIFIER_REGISTRY`
* singleton (there's no reset/clear API by design — the real registry
* is populated once at module load time). To stay isolated we use
* fresh, unique ids inside the test and only assert properties of
* descriptors we own.
*/
import { describe, it, expect } from "vitest";
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "./registry.js";
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
/**
* Build a minimal mock descriptor. We cast the id through string
* because the test descriptors use synthetic ids ("test-*") to avoid
* colliding with real ones that T6-T11 will register.
*/
function mockDescriptor(id: string): ModifierDescriptor {
return {
id: id as ModifierKindId,
attrName: "Hp",
label: `Mock ${id}`,
valueSchema: z.unknown(),
stackingRule: "additive",
apply: (_session: Session, _pieceId: EntityId, _value: unknown) => {
// no-op
},
describe: (value) => `mock=${String(value)}`,
uiForm: "number",
};
}
describe("MODIFIER_REGISTRY", () => {
it("starts empty (no T1 descriptors registered yet)", () => {
// At the time this test file is loaded, no descriptor side-effects
// have run (we don't import ./index.js), so the registry should be
// empty. If this ever fires, someone registered a descriptor at
// module scope without a corresponding import guard — investigate
// before relaxing.
expect(MODIFIER_REGISTRY.list()).toEqual([]);
});
it("register(descriptor) makes it retrievable via get(id)", () => {
const desc = mockDescriptor("test-get");
MODIFIER_REGISTRY.register(desc);
expect(MODIFIER_REGISTRY.get("test-get" as ModifierKindId)).toBe(desc);
});
it("has(id) returns true after registration, false before", () => {
const id = "test-has" as ModifierKindId;
expect(MODIFIER_REGISTRY.has(id)).toBe(false);
MODIFIER_REGISTRY.register(mockDescriptor("test-has"));
expect(MODIFIER_REGISTRY.has(id)).toBe(true);
});
it("list() returns every registered descriptor", () => {
const a = mockDescriptor("test-list-a");
const b = mockDescriptor("test-list-b");
MODIFIER_REGISTRY.register(a);
MODIFIER_REGISTRY.register(b);
const all = MODIFIER_REGISTRY.list();
expect(all).toContain(a);
expect(all).toContain(b);
});
it("register() with duplicate id throws an error mentioning the id", () => {
const id = "test-dup";
MODIFIER_REGISTRY.register(mockDescriptor(id));
expect(() => MODIFIER_REGISTRY.register(mockDescriptor(id))).toThrow(
/test-dup/,
);
});
it("get() returns undefined for unknown ids", () => {
expect(
MODIFIER_REGISTRY.get("test-never-registered" as ModifierKindId),
).toBeUndefined();
});
});

View file

@ -0,0 +1,76 @@
/**
* Modifier descriptor registry.
*
* Mirrors `LAYOUT_REGISTRY`: the six T1 modifier categories register
* themselves via side-effect imports from their own module files, and
* `./index.ts` is the barrel that imports every descriptor so that
* consumers get them all registered by importing anywhere from
* `./modifiers`.
*
* Duplicate-id registration throws — this is the signal that two
* modules are trying to claim the same `ModifierKindId`, which would
* silently overwrite in a Map. Throwing surfaces the collision at load
* time rather than at runtime when a modifier would mysteriously
* "disappear" because a later import clobbered the earlier descriptor.
*
* Descriptors are read-only once registered; the registry exposes no
* mutation paths. The set of modifier kinds is fixed at module-load
* time (ADR-8) — there's no runtime registration of new modifier
* categories from userland.
*/
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
class ModifierRegistryClass {
readonly #byId = new Map<ModifierKindId, ModifierDescriptor>();
/**
* Register a descriptor under its `id`. Throws if the id is already
* taken — defensive check because silently overwriting would create
* confusing debugging ("which of my two hp-bonus descriptors won?"
* races on module import order).
*
* Generic over V so descriptors with concrete value types
* (e.g. `ModifierDescriptor<number>`) can be passed without a
* type assertion at the call site. The registry stores them as
* `ModifierDescriptor<unknown>` — a heterogeneous container that
* loses V intentionally; callers that need the typed value should
* import the descriptor directly from its own module.
*/
register<V>(descriptor: ModifierDescriptor<V>): void {
if (this.#byId.has(descriptor.id)) {
throw new Error(
`ModifierRegistry: duplicate modifier id "${descriptor.id}". ` +
`Each modifier descriptor must have a unique id.`,
);
}
// `ModifierDescriptor<V>` is not structurally assignable to
// `ModifierDescriptor<unknown>` due function-parameter variance.
// Erase V once at registry storage boundary.
this.#byId.set(descriptor.id, descriptor as ModifierDescriptor);
}
/**
* Look up a descriptor by id. Returns undefined for unknown ids —
* the caller decides whether that's an error (validator rejecting a
* profile referencing an unknown modifier kind) or a benign miss.
*/
get(id: ModifierKindId): ModifierDescriptor | undefined {
return this.#byId.get(id);
}
/**
* All registered descriptors, in registration (= import) order. UI
* code iterating descriptors to render form rows relies on this
* being a stable order.
*/
list(): readonly ModifierDescriptor[] {
return [...this.#byId.values()];
}
/** True if a descriptor with the given id is registered. */
has(id: ModifierKindId): boolean {
return this.#byId.has(id);
}
}
export const MODIFIER_REGISTRY = new ModifierRegistryClass();

View file

@ -0,0 +1,300 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import {
TypeModifierSchema,
InstanceModifierSchema,
ModifierProfileSchema,
parseModifierProfile,
serializeModifierProfile,
} from "./schema.js";
import type { ModifierProfile } from "./types.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const VALID_PROFILE: ModifierProfile = {
id: "profile-001",
name: "Knight Buff",
description: "Gives all white knights +2 HP",
layoutId: "classic",
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 },
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
],
perInstance: [
{ kind: "hp-bonus", square: "b1", value: 5 },
],
version: 1,
source: "premade",
};
const VALID_PROFILE_NO_LAYOUT: ModifierProfile = {
id: "profile-002",
name: "Custom Pawns",
description: "Custom pawn modifiers",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
// ---------------------------------------------------------------------------
// TypeModifierSchema
// ---------------------------------------------------------------------------
describe("TypeModifierSchema", () => {
it("parses a valid hp-bonus TypeModifier", () => {
const result = TypeModifierSchema.parse({
kind: "hp-bonus",
pieceType: "knight",
color: "white",
value: 3,
});
expect(result.kind).toBe("hp-bonus");
expect(result.value).toBe(3);
});
it("parses direction-additions with 'both' color", () => {
const result = TypeModifierSchema.parse({
kind: "direction-additions",
pieceType: "pawn",
color: "both",
value: ["forward", "backward"],
});
expect(result.color).toBe("both");
});
it("parses capture-flags TypeModifier", () => {
const result = TypeModifierSchema.parse({
kind: "capture-flags",
pieceType: "queen",
color: "black",
value: 3,
});
expect(result.kind).toBe("capture-flags");
});
it("accepts arbitrary kind strings (T3 widening for custom modifier ids)", () => {
// Pre-T3 this schema used z.enum() to reject unknown kinds. The
// widening was forced by user-authored CustomModifierIds — they're
// arbitrary strings (e.g. "custom:my-shield"), and a profile that
// references one would be silently dropped on library load if the
// enum check rejected it. Validity is now enforced at apply time:
// MODIFIER_REGISTRY.get(kind) || engine.customModifiers.get(kind),
// unknown kinds are warned and skipped.
const result = TypeModifierSchema.parse({
kind: "custom:user-authored",
pieceType: "knight",
color: "white",
value: null,
});
expect(result.kind).toBe("custom:user-authored");
});
it("rejects empty kind string", () => {
expect(() =>
TypeModifierSchema.parse({
kind: "",
pieceType: "knight",
color: "white",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects unknown piece type", () => {
expect(() =>
TypeModifierSchema.parse({
kind: "hp-bonus",
pieceType: "dragon",
color: "white",
value: 1,
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// InstanceModifierSchema
// ---------------------------------------------------------------------------
describe("InstanceModifierSchema", () => {
it("parses a valid instance modifier with square 'b1'", () => {
const result = InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "b1",
value: 5,
});
expect(result.square).toBe("b1");
});
it("parses a valid instance modifier at 'h8'", () => {
const result = InstanceModifierSchema.parse({
kind: "range-bonus",
square: "h8",
value: 2,
});
expect(result.square).toBe("h8");
});
it("rejects invalid square 'z9'", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "z9",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects invalid square 'a9' (rank out of range)", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "a9",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects invalid square 'i1' (file out of range)", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "i1",
value: 1,
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// ModifierProfileSchema — valid cases
// ---------------------------------------------------------------------------
describe("ModifierProfileSchema — valid", () => {
it("parses a full valid profile", () => {
const result = ModifierProfileSchema.parse(VALID_PROFILE);
expect(result.id).toBe("profile-001");
expect(result.version).toBe(1);
expect(result.source).toBe("premade");
expect(result.perType).toHaveLength(2);
expect(result.perInstance).toHaveLength(1);
});
it("parses a profile without layoutId (optional field)", () => {
const result = ModifierProfileSchema.parse(VALID_PROFILE_NO_LAYOUT);
expect(result.layoutId).toBeUndefined();
});
it("parses a profile with empty perType and perInstance arrays", () => {
const result = ModifierProfileSchema.parse({
id: "empty-profile",
name: "Empty",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
});
expect(result.perType).toHaveLength(0);
expect(result.perInstance).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// ModifierProfileSchema — invalid cases
// ---------------------------------------------------------------------------
describe("ModifierProfileSchema — invalid", () => {
it("rejects profile with version=2", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, version: 2 })
).toThrow(z.ZodError);
});
it("rejects profile with missing 'name' field", () => {
const { name: _name, ...rest } = VALID_PROFILE;
expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError);
});
it("rejects profile with source='admin'", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, source: "admin" })
).toThrow(z.ZodError);
});
it("rejects profile with empty id string", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, id: "" })
).toThrow(z.ZodError);
});
it("rejects profile with invalid perInstance square", () => {
expect(() =>
ModifierProfileSchema.parse({
...VALID_PROFILE,
perInstance: [{ kind: "hp-bonus", square: "z9", value: 1 }],
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// parseModifierProfile / serializeModifierProfile
// ---------------------------------------------------------------------------
describe("parseModifierProfile", () => {
it("returns a typed ModifierProfile for valid input", () => {
const result = parseModifierProfile(VALID_PROFILE);
expect(result.id).toBe("profile-001");
});
it("throws ZodError for invalid input", () => {
expect(() => parseModifierProfile({ invalid: true })).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// Roundtrip tests
// ---------------------------------------------------------------------------
describe("roundtrip", () => {
it("full profile roundtrips through JSON.parse(JSON.stringify(...))", () => {
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE));
const result = parseModifierProfile(serialized);
expect(result).toEqual(VALID_PROFILE);
});
it("profile without layoutId roundtrips losslessly", () => {
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE_NO_LAYOUT));
const result = parseModifierProfile(serialized);
expect(result).toEqual(VALID_PROFILE_NO_LAYOUT);
});
it("serializeModifierProfile output can be re-parsed", () => {
const serialized = serializeModifierProfile(VALID_PROFILE);
const reparsed = parseModifierProfile(JSON.parse(JSON.stringify(serialized)));
expect(reparsed).toEqual(VALID_PROFILE);
});
it("profile with complex value types roundtrips", () => {
const profile: ModifierProfile = {
id: "complex-001",
name: "Complex Profile",
description: "Has various value types",
perType: [
{ kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward", "diagonal-fl"] },
{ kind: "promotion-override", pieceType: "pawn", color: "black", value: "queen" },
],
perInstance: [
{ kind: "capture-flags", square: "e4", value: 3 },
],
version: 1,
source: "custom",
};
const result = parseModifierProfile(JSON.parse(JSON.stringify(profile)));
expect(result).toEqual(profile);
});
});

View file

@ -0,0 +1,114 @@
/**
* Zod schemas for ModifierProfile serialization and validation.
*
* The `value` field on TypeModifier and InstanceModifier uses `z.any()`
* rather than `z.unknown()` — Zod v4 infers `z.unknown()` object fields as
* optional (`value?: unknown`) which conflicts with the required `value:
* unknown` in the TypeModifier/InstanceModifier interfaces. `z.any()` infers
* as a required `any` field while still accepting all values.
*
* Per-kind value validation happens at descriptor registration time, not at
* profile-parse time. This keeps the schema forwards-compatible.
*/
import { z } from "zod";
import type { ModifierProfile } from "./types.js";
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
/**
* Built-in modifier ids — preserved as a literal enum for documentation
* and editor-side completion. Not used as the parse schema directly
* because T3 widens `kind` to also accept user-authored CustomModifierIds
* (arbitrary strings); the runtime apply path (T22) dispatches via
* MODIFIER_REGISTRY first then engine.customModifiers, silently skipping
* unknown kinds.
*/
export const BUILTIN_MODIFIER_KIND_IDS = [
"hp-bonus",
"range-bonus",
"direction-additions",
"capture-flags",
"promotion-override",
"damage-resistance",
] as const;
/**
* Parse-time schema for `kind`: any non-empty string. T3 custom modifier
* ids are arbitrary strings (e.g. "custom:my-shield"), so an enum check
* here would silently reject every profile that uses a user-authored
* descriptor and cause the library to drop the entry on load.
*
* Validity of the kind is enforced ELSEWHERE: the apply pipeline looks
* the id up in MODIFIER_REGISTRY (built-ins) then engine.customModifiers
* (user-authored). Unknown kinds are warned and skipped.
*/
const ModifierKindIdSchema = z.string().min(1);
const PieceTypeSchema = z.enum([
"pawn",
"knight",
"bishop",
"rook",
"queen",
"king",
]);
// PieceColor extended with "both" for TypeModifier.color
const PieceColorExtSchema = z.enum(["white", "black", "both"]);
/** Algebraic notation square: "a1".."h8" */
const SquareStringSchema = z
.string()
.regex(/^[a-h][1-8]$/, "Must be algebraic notation (a1-h8)");
// ---------------------------------------------------------------------------
// TypeModifier
// ---------------------------------------------------------------------------
export const TypeModifierSchema = z.object({
kind: ModifierKindIdSchema,
pieceType: PieceTypeSchema,
color: PieceColorExtSchema,
value: z.any() as z.ZodType<unknown>,
});
// ---------------------------------------------------------------------------
// InstanceModifier
// ---------------------------------------------------------------------------
export const InstanceModifierSchema = z.object({
kind: ModifierKindIdSchema,
square: SquareStringSchema,
value: z.any() as z.ZodType<unknown>,
});
// ---------------------------------------------------------------------------
// ModifierProfile
// ---------------------------------------------------------------------------
export const ModifierProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
layoutId: z.string().optional(),
perType: z.array(TypeModifierSchema),
perInstance: z.array(InstanceModifierSchema),
version: z.literal(1),
source: z.enum(["premade", "custom"]),
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Parse an unknown value as a ModifierProfile. Throws ZodError on failure. */
export function parseModifierProfile(raw: unknown): ModifierProfile {
return ModifierProfileSchema.parse(raw) as ModifierProfile;
}
/** Serialize a ModifierProfile to a JSON-safe plain object. */
export function serializeModifierProfile(profile: ModifierProfile): unknown {
return ModifierProfileSchema.parse(profile);
}

View file

@ -0,0 +1,71 @@
import { expect, test } from "vitest";
import { ChessEngine } from "../engine.js";
import { getModifierSource } from "./source.js";
import type { ModifierProfile } from "./types.js";
import { PRESET_REGISTRY } from "../presets/registry.js";
import type { EntityId } from "@paratype/rete";
const TEST_PROFILE: ModifierProfile = {
id: "test-profile",
name: "Test Profile",
description: "",
version: 1,
source: "custom",
perInstance: [
{ kind: "hp-bonus", square: "b1", value: 2 },
],
perType: [
{ kind: "range-bonus", pieceType: "knight", color: "white", value: 1 },
],
};
test("returns per-instance for a square match", () => {
const engine = new ChessEngine({ profile: TEST_PROFILE });
// Find the knight on b1
const b1Id = engine.session.allFacts().find(
(f) => f.attr === "Position" && f.value === 1 // b1 is index 1
)?.id as number;
const source = getModifierSource(engine, b1Id as unknown as EntityId, "hp-bonus");
expect(source).toEqual({ kind: "per-instance", square: "b1" });
});
test("returns per-type for a type/color match when no instance match", () => {
const engine = new ChessEngine({ profile: TEST_PROFILE });
// Find a white knight that ISN'T on b1 (e.g. g1)
const g1Id = engine.session.allFacts().find(
(f) => f.attr === "Position" && f.value === 6 // g1 is index 6
)?.id as number;
const source = getModifierSource(engine, g1Id as unknown as EntityId, "range-bonus");
expect(source).toEqual({ kind: "per-type", pieceType: "knight", color: "white" });
});
test("returns preset if no profile overrides but a preset declares the attribute", () => {
// Use a preset that declares Hp. Let's make sure 'piece-hp' is active.
const engine = new ChessEngine();
engine.activePresets.replaceAll([{ id: "piece-hp", scope: "both", turnsRemaining: null }]);
// Find any piece, e.g., a1 rook
const a1Id = engine.session.allFacts().find(
(f) => f.attr === "Position" && f.value === 0
)?.id as EntityId;
const source = getModifierSource(engine, a1Id, "hp-bonus");
const hpDef = PRESET_REGISTRY.get("piece-hp")!;
expect(source).toEqual({ kind: "preset", presetId: "piece-hp", presetName: hpDef.name });
});
test("returns default if nothing overrides or declares it", () => {
const engine = new ChessEngine();
// Find any piece
const e2Id = engine.session.allFacts().find(
(f) => f.attr === "Position" && f.value === 12
)?.id as EntityId;
// With no profile and no active preset declaring DirectionAdditions, it should be default
const source = getModifierSource(engine, e2Id, "direction-additions");
expect(source).toEqual({ kind: "default" });
});

View file

@ -0,0 +1,70 @@
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { PieceColor, PieceType } from "../schema.js";
import type { ModifierKindId } from "./types.js";
import { PRESET_REGISTRY } from "../presets/registry.js";
import { MODIFIER_REGISTRY } from "./index.js";
import { squareToAlgebraic } from "../coord.js";
export type ModifierSource =
| { kind: "per-instance"; square: string }
| { kind: "per-type"; pieceType: PieceType; color: PieceColor | "both" }
| { kind: "preset"; presetId: string; presetName: string }
| { kind: "default" };
/**
* Determine the origin of a modifier applied to a specific piece.
* Checks the active profile (per-instance, then per-type), then active presets,
* falling back to "default".
*/
export function getModifierSource(
engine: ChessEngine,
pieceId: EntityId,
modifierKind: ModifierKindId
): ModifierSource {
const profile = engine.activeProfile;
const squareNum = engine.session.get(pieceId, "Position") as number | undefined;
const algebraicSq = squareNum !== undefined ? squareToAlgebraic(squareNum) : null;
const pieceType = engine.session.get(pieceId, "PieceType") as PieceType | undefined;
const color = engine.session.get(pieceId, "Color") as PieceColor | undefined;
if (profile) {
// 1. Check per-instance modifiers
if (algebraicSq) {
const instanceMatch = profile.perInstance.find(
(m) => m.kind === modifierKind && m.square === algebraicSq,
);
if (instanceMatch) {
return { kind: "per-instance", square: instanceMatch.square };
}
}
// 2. Check per-type modifiers
if (pieceType && color) {
const typeMatch = profile.perType.find(
(m) =>
m.kind === modifierKind &&
m.pieceType === pieceType &&
(m.color === "both" || m.color === color),
);
if (typeMatch) {
return { kind: "per-type", pieceType: typeMatch.pieceType, color: typeMatch.color };
}
}
}
// 3. Check active presets
const descriptor = MODIFIER_REGISTRY.get(modifierKind);
if (descriptor) {
const presetAttr = descriptor.baseAttr ?? descriptor.attrName;
for (const presetEntry of engine.activePresets.list()) {
const def = PRESET_REGISTRY.get(presetEntry.id);
if (def?.pieceAttributes?.includes(presetAttr)) {
return { kind: "preset", presetId: presetEntry.id, presetName: def.name };
}
}
}
// 4. Default
return { kind: "default" };
}

View file

@ -0,0 +1,264 @@
/**
* Trigger primitive evaluator tests.
*
* The integration preset's onBeforeMove / onAfterMove hooks dispatch
* to the four trigger families. These tests poke at the dispatchers
* directly via a profile that wires each kind of trigger and then
* apply moves through the engine, asserting the inner primitives
* actually ran.
*/
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../engine.js";
import type { ModifierProfile } from "./types.js";
import "./primitives/index.js";
function makeProfileWithCustomKind(profileId: string): ModifierProfile {
return {
id: profileId,
name: profileId,
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
}
function findPiece(engine: ChessEngine, square: number) {
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
return f.id;
}
}
throw new Error(`no piece at square ${square}`);
}
describe("on-turn-start triggers", () => {
it("runs nested primitives at the start of the matching color's turn", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("trigger-test"),
});
// Seed an on-turn-start hook on the white queen that bumps RangeBonus
// by 1 each turn.
const whiteQueen = findPiece(engine, 3); // d1
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
]);
// Move e2-e4. After this move, the next turn (black) starts. Our
// hook is on a WHITE piece; it fires when WHITE's turn begins.
// So one half-move (e4) doesn't trigger; we need the next white
// move.
const movesAfterE4 = engine.getAllLegalMoves();
const e2e4 = movesAfterE4.find((m) => m.from === 12 && m.to === 28);
expect(e2e4).toBeDefined();
engine.applyMove(e2e4!);
// After black moves, white's turn begins → hook fires.
const blackMoves = engine.getAllLegalMoves();
const e7e5 = blackMoves.find((m) => m.from === 52 && m.to === 36);
expect(e7e5).toBeDefined();
engine.applyMove(e7e5!);
const range = engine.session.get(whiteQueen, "RangeBonus") as
| number
| undefined;
expect(range).toBe(1);
});
it("does not fire for pieces of the opposite color when their turn isn't starting", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("trigger-color"),
});
// Hook on the WHITE queen.
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
]);
// Make ONE white move. Black's turn now begins. The hook is on a
// white piece, so it should NOT fire (only white-turn beginnings
// trigger it).
const movesBeforeAnyMove = engine.getAllLegalMoves();
const e2e4 = movesBeforeAnyMove.find((m) => m.from === 12 && m.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBeUndefined();
});
});
describe("on-capture triggers", () => {
it("fires when this piece captures another", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-capture-test"),
});
// Set up a capture by moving white pawn to e4 then black pawn to d5
// then white pawn captures e4xd5. We need on-capture hook on the
// white pawn that ends up doing the capture.
const whitePawn = findPiece(engine, 12); // e2
engine.session.insert(whitePawn, "OnCaptureHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 5 } }],
]);
// White e2-e4
let moves = engine.getAllLegalMoves();
let m = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(m!);
// Black d7-d5
moves = engine.getAllLegalMoves();
m = moves.find((mv) => mv.from === 51 && mv.to === 35);
engine.applyMove(m!);
// White e4xd5
moves = engine.getAllLegalMoves();
m = moves.find((mv) => mv.from === 28 && mv.to === 35 && mv.isCapture);
expect(m).toBeDefined();
engine.applyMove(m!);
// Hook should have fired — RangeBonus of 5 written.
expect(engine.session.get(whitePawn, "RangeBonus")).toBe(5);
});
});
describe("conditional triggers", () => {
it("runs the then-branch when the condition matches", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-then"),
});
const whiteQueen = findPiece(engine, 3);
// Seed condition: when HpBonus is 0 (default), run the then-branch
// which sets RangeBonus to 7. The condition will match on every move.
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: { type: "always" } as const,
then: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
],
},
]);
// Apply any move so onAfterMove fires.
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(7);
});
it("runs the else-branch when the condition fails", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-else"),
});
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: { type: "never" } as const,
then: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
],
else: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
],
},
]);
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(9);
});
it("attr-lt condition compares against the live attr value", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("conditional-attr-lt"),
});
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "RangeBonus", 0);
engine.session.insert(whiteQueen, "ConditionalHooks", [
{
condition: {
type: "attr-lt" as const,
attr: "RangeBonus",
value: 5,
},
then: [
{
kind: "seed-attribute",
params: { attr: "RangeBonus", value: 100 },
},
],
},
]);
const moves = engine.getAllLegalMoves();
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
engine.applyMove(e2e4!);
// 0 < 5 → then-branch fires → RangeBonus = 100.
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(100);
});
});
describe("absorb-damage-with-attribute (damage pipeline integration)", () => {
it("consumes ShieldCharges before HP drops via the onDamage hook", async () => {
// Drive the integration preset's onDamage hook directly. The
// preset is registered globally; we look it up via PRESET_REGISTRY
// and call its hook with a synthetic ctx. The behavioural
// end-to-end (capture during real gameplay → shield absorbs) is
// covered by the Playwright e2e suite.
const { PRESET_REGISTRY } = await import("../presets/registry.js");
const integrationPreset = PRESET_REGISTRY.get(
"__modifier-profile-integration__",
);
expect(integrationPreset?.onDamage).toBeDefined();
const engine = new ChessEngine();
const e2Pawn = findPiece(engine, 12);
// Charge the shield: 3 charges, rate 1 per damage point.
engine.session.insert(e2Pawn, "ShieldCharges" as never, 3);
engine.session.insert(e2Pawn, "AbsorbDamageAttr", "ShieldCharges");
engine.session.insert(e2Pawn, "AbsorbDamageRate", 1);
const damageCtx = {
engine,
target: e2Pawn,
amount: 1,
kind: "capture" as const,
attacker: e2Pawn,
};
// Fire a 1-damage event. Charges go 3 → 2; consume=true returned.
const result = integrationPreset!.onDamage!(damageCtx);
expect(result?.consume).toBe(true);
expect(result?.died).toBe(false);
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(2);
// Drain the shield with two more 1-damage events. After the third,
// charges reach 0; further damage should fall through (no consume).
integrationPreset!.onDamage!(damageCtx);
integrationPreset!.onDamage!(damageCtx);
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(0);
// Fourth hit: shield empty. Handler should fall through (no
// consume-true return), so the rest of the damage pipeline runs.
const fourthHit = integrationPreset!.onDamage!(damageCtx);
expect(fourthHit?.consume).not.toBe(true);
});
});

View file

@ -0,0 +1,222 @@
/**
* Trigger-primitive evaluator (T29 follow-up).
*
* The four trigger primitives — `on-turn-start`, `on-capture`,
* `on-damaged`, `conditional` — seed hook facts on a piece at apply
* time. The integration preset's `onAfterMove` hook calls these
* dispatchers to walk every piece with each kind of hook fact and
* run the inner primitive lists at the corresponding game phase.
*
* Phase mapping:
* - on-turn-start hooks fire for pieces of the color whose turn is
* NOW beginning (i.e. the non-mover after a successful move).
* - on-capture hooks fire for the mover's piece when the move just
* captured something (capturedId !== null on the last move log entry).
* - on-damaged hooks fire for any piece whose Hp decreased between
* the snapshot taken before the move and the post-move state.
* Detected by comparing a pre-move HP snapshot.
* - conditional hooks evaluate at every onAfterMove against the
* piece's current facts; the matching branch's primitives run.
*
* Each inner primitive runs via the same `applyCustomDescriptor` path
* used at profile-apply time, so nested triggers and conditionals
* compose recursively (with the runtime depth cap as a backstop).
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessAttrMap, ConditionSpec, PieceColor } from "../schema.js";
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
import type {
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "./primitives/types.js";
import type { ChessEngine } from "../engine.js";
/**
* Iterate every piece (id > 0) and yield (id, color, hp).
*/
function* eachPiece(
session: Session,
): Generator<{ id: EntityId; color: PieceColor }> {
const seen = new Set<number>();
for (const f of session.allFacts()) {
if (f.attr !== "Color") continue;
if ((f.id as number) <= 0) continue;
if (seen.has(f.id as number)) continue;
seen.add(f.id as number);
yield { id: f.id, color: f.value as PieceColor };
}
}
/**
* Run a list of primitive nodes against a single piece. Mirrors
* `applyCustomDescriptor`'s walker but operates without a parent
* descriptor (triggers fire mid-game; the descriptor that originally
* seeded the hook isn't available at this phase).
*/
function runPrimitives(
engine: ChessEngine,
pieceId: EntityId,
nodes: readonly EffectPrimitiveNode[],
depth: number,
): void {
if (depth > 8) return; // hard runtime cap, mirrors validator
for (const node of nodes) {
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive === undefined) continue;
const ctx: PrimitiveApplyContext = {
engine,
session: engine.session,
pieceId,
depth,
// Trigger evaluation has no parent descriptor — synthesise a
// minimal ref so the type contract is satisfied.
descriptor: { id: "__trigger__", type: "data", version: 1 },
};
primitive.apply(ctx, node.params);
if (primitive.childPrimitives === undefined) continue;
let children: readonly EffectPrimitiveNode[] = [];
try {
children = primitive.childPrimitives(node.params);
} catch {
children = [];
}
if (children.length > 0) {
runPrimitives(engine, pieceId, children, depth + 1);
}
}
}
/**
* Evaluate a single ConditionSpec against a piece's current facts.
*/
function evaluateCondition(
session: Session,
pieceId: EntityId,
condition: ConditionSpec,
): boolean {
switch (condition.type) {
case "always":
return true;
case "never":
return false;
case "attr-eq": {
const v = session.get(pieceId, condition.attr);
return v === condition.value;
}
case "attr-lt": {
const v = session.get(pieceId, condition.attr);
return typeof v === "number" && v < condition.value;
}
case "attr-gt": {
const v = session.get(pieceId, condition.attr);
return typeof v === "number" && v > condition.value;
}
}
}
/**
* Fire `on-turn-start` hooks for every piece of the given color.
* Called from the integration preset's onAfterMove with the
* non-mover color (whose turn is now beginning).
*/
export function fireOnTurnStartHooks(
engine: ChessEngine,
whoseTurn: PieceColor,
): void {
for (const { id, color } of eachPiece(engine.session)) {
if (color !== whoseTurn) continue;
const hooks = engine.session.get(id, "OnTurnStartHooks") as
| ChessAttrMap["OnTurnStartHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
}
}
}
/**
* Fire `on-capture` hooks for the attacker piece. Called from the
* integration preset's onAfterMove, which receives the attacker id
* + capture-target metadata via a per-engine snapshot taken in
* onBeforeMove (the engine's `moveLog` isn't populated yet at
* onAfterMove time).
*
* `attackerId` is null when the most-recent move wasn't a capture.
*/
export function fireOnCaptureHooks(
engine: ChessEngine,
attackerId: EntityId | null,
): void {
if (attackerId === null) return;
const hooks = engine.session.get(attackerId, "OnCaptureHooks") as
| ChessAttrMap["OnCaptureHooks"]
| undefined;
if (hooks === undefined) return;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1);
}
}
/**
* Snapshot every piece's current Hp value (for damage-delta detection
* around a move). The integration preset captures this BEFORE the
* move happens, then `fireOnDamagedHooks` compares to the post-move
* state to find pieces whose Hp decreased.
*/
export function snapshotHp(session: Session): Map<EntityId, number> {
const out = new Map<EntityId, number>();
for (const f of session.allFacts()) {
if (f.attr !== "Hp") continue;
if ((f.id as number) <= 0) continue;
if (typeof f.value === "number") out.set(f.id, f.value);
}
return out;
}
/**
* Fire `on-damaged` hooks for every piece whose Hp decreased relative
* to the supplied pre-move snapshot. A retracted Hp fact (piece died)
* also counts as damage. New pieces (no entry in snapshot) don't fire.
*/
export function fireOnDamagedHooks(
engine: ChessEngine,
preMoveHp: ReadonlyMap<EntityId, number>,
): void {
for (const [id, prev] of preMoveHp) {
const current = engine.session.get(id, "Hp") as number | undefined;
const damaged = current === undefined || current < prev;
if (!damaged) continue;
const hooks = engine.session.get(id, "OnDamagedHooks") as
| ChessAttrMap["OnDamagedHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
}
}
}
/**
* Evaluate every piece's `ConditionalHooks` and run the matching
* branch (then-primitives or else-primitives). Fires on every move
* — conditions are re-evaluated against current facts so a hook that
* reacts to "Hp < 2" fires the moment HP drops below threshold.
*/
export function fireConditionalHooks(engine: ChessEngine): void {
for (const { id } of eachPiece(engine.session)) {
const hooks = engine.session.get(id, "ConditionalHooks") as
| ChessAttrMap["ConditionalHooks"]
| undefined;
if (hooks === undefined) continue;
for (const hook of hooks) {
const matches = evaluateCondition(engine.session, id, hook.condition);
const branch = matches ? hook.then : hook.else;
if (branch === undefined || branch.length === 0) continue;
runPrimitives(engine, id, branch, 1);
}
}
}

View file

@ -0,0 +1,160 @@
/**
* Type definitions for the piece modifier profile system.
*
* A ModifierProfile is a first-class entity (orthogonal to layouts and
* presets) that attaches rule modifiers to pieces by type ("all white
* knights have +1 HP") or by layout slot ("the piece on b1 has +2 range").
*
* Modifier profiles combine with layouts at game start. They are saved
* independently to a library (houserules:modifier-profiles:v1) and can be
* hot-swapped mid-game at turn boundaries.
*
* ADR-2: per-instance modifiers keyed by algebraic-notation square string
* (e.g. "b1"). Profile may optionally specify a layoutId for validation.
*/
import type { EntityId, Session } from "@paratype/rete";
import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js";
import type { ZodType } from "zod";
/** The six T1 modifier category identifiers. */
export type ModifierKindId =
| "hp-bonus"
| "range-bonus"
| "direction-additions"
| "capture-flags"
| "promotion-override"
| "damage-resistance"
| (string & {});
/**
* Named movement directions (from the perspective of the moving piece,
* color-independent — engine interprets "forward" as toward the opponent's
* back rank based on piece color).
*
* Used by DirectionAdditions modifier to specify additional movement directions.
*/
export type Direction =
| "forward" // toward opponent's back rank (1 square)
| "backward" // toward own back rank (1 square)
| "left" // queenside (from white's perspective)
| "right" // kingside (from white's perspective)
| "diagonal-fl" // forward-left
| "diagonal-fr" // forward-right
| "diagonal-bl" // backward-left
| "diagonal-br"; // backward-right
/**
* A modifier that applies to ALL pieces of a given type+color combo.
* `value` is the modifier-kind-specific value (number, string[], etc.)
*/
export interface TypeModifier {
readonly kind: ModifierKindId;
readonly pieceType: PieceType;
readonly color: PieceColor | "both";
readonly value: unknown;
}
/**
* A modifier that applies to the piece at a specific layout square.
* `square` is algebraic notation: "a1" through "h8".
* ADR-2: keyed by layout-slot (square string), not EntityId.
*/
export interface InstanceModifier {
readonly kind: ModifierKindId;
readonly square: string; // algebraic notation, e.g. "b1"
readonly value: unknown;
}
/**
* A complete modifier profile.
*
* Combines with a layout at game start: perType modifiers apply to all
* matching pieces; perInstance modifiers apply to pieces at specific squares.
*
* `layoutId` is optional — only required when perInstance entries are
* present, as per-instance modifiers are layout-slot-bound (ADR-2).
* When layoutId is absent and perInstance is non-empty, the validator
* emits a warning.
*/
export interface ModifierProfile {
readonly id: string;
readonly name: string;
readonly description: string;
/** Layout this profile's per-instance modifiers are bound to. Optional. */
readonly layoutId?: string | undefined;
readonly perType: readonly TypeModifier[];
readonly perInstance: readonly InstanceModifier[];
readonly version: 1;
readonly source: "premade" | "custom";
}
/**
* Descriptor for a modifier category. Each T1 modifier registers one
* descriptor into MODIFIER_REGISTRY at module load time (ADR-8).
*
* `V` is the value type (number for HpBonus, string[] for DirectionAdditions, etc.)
*/
export interface ModifierDescriptor<V = unknown> {
/** Matches ModifierKindId — used as the registry key. */
readonly id: ModifierKindId;
/** The ChessAttrMap key this modifier seeds on piece entities. */
readonly attrName: ChessAttrKey;
/**
* Optional: the base piece attribute this modifier augments. When set,
* `getModifierSource` uses this (not `attrName`) to match against a
* preset's `pieceAttributes` list — e.g. `hp-bonus` augments the `Hp`
* attribute declared by the `piece-hp` preset, even though the
* modifier itself writes to `HpBonus`.
*
* Omit for modifiers whose `attrName` is the authoritative attribute
* (no preset declares it separately).
*/
readonly baseAttr?: ChessAttrKey;
/** Human-readable display label for UI. */
readonly label: string;
/** Zod schema for the value field — used for validation and UI form generation. */
readonly valueSchema: ZodType<V>;
/** How multiple values from different sources stack (ADR-4). */
readonly stackingRule: "additive" | "union" | "multiplicative" | "priority-wins";
/**
* Apply the modifier's effective value to a piece entity in the session.
* Called once per (pieceId, kind) pair after all sources have been
* collected and the effective value computed via stacking rules.
*
* @param session - The game session to mutate
* @param pieceId - Target piece entity
* @param effectiveValue - The already-stacked value to apply
*/
readonly apply: (session: Session, pieceId: EntityId, effectiveValue: V) => void;
/** Return a human-readable description of this modifier value for UI. */
readonly describe: (value: V) => string;
/**
* Hint for the UI editor about which input widget to render for this modifier.
* Each uiForm maps to a specific React component in the PerTypePanel/PerInstancePanel.
*/
readonly uiForm:
| "number"
| "direction-set"
| "capture-flags"
| "promotion-target"
| "percentage";
}
/** Helper: create a TypeModifier with proper typing. */
export function typeModifier(
kind: ModifierKindId,
pieceType: PieceType,
color: PieceColor | "both",
value: unknown,
): TypeModifier {
return { kind, pieceType, color, value };
}
/** Helper: create an InstanceModifier with proper typing. */
export function instanceModifier(
kind: ModifierKindId,
square: string,
value: unknown,
): InstanceModifier {
return { kind, square, value };
}

View file

@ -0,0 +1,261 @@
import { describe, it, expect } from "vitest";
import { validateProfile } from "./validate.js";
import { CLASSIC_LAYOUT } from "../layouts/classic.js";
import { EMPTY_LAYOUT } from "../layouts/empty.js";
import type { ModifierProfile, TypeModifier } from "./types.js";
import type { StartingLayout } from "../layouts/types.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function emptyProfile(overrides: Partial<ModifierProfile> = {}): ModifierProfile {
return {
id: "test",
name: "Test Profile",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
...overrides,
};
}
/** Minimal layout with one king of each color and nothing else. */
const KINGS_ONLY_LAYOUT: StartingLayout = {
id: "kings-only",
name: "Kings Only",
description: "Two kings for validator tests.",
pieces: [
{ type: "king", color: "white", square: 4 }, // e1
{ type: "king", color: "black", square: 60 }, // e8
],
source: "custom",
};
/** Layout without any white king. */
const NO_WHITE_KING_LAYOUT: StartingLayout = {
...KINGS_ONLY_LAYOUT,
id: "no-white-king",
pieces: [{ type: "king", color: "black", square: 60 }],
};
/** Layout without any black king. */
const NO_BLACK_KING_LAYOUT: StartingLayout = {
...KINGS_ONLY_LAYOUT,
id: "no-black-king",
pieces: [{ type: "king", color: "white", square: 4 }],
};
// ─── valid baseline ───────────────────────────────────────────────────────────
describe("validateProfile — valid baseline", () => {
it("returns valid=true and no errors/warnings for an empty profile on CLASSIC_LAYOUT", () => {
const result = validateProfile(emptyProfile(), CLASSIC_LAYOUT);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
it("returns valid=true for a benign hp-bonus per-type modifier", () => {
const profile = emptyProfile({
perType: [{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
// ─── E_PROFILE_NO_KING ────────────────────────────────────────────────────────
describe("validateProfile — E_PROFILE_NO_KING", () => {
it("errors when layout has no white king", () => {
const result = validateProfile(emptyProfile(), NO_WHITE_KING_LAYOUT);
expect(result.valid).toBe(false);
const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING");
expect(err).toBeDefined();
expect(err?.message).toMatch(/white king/i);
});
it("errors when layout has no black king", () => {
const result = validateProfile(emptyProfile(), NO_BLACK_KING_LAYOUT);
expect(result.valid).toBe(false);
const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING");
expect(err).toBeDefined();
expect(err?.message).toMatch(/black king/i);
});
it("emits two E_PROFILE_NO_KING errors for EMPTY_LAYOUT (no kings of either color)", () => {
const result = validateProfile(emptyProfile(), EMPTY_LAYOUT);
expect(result.valid).toBe(false);
const kingErrors = result.errors.filter((e) => e.code === "E_PROFILE_NO_KING");
expect(kingErrors).toHaveLength(2);
});
});
// ─── E_PROFILE_INVULN_KING ───────────────────────────────────────────────────
describe("validateProfile — E_PROFILE_INVULN_KING", () => {
it("errors when per-type modifier grants all kings CANNOT_BE_CAPTURED", () => {
const profile = emptyProfile({
perType: [
// value 2 = CANNOT_BE_CAPTURED
{ kind: "capture-flags", pieceType: "king", color: "white", value: 2 },
],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.valid).toBe(false);
const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
expect(err).toBeDefined();
expect(err?.message).toMatch(/CANNOT_BE_CAPTURED/);
});
it("errors when per-instance modifier gives a king CANNOT_BE_CAPTURED (white king at e1)", () => {
// White king is at e1 = square 4 in classic layout
const profile = emptyProfile({
perInstance: [{ kind: "capture-flags", square: "e1", value: 2 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.valid).toBe(false);
const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
expect(err).toBeDefined();
expect(err?.square).toBe("e1");
});
it("does NOT error when per-instance CANNOT_BE_CAPTURED targets a non-king (rook at a1)", () => {
// White rook is at a1 = square 0 — not a king, so invuln is allowed
const profile = emptyProfile({
perInstance: [{ kind: "capture-flags", square: "a1", value: 2 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
expect(invulnError).toBeUndefined();
});
it("does NOT error for CAN_CAPTURE_OWN (flag=1) on a king", () => {
// CAN_CAPTURE_OWN (= 1) on a king is unusual but not a deadlock
const profile = emptyProfile({
perType: [{ kind: "capture-flags", pieceType: "king", color: "white", value: 1 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
expect(invulnError).toBeUndefined();
});
it("errors when combined flags include CANNOT_BE_CAPTURED on king (value=3)", () => {
// value 3 = CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED — still has the invuln bit
const profile = emptyProfile({
perType: [{ kind: "capture-flags", pieceType: "king", color: "both", value: 3 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.valid).toBe(false);
expect(result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING")).toBeDefined();
});
});
// ─── E_PROFILE_ORPHAN_INSTANCE ───────────────────────────────────────────────
describe("validateProfile — E_PROFILE_ORPHAN_INSTANCE (warning)", () => {
it("warns when per-instance modifier targets an empty square (d4 is empty in classic)", () => {
// d4 = rank 3, file 3 → square 27 — empty in CLASSIC_LAYOUT
const profile = emptyProfile({
perInstance: [{ kind: "hp-bonus", square: "d4", value: 2 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
// Warning, not error — profile is still valid
expect(result.valid).toBe(true);
const warn = result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE");
expect(warn).toBeDefined();
expect(warn?.square).toBe("d4");
});
it("does NOT warn when per-instance modifier targets an occupied square (e2 pawn)", () => {
// e2 = rank 1, file 4 → square 12 — white pawn in CLASSIC_LAYOUT
const profile = emptyProfile({
perInstance: [{ kind: "hp-bonus", square: "e2", value: 1 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE")).toBeUndefined();
});
it("emits multiple orphan warnings for multiple empty-square entries", () => {
const profile = emptyProfile({
perInstance: [
{ kind: "hp-bonus", square: "d4", value: 1 },
{ kind: "range-bonus", square: "e5", value: 1 },
],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
const orphans = result.warnings.filter((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE");
expect(orphans).toHaveLength(2);
expect(orphans.map((w) => w.square).sort()).toEqual(["d4", "e5"]);
});
});
// ─── E_PROFILE_ATTR_LIMIT ─────────────────────────────────────────────────────
describe("validateProfile — E_PROFILE_ATTR_LIMIT", () => {
it("does NOT error with all 6 current modifier kinds on one piece (6 < 12)", () => {
// A white pawn gets all 6 T1 modifier kinds — well within the 12-kind limit
const perType: TypeModifier[] = [
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 },
{ kind: "range-bonus", pieceType: "pawn", color: "white", value: 1 },
{ kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward"] },
{ kind: "capture-flags", pieceType: "pawn", color: "white", value: 1 },
{ kind: "promotion-override", pieceType: "pawn", color: "white", value: "queen" },
{ kind: "damage-resistance", pieceType: "pawn", color: "white", value: 0.5 },
];
const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT);
expect(result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT")).toBeUndefined();
});
it("errors when a single piece accumulates more than 12 distinct modifier kinds", () => {
// Inject 13 synthetic modifier kinds via type assertion (for test only).
// In practice this requires future modifier kinds beyond the 6 T1 set.
const perType = Array.from({ length: 13 }, (_, i) => ({
kind: `synthetic-kind-${i}` as unknown as TypeModifier["kind"],
pieceType: "pawn" as const,
color: "white" as const,
value: i,
})) satisfies TypeModifier[];
const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT);
expect(result.valid).toBe(false);
const err = result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT");
expect(err).toBeDefined();
expect(err?.message).toMatch(/13/);
});
});
// ─── valid field integrity ────────────────────────────────────────────────────
describe("validateProfile — valid field", () => {
it("valid=false when any error is present", () => {
const result = validateProfile(emptyProfile(), EMPTY_LAYOUT);
expect(result.valid).toBe(false);
});
it("valid=true even when warnings are present", () => {
const profile = emptyProfile({
perInstance: [{ kind: "hp-bonus", square: "d4", value: 1 }],
});
const result = validateProfile(profile, CLASSIC_LAYOUT);
expect(result.valid).toBe(true);
expect(result.warnings.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,197 @@
/**
* Modifier profile legality validator.
*
* Validates a ModifierProfile against a StartingLayout for game-rule legality.
*
* Errors BLOCK profile activation (server rejects the profile; editor hides
* the "Apply" CTA). Warnings are surfaced to the user but don't prevent the
* profile from being applied — the user made a deliberate choice.
*
* ## Error rules
*
* 1. E_PROFILE_NO_KING: Layout must have ≥1 king per color.
* (Profiles cannot compensate for a king-less layout.)
*
* 2. E_PROFILE_INVULN_KING: King cannot have the CANNOT_BE_CAPTURED flag.
* An invulnerable king means the game can never end — instant deadlock.
*
* 3. E_PROFILE_ATTR_LIMIT: A single piece cannot accumulate > 12 distinct
* modifier kinds. The EAV ceiling is 16 attributes per entity; 4 are
* reserved for core piece facts (PieceType, Color, Position, HasMoved),
* leaving 12 slots for modifier attributes.
*
* 4. E_PROFILE_DEADLOCK: Reserved. Requires a session simulation to detect
* mutual-invulnerability loops; deferred to a future task.
*
* ## Warning rules
*
* - E_PROFILE_ORPHAN_INSTANCE: A per-instance modifier references a square
* that has no piece in the layout. The modifier will never apply, but it is
* harmless — the user may have changed the layout after crafting the profile.
*/
import type { ModifierProfile } from "./types.js";
import type { StartingLayout } from "../layouts/types.js";
import { CaptureFlag } from "../schema.js";
import { squareToAlgebraic } from "../coord.js";
// ─── Public types ─────────────────────────────────────────────────────────────
export type ValidationErrorCode =
| "E_PROFILE_NO_KING"
| "E_PROFILE_INVULN_KING"
| "E_PROFILE_DEADLOCK"
| "E_PROFILE_ATTR_LIMIT";
export type ValidationWarningCode = "E_PROFILE_ORPHAN_INSTANCE";
export interface ValidationError {
readonly code: ValidationErrorCode;
readonly message: string;
/** Algebraic square, present when the error is tied to a specific square. */
readonly square?: string;
}
export interface ValidationWarning {
readonly code: ValidationWarningCode;
readonly message: string;
/** Algebraic square the warning refers to (always present for ORPHAN_INSTANCE). */
readonly square: string;
}
export interface ValidationResult {
readonly errors: readonly ValidationError[];
readonly warnings: readonly ValidationWarning[];
/** `true` iff `errors` is empty — profile can be activated. */
readonly valid: boolean;
}
// ─── Validator ────────────────────────────────────────────────────────────────
export function validateProfile(
profile: ModifierProfile,
layout: StartingLayout,
): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// ── Check 1: E_PROFILE_NO_KING ─────────────────────────────────────────────
// Each color must have at least one king in the layout; without one, there
// is no royal piece to checkmate/capture and the game has no terminal state.
for (const color of ["white", "black"] as const) {
const hasKing = layout.pieces.some(
(p) => p.type === "king" && p.color === color,
);
if (!hasKing) {
errors.push({
code: "E_PROFILE_NO_KING",
message: `Layout has no ${color} king — the game cannot reach a terminal condition.`,
});
}
}
// ── Check 2: E_PROFILE_INVULN_KING ────────────────────────────────────────
// A king with CANNOT_BE_CAPTURED (= 2) can never be taken; the game would
// loop indefinitely with no win condition.
const INVULN = CaptureFlag.CANNOT_BE_CAPTURED;
// Per-type: modifier applies to ALL kings of the given color.
for (const tm of profile.perType) {
if (
tm.kind === "capture-flags" &&
tm.pieceType === "king" &&
typeof tm.value === "number" &&
(tm.value & INVULN) !== 0
) {
errors.push({
code: "E_PROFILE_INVULN_KING",
message:
`Per-type modifier grants all ${tm.color} kings CANNOT_BE_CAPTURED ` +
`— the game would have no terminal condition.`,
});
}
}
// Per-instance: modifier applies only if the piece at that square is a king.
for (const im of profile.perInstance) {
if (im.kind !== "capture-flags") continue;
if (typeof im.value !== "number") continue;
if ((im.value & INVULN) === 0) continue;
const piece = layout.pieces.find(
(p) => squareToAlgebraic(p.square) === im.square,
);
if (piece?.type === "king") {
errors.push({
code: "E_PROFILE_INVULN_KING",
message:
`Per-instance modifier grants king at ${im.square} CANNOT_BE_CAPTURED ` +
`— the game would have no terminal condition.`,
square: im.square,
});
}
}
// ── Check 3: E_PROFILE_ORPHAN_INSTANCE (WARNING) ──────────────────────────
// A per-instance entry that targets an empty square will never fire.
// Warning only — the user may have intentionally left the slot as a draft,
// or changed the layout after building the profile.
for (const im of profile.perInstance) {
const piece = layout.pieces.find(
(p) => squareToAlgebraic(p.square) === im.square,
);
if (!piece) {
warnings.push({
code: "E_PROFILE_ORPHAN_INSTANCE",
message:
`Per-instance modifier at "${im.square}" references an empty square — ` +
`the modifier will never apply.`,
square: im.square,
});
}
}
// ── Check 4: E_PROFILE_ATTR_LIMIT ─────────────────────────────────────────
// For each piece in the layout, count the distinct modifier kinds that
// target it (from both perType and perInstance sources). More than 12
// would overflow the EAV attribute ceiling (16 total − 4 core = 12 slots).
for (const piece of layout.pieces) {
const sq = squareToAlgebraic(piece.square);
const kinds = new Set<string>();
for (const tm of profile.perType) {
if (
tm.pieceType === piece.type &&
(tm.color === piece.color || tm.color === "both")
) {
kinds.add(tm.kind);
}
}
for (const im of profile.perInstance) {
if (im.square === sq) {
kinds.add(im.kind);
}
}
if (kinds.size > 12) {
errors.push({
code: "E_PROFILE_ATTR_LIMIT",
message:
`Piece at ${sq} has ${kinds.size} distinct modifier kinds (max 12).`,
square: sq,
});
}
}
// ── Check 5: E_PROFILE_DEADLOCK ────────────────────────────────────────────
// TODO: Detect modifier combinations that create irresolvable game states
// (e.g., both colors have kings with CANNOT_BE_CAPTURED, neither side can
// win). This check requires a session simulation and is deferred; the
// per-type INVULN_KING check above catches the most common case.
return {
errors,
warnings,
valid: errors.length === 0,
};
}

View file

@ -12,12 +12,18 @@
import type {
ClientMessage,
CustomModifierRegisteredPayload,
ErrorPayload,
GameDeltaPayload,
GameEndPayload,
GameMovePayload,
GamePresetsPayload,
GameStatePayload,
ModifierProfileProposalPendingPayload,
ModifierProfileRejectedPayload,
ModifierProfileConsentReceivedPayload,
ModifierProfileQueuedPayload,
ModifierProfileUpdatedPayload,
PresetActivation,
PromotionPiece,
RoomCreatedPayload,
@ -36,6 +42,12 @@ export type GameClientEvent =
| { type: "game.presets"; payload: GamePresetsPayload }
| { type: "room.created"; payload: RoomCreatedPayload }
| { type: "room.joined"; payload: RoomJoinedPayload }
| { type: "modifier-profile.proposal-pending"; payload: ModifierProfileProposalPendingPayload }
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
| { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload }
| { type: "error"; payload: ErrorPayload }
| { type: "connected" }
| { type: "disconnected"; willReconnect: boolean };
@ -49,20 +61,13 @@ type EventOfType<T extends GameClientEventType> = Extract<
type Listener<T extends GameClientEventType> = (event: EventOfType<T>) => void;
// Heterogeneous internal listener map — narrowed via the public `on()` API.
// Using `unknown` avoids `any` while still permitting one map for all types.
type AnyListener = (event: GameClientEvent) => void;
// Shape emitted to listeners of a lifecycle-only event. We accept these two
// shapes when callers invoke `emit()` so the compiler stays honest about the
// discriminated union without resorting to casts.
interface LifecycleConnected {
type: "connected";
}
interface LifecycleDisconnected {
type: "disconnected";
willReconnect: boolean;
}
// Heterogeneous internal listener table. Typing as a mapped type keyed by
// the discriminant preserves the per-event Listener<T> relationship through
// index lookup, so `on`/`off`/`emit` can manipulate listener arrays without
// any casts.
type ListenerTable = {
[T in GameClientEventType]?: Listener<T>[];
};
// ---------------------------------------------------------------------------
// Configuration
@ -132,8 +137,9 @@ export class GameClient {
// `closed` is set when close() is called: it suppresses auto-reconnect.
private closed = false;
// Listeners keyed by event type.
private readonly listeners = new Map<GameClientEventType, AnyListener[]>();
// Listeners keyed by event type. Mapped-type keys preserve the
// per-type Listener<T> relationship; see `ListenerTable`.
private readonly listeners: ListenerTable = {};
constructor(url: string, options: GameClientOptions = {}) {
this.url = url;
@ -165,20 +171,21 @@ export class GameClient {
// -------------------------------------------------------------------------
on<T extends GameClientEventType>(type: T, listener: Listener<T>): void {
const arr = this.listeners.get(type);
// We up-cast to AnyListener here because the map is heterogeneous; the
// public `on` signature guarantees each listener only ever receives its
// own discriminated variant, which we enforce at `emit()` sites.
const cast = listener as unknown as AnyListener;
if (arr) arr.push(cast);
else this.listeners.set(type, [cast]);
// TS can't prove writes to `this.listeners[type]` are safe for a
// generic T (the mapped-type key makes the target an intersection).
// Projecting to a per-call `Record<T, …>` narrows the write site
// safely — this is the only place that widening happens, and it
// preserves the per-type Listener<T> relationship elsewhere.
const table = this.listeners as Record<T, Listener<T>[] | undefined>;
const arr = table[type];
if (arr) arr.push(listener);
else table[type] = [listener];
}
off<T extends GameClientEventType>(type: T, listener: Listener<T>): void {
const arr = this.listeners.get(type);
const arr = this.listeners[type];
if (!arr) return;
const cast = listener as unknown as AnyListener;
const idx = arr.indexOf(cast);
const idx = arr.indexOf(listener);
if (idx >= 0) arr.splice(idx, 1);
}
@ -275,6 +282,30 @@ export class GameClient {
this.send({ type: "room.setPresets", payload });
}
/**
* T3: register a custom modifier descriptor on the current room.
* Server validates structurally, stores in the per-room registry
* (capped at 10 distinct descriptors), and broadcasts
* `custom-modifier.registered` to every connected client. Clients
* (including the sender) mirror the descriptor onto their local
* engine's customModifiers registry via the predicate manager
* subscriber, so subsequent profile applies can resolve the kind.
*/
sendRegisterCustomModifier(
descriptor: import("./types.js").CustomModifierDescriptorWire,
): void {
if (this.code === null) {
// No active room — silently no-op rather than throw, mirroring
// sendMove's behaviour when called pre-connect.
return;
}
const payload: import("./types.js").CustomModifierRegisterPayload = {
roomCode: this.code,
descriptor,
};
this.send({ type: "custom-modifier.register", payload });
}
// -------------------------------------------------------------------------
// Accessors (primarily for tests & reconnect logic)
// -------------------------------------------------------------------------
@ -438,6 +469,24 @@ export class GameClient {
case "room.joined":
this.emit({ type, payload: payload as RoomJoinedPayload });
return;
case "modifier-profile.proposal-pending":
this.emit({ type, payload: payload as ModifierProfileProposalPendingPayload });
return;
case "modifier-profile.rejected":
this.emit({ type, payload: payload as ModifierProfileRejectedPayload });
return;
case "modifier-profile.consent-received":
this.emit({ type, payload: payload as ModifierProfileConsentReceivedPayload });
return;
case "modifier-profile.queued":
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
return;
case "modifier-profile.updated":
this.emit({ type, payload: payload as ModifierProfileUpdatedPayload });
return;
case "custom-modifier.registered":
this.emit({ type, payload: payload as CustomModifierRegisteredPayload });
return;
case "error":
this.emit({ type, payload: payload as ErrorPayload });
return;
@ -448,11 +497,11 @@ export class GameClient {
}
}
private emit(event: GameClientEvent): void;
private emit(event: LifecycleConnected): void;
private emit(event: LifecycleDisconnected): void;
private emit(event: GameClientEvent): void {
const arr = this.listeners.get(event.type);
private emit<T extends GameClientEventType>(event: EventOfType<T>): void {
// Generic over T so `this.listeners[event.type]` resolves to
// `Listener<T>[] | undefined` (not a union), which matches the
// concrete `event: EventOfType<T>` being dispatched. No casts needed.
const arr = this.listeners[event.type];
if (!arr) return;
// Iterate over a copy so listeners that call `off()` mid-dispatch don't
// skip subsequent listeners.

View file

@ -19,7 +19,7 @@ const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
'ws://localhost:7357/ws';
import type { ResolvedLayoutWire } from './types';
import type { ModifierProfileWire, ResolvedLayoutWire } from './types';
interface RoomPayload {
code?: string;
@ -27,6 +27,7 @@ interface RoomPayload {
color?: string;
message?: string;
layout?: ResolvedLayoutWire;
profile?: ModifierProfileWire;
}
interface ServerMsg {
@ -37,13 +38,15 @@ interface ServerMsg {
/**
* The shape resolved by oneShotRoomRequest on success. `layout` is
* optional for wire compat with older servers; new servers always
* populate it.
* populate it. `profile` is populated when the room was created with a
* modifier profile (T19) — absent otherwise.
*/
export interface OneShotRoomResult {
code: string;
token: string;
color: string;
layout?: ResolvedLayoutWire;
profile?: ModifierProfileWire;
}
export function oneShotRoomRequest(
@ -88,6 +91,9 @@ export function oneShotRoomRequest(
if (msg.payload.layout !== undefined) {
result.layout = msg.payload.layout;
}
if (msg.payload.profile !== undefined) {
result.profile = msg.payload.profile;
}
resolve(result);
} else if (msg.type === 'error') {
clearTimeout(timeout);

Some files were not shown because too many files have changed in this diff Show more