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).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 13:15:15 -06:00
commit 567480a788
No known key found for this signature in database
8 changed files with 647 additions and 42 deletions

View file

@ -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);
}

View file

@ -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);

View file

@ -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;

View file

@ -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 = [];

View file

@ -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;
}

View file

@ -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 () => {

View file

@ -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: "",
}),
);

View file

@ -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<typeof GameStatePayloadSchema>;