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