Commit graph

298 commits

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