diff --git a/packages/chess/e2e/modifier-profiles.spec.ts b/packages/chess/e2e/modifier-profiles.spec.ts index 5e11f08..1643115 100644 --- a/packages/chess/e2e/modifier-profiles.spec.ts +++ b/packages/chess/e2e/modifier-profiles.spec.ts @@ -7,7 +7,7 @@ * * Runs against the local dev server (no WS server needed — solo play only). */ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; test.describe('Modifier Profiles', () => { test.beforeEach(async ({ page }) => { @@ -975,50 +975,586 @@ test.describe('Modifier Profiles — T2 polish', () => { // T2-P6 — Source-chain breakdown in the pinned panel. // - // `ModifierPinnedPanel` collects the modifier SOURCE via - // `getModifierSource()` but doesn't currently render it — see - // packages/chess/src/ui/ModifierPinnedPanel.tsx: the `source` value - // is computed, placed on the row object, and then dropped on the - // floor (only `label` and `description` are emitted to the DOM). - // Until the panel wires the source into a visible element we can't - // assert on it from the outside. - test.fixme( - 'source chain (per-instance vs per-type) surfaced in pinned panel', - async () => { - // Depends on ModifierPinnedPanel rendering row.source in a - // test-discoverable element (e.g. `source-badge`). See - // docs/adr/modifier-profiles.md §T2 enhanced source chain. - }, - ); + // `ModifierPinnedPanel` renders a `source-badge-{kindId}` element per + // modifier row. We seed a profile that mixes per-type (HP +1 on all + // pawns) and per-instance (Range +1 on the b1 knight) modifiers, + // create a multiplayer room with that profile, then click each piece + // in turn to assert its badge text matches the profile source. + // + // Falls back to test.skip when no WS server is running (mirrors P5). + test('source chain (per-instance vs per-type) surfaced in pinned panel', async ({ + page, + }) => { + // Close the editor + drawer left open by beforeEach so the lobby is + // navigable cleanly. + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + + const entry = { + id: 't2-p6-source-chain', + name: 'Source-Chain Fixture', + profile: { + id: 't2-p6-source-chain', + name: 'Source-Chain Fixture', + description: 'Mixes per-type + per-instance modifiers for T2-P6.', + layoutId: 'classic', + perType: [ + // All pawns gain +1 HP — per-type source. + { kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 }, + ], + perInstance: [ + // The b1 knight specifically gains +1 range — per-instance source. + { kind: 'range-bonus', square: 'b1', value: 1 }, + ], + version: 1, + source: 'custom', + }, + starred: false, + updatedAt: Date.now(), + }; + + await page.goto('/'); + await page.evaluate( + ({ key, e }) => localStorage.setItem(key, JSON.stringify([e])), + { key: LIBRARY_KEY, e: entry }, + ); + await page.reload(); + + const picker = page.getByTestId('profile-picker'); + await expect(picker).toBeVisible(); + await picker.selectOption('t2-p6-source-chain'); + + await page.click('[data-action="create-room"]'); + await Promise.race([ + page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }), + page + .getByTestId('lobby-error') + .waitFor({ state: 'visible', timeout: 5000 }), + ]); + + const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url()); + test.skip(!isOnGamePage, 'No WS server — source-chain assertion skipped'); + + // Click the b1 knight (per-instance: range-bonus) and verify badges. + await page.click('[data-square="b1"]'); + const panel = page.getByTestId('modifier-pinned-panel'); + await expect(panel).toBeVisible({ timeout: 3000 }); + + // Range-bonus row should show "per-instance: b1". + const rangeBadge = page.getByTestId('source-badge-range-bonus'); + await expect(rangeBadge).toBeVisible(); + await expect(rangeBadge).toContainText(/per-instance:\s*b1/i); + + // Now pin a pawn (per-type source). Close current pin first by + // clicking the same piece again (toggle), or click another piece. + await page.click('[data-square="a2"]'); + // Wait for panel to repaint with the pawn's data. + await expect(panel).toContainText(/pawn/i, { timeout: 2000 }); + + const hpBadge = page.getByTestId('source-badge-hp-bonus'); + await expect(hpBadge).toBeVisible(); + await expect(hpBadge).toContainText(/per-type:\s*all\s+(white|both)\s+pawns/i); + }); // T2-P7 — Multiplayer proposal approve → both clients see // `modifier-profile.updated` after the next move. // - // Requires two BrowserContexts to simulate the two WS clients. - // This spec currently runs with a single `page` fixture; wiring a - // second context + sharing the room code across the two is a - // non-trivial harness addition. Unit-level coverage for the - // protocol state machine lives at - // `packages/server/src/ws.modifier-profile-consent.test.ts` - // (T3 integration tests), which exercises the same server - // endpoints that this e2e would drive. - test.fixme( - 'multiplayer: propose → approve → both clients observe updated', - async () => { - // Implement with `test('...', async ({ browser }) => { ... })` - // spawning two contexts, one creating the room and one joining. - }, - ); + // Spawns two browser contexts via the `browser` fixture (mirroring + // packages/chess/e2e/multiplayer.spec.ts). Host (white) creates a + // room with a baseline profile, opponent (black) joins via raw WS, + // both navigate into MultiplayerGameView. Host then sends + // `modifier-profile.propose` from inside its page context; opponent + // approves; host plays a move (drains the queue per T2-ADR-1); both + // clients observe `modifier-profile.updated`. + // + // Skipped when the WS server isn't running. + test('multiplayer: propose → approve → both clients observe updated', async ({ + browser, + }) => { + // Skip if no WS server (consistent with multiplayer.spec.ts). + let wsUp = false; + try { + const res = await fetch('http://localhost:7357/healthz'); + wsUp = res.ok; + } catch { + wsUp = false; + } + test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped'); + + // We exercise the propose/consent flow purely at the WS-protocol + // level using two raw WebSockets per player (one connection per + // player slot — the server's reconnect-by-token semantics would + // otherwise treat a second socket as a take-over). We never open + // the React UI for these tests; the protocol is what we're verifying, + // not the UI dialog (which has its own coverage in T9 unit tests). + const ctxHost = await browser.newContext(); + const ctxOpp = await browser.newContext(); + const pageHost = await ctxHost.newPage(); + const pageOpp = await ctxOpp.newPage(); + + try { + // Host creates room (no initial profile — baseline classic game). + await pageHost.goto('/'); + await pageHost.waitForSelector('[data-testid="page-home"]'); + const roomHost = await wsCreateRoomNoProfile(pageHost); + + // Opponent joins the same room from a separate context. + await pageOpp.goto('/'); + await pageOpp.waitForSelector('[data-testid="page-home"]'); + const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code); + + // Define the candidate profile. + const candidateProfile = { + id: 't2-p7-propose', + name: 'Propose Fixture', + description: 'Used by T2-P7 e2e.', + perType: [ + { kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 }, + ], + perInstance: [], + version: 1, + source: 'custom', + }; + + // Run host and opponent socket flows CONCURRENTLY via Promise.all + // so opponent is connected and listening BEFORE the host's propose + // arrives at the server. Otherwise proposal-pending would be sent + // to nobody and lost. + // + // Host script: connect (reconnect via token), wait briefly so the + // opponent has time to connect too, send propose, then wait for + // game.state and send a move to drain the pending queue, then + // resolve when modifier-profile.updated arrives. + // + // Opponent script: connect (reconnect via token), wait for + // proposal-pending, send consent=approve, then resolve when + // modifier-profile.updated arrives (proves the swap applied). + const [hostFlow, oppFlow] = await Promise.all([ + pageHost.evaluate( + async ({ roomCode, token, profile }) => { + return new Promise<{ + proposed: boolean; + sawUpdated: boolean; + types: string[]; + }>((resolve) => { + const types: string[] = []; + let proposed = false; + const ws = new WebSocket('ws://localhost:7357/ws'); + const timer = setTimeout( + () => resolve({ proposed, sawUpdated: false, types }), + 10000, + ); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + token, + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { + type: string; + payload?: { code?: string; message?: string }; + }; + types.push( + msg.type === 'error' + ? `error(${msg.payload?.code ?? '?'}:${msg.payload?.message ?? '?'})` + : msg.type, + ); + if (msg.type === 'room.joined' || msg.type === 'game.state') { + // Wait 500ms for opponent to connect then propose ONCE. + if (!proposed) { + proposed = true; + setTimeout(() => { + ws.send( + JSON.stringify({ + v: 1, + seq: 2, + ts: Date.now(), + type: 'modifier-profile.propose', + payload: { roomCode, candidate: profile, version: 0 }, + }), + ); + }, 500); + } + } else if (msg.type === 'modifier-profile.consent-received') { + // Send a move 500ms later to drain the pending queue. + setTimeout(() => { + ws.send( + JSON.stringify({ + v: 1, + seq: 3, + ts: Date.now(), + type: 'game.move', + payload: { from: 'a2', to: 'a3' }, + }), + ); + }, 500); + } else if (msg.type === 'modifier-profile.updated') { + clearTimeout(timer); + ws.close(); + resolve({ proposed, sawUpdated: true, types }); + } + }; + ws.onerror = () => { + clearTimeout(timer); + resolve({ proposed, sawUpdated: false, types }); + }; + }); + }, + { roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile }, + ), + pageOpp.evaluate( + async ({ roomCode, token }) => { + return new Promise<{ + receivedPending: boolean; + sawUpdated: boolean; + types: string[]; + }>((resolve) => { + const types: string[] = []; + let receivedPending = false; + const ws = new WebSocket('ws://localhost:7357/ws'); + const timer = setTimeout( + () => resolve({ receivedPending, sawUpdated: false, types }), + 10000, + ); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + token, + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { type: string }; + types.push(msg.type); + if (msg.type === 'modifier-profile.proposal-pending') { + receivedPending = true; + ws.send( + JSON.stringify({ + v: 1, + seq: 2, + ts: Date.now(), + type: 'modifier-profile.consent', + payload: { roomCode, decision: 'approve' }, + }), + ); + } else if (msg.type === 'modifier-profile.updated') { + clearTimeout(timer); + ws.close(); + resolve({ receivedPending, sawUpdated: true, types }); + } + }; + ws.onerror = () => { + clearTimeout(timer); + resolve({ receivedPending, sawUpdated: false, types }); + }; + }); + }, + { roomCode: roomHost.code, token: roomOpp.token }, + ), + ]); + + expect(hostFlow.proposed).toBe(true); + expect(oppFlow.receivedPending).toBe(true); + // Both clients must observe the updated broadcast. + if (!hostFlow.sawUpdated || !oppFlow.sawUpdated) { + // Surface the actual message stream to diagnose. + throw new Error( + `updated not seen | hostTypes: ${hostFlow.types.join(',')} | oppTypes: ${oppFlow.types.join(',')}`, + ); + } + expect(hostFlow.sawUpdated).toBe(true); + expect(oppFlow.sawUpdated).toBe(true); + } finally { + await ctxHost.close(); + await ctxOpp.close(); + } + }); // T2-P8 — Multiplayer proposal reject → no `modifier-profile.updated`. // - // Same multiplayer-harness gap as T2-P7. Covered at the server - // integration level in ws.modifier-profile-consent.test.ts - // ("reject → both get modifier-profile.rejected reason=rejected"). - test.fixme( - 'multiplayer: propose → reject → no updated broadcast', - async () => { - // See T2-P7 fixme note. - }, - ); + // Same harness as P7, but opponent sends consent=reject. Host's profile + // never enters the queue; no indicators appear after the next move. + test('multiplayer: propose → reject → no updated broadcast', async ({ + browser, + }) => { + let wsUp = false; + try { + const res = await fetch('http://localhost:7357/healthz'); + wsUp = res.ok; + } catch { + wsUp = false; + } + test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped'); + + // Same WS-protocol-level approach as P7 — no React UI, just two + // raw connections per player. + const ctxHost = await browser.newContext(); + const ctxOpp = await browser.newContext(); + const pageHost = await ctxHost.newPage(); + const pageOpp = await ctxOpp.newPage(); + + try { + await pageHost.goto('/'); + await pageHost.waitForSelector('[data-testid="page-home"]'); + const roomHost = await wsCreateRoomNoProfile(pageHost); + + await pageOpp.goto('/'); + await pageOpp.waitForSelector('[data-testid="page-home"]'); + const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code); + + const candidateProfile = { + id: 't2-p8-reject', + name: 'Reject Fixture', + description: 'Used by T2-P8 e2e.', + perType: [ + { kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 }, + ], + perInstance: [], + version: 1, + source: 'custom', + }; + + // Same parallel pattern as P7. Host proposes after opponent is + // listening, then plays a move. Opponent rejects on receipt. + // Both flows assert that `modifier-profile.updated` NEVER appears + // (the rejected proposal must never reach the apply stage). + const [hostFlow, oppFlow] = await Promise.all([ + pageHost.evaluate( + async ({ roomCode, token, profile }) => { + return new Promise<{ proposed: boolean; types: string[] }>((resolve) => { + const types: string[] = []; + let proposed = false; + const ws = new WebSocket('ws://localhost:7357/ws'); + const settle = setTimeout(() => { + ws.close(); + resolve({ proposed, types }); + }, 6000); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + token, + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { type: string }; + types.push(msg.type); + if ((msg.type === 'room.joined' || msg.type === 'game.state') && !proposed) { + proposed = true; + setTimeout(() => { + ws.send( + JSON.stringify({ + v: 1, + seq: 2, + ts: Date.now(), + type: 'modifier-profile.propose', + payload: { roomCode, candidate: profile, version: 0 }, + }), + ); + }, 500); + } else if (msg.type === 'modifier-profile.rejected') { + // After opponent rejects, send a move and wait briefly + // to confirm no `updated` broadcast follows. + setTimeout(() => { + ws.send( + JSON.stringify({ + v: 1, + seq: 3, + ts: Date.now(), + type: 'game.move', + payload: { from: 'a2', to: 'a3' }, + }), + ); + }, 200); + setTimeout(() => { + clearTimeout(settle); + ws.close(); + resolve({ proposed, types }); + }, 2500); + } + }; + ws.onerror = () => { + clearTimeout(settle); + resolve({ proposed, types }); + }; + }); + }, + { roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile }, + ), + pageOpp.evaluate( + async ({ roomCode, token }) => { + return new Promise<{ receivedPending: boolean; types: string[] }>((resolve) => { + const types: string[] = []; + let receivedPending = false; + const ws = new WebSocket('ws://localhost:7357/ws'); + const settle = setTimeout(() => { + ws.close(); + resolve({ receivedPending, types }); + }, 6000); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + token, + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { type: string }; + types.push(msg.type); + if (msg.type === 'modifier-profile.proposal-pending') { + receivedPending = true; + ws.send( + JSON.stringify({ + v: 1, + seq: 2, + ts: Date.now(), + type: 'modifier-profile.consent', + payload: { roomCode, decision: 'reject' }, + }), + ); + } else if (msg.type === 'modifier-profile.rejected') { + // Wait briefly to confirm no `updated` follows, then resolve. + setTimeout(() => { + clearTimeout(settle); + ws.close(); + resolve({ receivedPending, types }); + }, 2500); + } + }; + ws.onerror = () => { + clearTimeout(settle); + resolve({ receivedPending, types }); + }; + }); + }, + { roomCode: roomHost.code, token: roomOpp.token }, + ), + ]); + + expect(hostFlow.proposed).toBe(true); + expect(oppFlow.receivedPending).toBe(true); + // The rejected profile must NEVER reach the apply stage. + expect(hostFlow.types).not.toContain('modifier-profile.updated'); + expect(oppFlow.types).not.toContain('modifier-profile.updated'); + } finally { + await ctxHost.close(); + await ctxOpp.close(); + } + }); }); + +// --------------------------------------------------------------------------- +// Multiplayer helpers (used by T2-P7, T2-P8). Mirror the patterns in +// packages/chess/e2e/multiplayer.spec.ts but inlined here so this spec +// stays self-contained. +// --------------------------------------------------------------------------- + +/** Create a room via raw WS — no profile. Returns {code, token, color}. */ +async function wsCreateRoomNoProfile( + p: Page, +): Promise<{ code: string; token: string; color: string }> { + return p.evaluate(async () => { + return new Promise<{ code: string; token: string; color: string }>( + (resolve, reject) => { + const ws = new WebSocket('ws://localhost:7357/ws'); + const timer = setTimeout( + () => reject(new Error('wsCreateRoom: timeout')), + 5000, + ); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.create', + payload: {}, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { + type: string; + payload: { code: string; token: string; color: string }; + }; + if (msg.type === 'room.created') { + clearTimeout(timer); + ws.close(); + resolve(msg.payload); + } + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error('wsCreateRoom: error')); + }; + }, + ); + }); +} + +/** Join a room via raw WS. Returns {code, token, color}. */ +async function wsJoinRoomShared( + p: Page, + code: string, +): Promise<{ code: string; token: string; color: string }> { + return p.evaluate(async (roomCode: string) => { + return new Promise<{ code: string; token: string; color: string }>( + (resolve, reject) => { + const ws = new WebSocket('ws://localhost:7357/ws'); + const timer = setTimeout( + () => reject(new Error('wsJoinRoom: timeout')), + 5000, + ); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { + type: string; + payload: { code: string; token: string; color: string }; + }; + if (msg.type === 'room.joined') { + clearTimeout(timer); + ws.close(); + resolve(msg.payload); + } + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error('wsJoinRoom: error')); + }; + }, + ); + }, code); +} diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 397a5a0..0f2ef45 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -342,8 +342,13 @@ export class ChessEngine { /** * The currently active ModifierProfile, if any. * Profiles attach rule modifiers to pieces by type or layout slot. + * + * Mutable internally via `setActiveProfile()` so the client can sync + * with the server's `game.state` snapshots (the server is authoritative + * for which profile is active in a multiplayer room). Callers MUST NOT + * write directly — use `setActiveProfile()` to keep invariants intact. */ - public readonly activeProfile: ModifierProfile | null; + public activeProfile: ModifierProfile | null; /** * Chronological log of every successful applyMove. Callers consume @@ -584,6 +589,22 @@ export class ChessEngine { * down state cleanly before the incoming preset reads the board. * Ordering within each phase follows the input list's natural order. */ + /** + * Replace the active modifier profile (used by clients syncing with + * server `game.state` snapshots in multiplayer). The session facts + * carrying modifier values come from the server via `loadFacts`; this + * setter just keeps the engine's metadata view of the profile in sync + * so source-chain attribution and other profile-aware reads work. + * + * Does NOT re-seed facts or run reconcile — the server is authoritative + * for the actual modifier state. Callers should only invoke this from + * server-event handlers (game.state replay) or from a fresh local game + * setup, never as a way to mutate gameplay. + */ + setActiveProfile(profile: ModifierProfile | null): void { + this.activeProfile = profile; + } + setActivePresets(requests: readonly ActivationRequest[]): void { const oldIds = new Set(this.activePresets.list().map((e) => e.id)); this.activePresets.replaceAll(requests); diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index 59707bc..13ae45b 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -22,6 +22,7 @@ import type { ModifierProfileRejectedPayload, ModifierProfileConsentReceivedPayload, ModifierProfileQueuedPayload, + ModifierProfileUpdatedPayload, PresetActivation, PromotionPiece, RoomCreatedPayload, @@ -44,6 +45,7 @@ export type GameClientEvent = | { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload } | { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload } | { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload } + | { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload } | { type: "error"; payload: ErrorPayload } | { type: "connected" } | { type: "disconnected"; willReconnect: boolean }; @@ -458,6 +460,9 @@ export class GameClient { case "modifier-profile.queued": this.emit({ type, payload: payload as ModifierProfileQueuedPayload }); return; + case "modifier-profile.updated": + this.emit({ type, payload: payload as ModifierProfileUpdatedPayload }); + return; case "error": this.emit({ type, payload: payload as ErrorPayload }); return; diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index 66bc95a..b577378 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -115,6 +115,16 @@ export class PredictionManager { this.client.on("game.state", (e) => this.applyFullState(e.payload)); this.client.on("game.delta", (e) => this.reconcile(e.payload)); this.client.on("game.presets", (e) => this.applyPresets(e.payload)); + // T2: when a hot-swap fires, update the engine's profile metadata so + // source-chain attribution stays accurate. Modifier facts arrive + // separately via the next game.state / game.delta. + this.client.on("modifier-profile.updated", (e) => { + this.baseEngine.setActiveProfile(e.payload.profile); + if (this.predictedEngine) { + this.predictedEngine.setActiveProfile(e.payload.profile); + } + this.onStateChange(this.baseEngine); + }); this.client.on("error", (e) => { // Fatal errors tear down the session; the app restarts from a fresh // `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …) @@ -180,6 +190,14 @@ export class PredictionManager { next.activePresets.clear(); } } + // Sync the active modifier profile (T2). Optional on the wire so + // older servers / vanilla rooms still validate. The facts carrying + // the actual modifier values are already in `state.facts` (loaded + // above); this setter just gives the engine the profile metadata + // for source-chain attribution. + if (state.profile) { + next.setActiveProfile(state.profile); + } this.baseEngine = next; this.predictedEngine = null; this.pendingMoves = []; diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index ebfb1a1..430c222 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -136,6 +136,11 @@ export interface GameStatePayload { /** Optional for wire compat with older servers. Newer ones always * emit this field; PredictionManager treats missing as empty. */ activations?: PresetActivation[]; + /** Active modifier profile for the room (T2 — optional for older + * servers / vanilla rooms). Clients that receive this populate + * `engine.activeProfile` so source-chain attribution and other + * profile-aware UI works on snapshot replay. */ + profile?: ModifierProfileWire; fen: string; } diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index 8eb8ed5..f36714d 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -223,7 +223,18 @@ export function Lobby({ chessState }: LobbyProps = {}) { // the selected layout so the board shows the chosen starting // position the instant the user lands on /game. clearAllAutoSaves(); - chessState?.loadEngine(new ChessEngine({ layout: selectedLayout })); + // Pass the selected profile (if any) so the local engine seeds + // modifier facts and tracks `activeProfile`. Without this, the + // server reconciles the facts via game.state snapshots but the + // client's `engine.activeProfile` stays null, breaking source-chain + // attribution and any other UI that needs to know the active profile. + chessState?.loadEngine( + new ChessEngine( + selectedProfile !== null + ? { layout: selectedLayout, profile: selectedProfile } + : { layout: selectedLayout }, + ), + ); }; const handleCreate = async () => { diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 3b676ff..0a5665a 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -487,6 +487,7 @@ function handleRoomJoin( .error("room.join: missing session for existing room"); return; } + const activeProfile = session.getProfile(); broadcastToRoom( payload.code, envelope("game.state", { @@ -496,6 +497,7 @@ function handleRoomJoin( moveHistory: [], activeRules: [...result.activeRules], activations: session.getPresetActivations(), + ...(activeProfile !== undefined ? { profile: activeProfile } : {}), // fen is a UI convenience for v1; we haven't wired FEN generation // on the server yet, so we send an empty string. Clients that need // FEN can derive it from `facts`. @@ -573,6 +575,7 @@ function handleReconnect( // Snapshot: authoritative state for the returning client. The client // discards its local fact store and rebuilds from this frame. + const reconnectActiveProfile = session.getProfile(); sendTo( ws, envelope("game.state", { @@ -586,6 +589,7 @@ function handleReconnect( moveHistory: [], activeRules: [...room.rulesetIds], activations: session.getPresetActivations(), + ...(reconnectActiveProfile !== undefined ? { profile: reconnectActiveProfile } : {}), fen: "", }), ); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index a849d00..7f5dfc2 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -545,6 +545,11 @@ export const GameStatePayloadSchema = z.object({ /** Full preset activation set authoritative on the server. Optional * on the wire so older servers don't break the schema check. */ activations: z.array(PresetActivationSchema).optional(), + /** Active modifier profile for the room (T2). Optional — absent for + * vanilla rooms or when the server hasn't been told about a profile. + * Clients use this to populate `engine.activeProfile` so source-chain + * attribution and other profile-aware UI works on snapshot replay. */ + profile: ModifierProfileSchema.optional(), fen: z.string(), }); export type GameStatePayload = z.infer;