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:
parent
748dde5d4c
commit
567480a788
8 changed files with 647 additions and 42 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue