Commit graph

183 commits

Author SHA1 Message Date
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