Attr-string fields in the Custom Modifier Editor now differentiate
between declare-sites (seed-attribute.attr) and consume-sites
(add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr,
absorb-damage-with-attribute.attr):
- Consume-sites hide the illustrative 'User-defined examples' group
(ArmorPlates/BloodStacks/ManaPool) because those names are fiction
unless something seeds them.
- Consume-sites surface a new 'Seeded in this descriptor' group
populated from seed-attribute primitives elsewhere in the current
tree (including inside trigger children via childPrimitives).
- Badge states: emerald 'seeded' when the typed value matches an
in-tree seed; red 'not seeded' when it's a non-schema name with no
backing seed; amber 'user-defined' only in declare-mode for
off-catalog names.
Declare-mode behaviour is unchanged — inventing ShieldCharges there
still works without warnings.
Surfaces the contents of docs/user/custom-modifiers.md directly inside
the Custom Modifier Editor so authors can compose descriptors without
cross-referencing the guide:
- Per-primitive docs panel in the Parameter Inspector with a longer
behaviour explanation + one or more worked examples (collapsible).
- Palette hover tooltips now show the full long description plus the
first example's headline.
- New 'Templates' header button opens a picker with 5 built-in recipes
(Boosted Pawn, 3-Charge Shield, Aura King, Vampire, Low-HP Fortress).
- AttrCombobox replaces plain text inputs for attr / targetAttr fields.
Grouped, free-form autocomplete over 17 curated suggestions with a
'user-defined' badge for out-of-catalog typed names so ShieldCharges-
style recipes still work.
Primitives gain optional longDescription + examples fields on their
EffectPrimitive descriptor; 15 registrations annotated. Recipe
descriptors pass the existing validator, and new unit tests enforce
doc coverage going forward.
T3 audit gap 2 (CRITICAL). The server's Room kept registered custom
modifier descriptors in a per-room Map but the game.state snapshot
carried no field for them. Impact:
- Client A registers 'custom:shield' → server broadcasts
custom-modifier.registered → A + any currently-connected B see it.
- Client C joins AFTER the registration → receives game.state →
has no knowledge of 'custom:shield'.
- Client C's engine applies a profile with kind='custom:shield' →
registry-dispatch fallback silently no-ops → apparent cosmetic
modifier mismatch between A/B and C.
Symmetric fix across the wire:
- GameStatePayloadSchema (server + client types) gains an optional
customModifiers: CustomModifierDescriptorWire[] field.
- Both emit sites in broadcast.ts (late-joiner path +
reconnect-with-buffered-deltas path) include the room's registered
descriptors.
- PredictionManager.applyFullState mirrors received descriptors
onto the fresh engine's customModifiers registry before handing
control to the UI. Unknown descriptor shapes are accepted as-is
(the wire-shape cast at the single boundary bridges the Zod v3/v4
type split same as the custom-modifier.registered subscriber).
E2E regression guard (Oracle Q4.1 recommendation): new scenario
'late-joiner + reconnect receive registered custom modifiers in
game.state'. Host creates + registers, opponent joins AFTER
registration, asserts opponent's game.state carries the descriptor.
Would have caught the pre-fix behaviour as a test failure instead of
a manual audit find.
1393 unit + 19/19 custom-modifiers e2e green.
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.
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.