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).
|
* 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.describe('Modifier Profiles', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|
@ -975,50 +975,586 @@ test.describe('Modifier Profiles — T2 polish', () => {
|
||||||
|
|
||||||
// T2-P6 — Source-chain breakdown in the pinned panel.
|
// T2-P6 — Source-chain breakdown in the pinned panel.
|
||||||
//
|
//
|
||||||
// `ModifierPinnedPanel` collects the modifier SOURCE via
|
// `ModifierPinnedPanel` renders a `source-badge-{kindId}` element per
|
||||||
// `getModifierSource()` but doesn't currently render it — see
|
// modifier row. We seed a profile that mixes per-type (HP +1 on all
|
||||||
// packages/chess/src/ui/ModifierPinnedPanel.tsx: the `source` value
|
// pawns) and per-instance (Range +1 on the b1 knight) modifiers,
|
||||||
// is computed, placed on the row object, and then dropped on the
|
// create a multiplayer room with that profile, then click each piece
|
||||||
// floor (only `label` and `description` are emitted to the DOM).
|
// in turn to assert its badge text matches the profile source.
|
||||||
// Until the panel wires the source into a visible element we can't
|
//
|
||||||
// assert on it from the outside.
|
// Falls back to test.skip when no WS server is running (mirrors P5).
|
||||||
test.fixme(
|
test('source chain (per-instance vs per-type) surfaced in pinned panel', async ({
|
||||||
'source chain (per-instance vs per-type) surfaced in pinned panel',
|
page,
|
||||||
async () => {
|
}) => {
|
||||||
// Depends on ModifierPinnedPanel rendering row.source in a
|
// Close the editor + drawer left open by beforeEach so the lobby is
|
||||||
// test-discoverable element (e.g. `source-badge`). See
|
// navigable cleanly.
|
||||||
// docs/adr/modifier-profiles.md §T2 enhanced source chain.
|
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
|
// T2-P7 — Multiplayer proposal approve → both clients see
|
||||||
// `modifier-profile.updated` after the next move.
|
// `modifier-profile.updated` after the next move.
|
||||||
//
|
//
|
||||||
// Requires two BrowserContexts to simulate the two WS clients.
|
// Spawns two browser contexts via the `browser` fixture (mirroring
|
||||||
// This spec currently runs with a single `page` fixture; wiring a
|
// packages/chess/e2e/multiplayer.spec.ts). Host (white) creates a
|
||||||
// second context + sharing the room code across the two is a
|
// room with a baseline profile, opponent (black) joins via raw WS,
|
||||||
// non-trivial harness addition. Unit-level coverage for the
|
// both navigate into MultiplayerGameView. Host then sends
|
||||||
// protocol state machine lives at
|
// `modifier-profile.propose` from inside its page context; opponent
|
||||||
// `packages/server/src/ws.modifier-profile-consent.test.ts`
|
// approves; host plays a move (drains the queue per T2-ADR-1); both
|
||||||
// (T3 integration tests), which exercises the same server
|
// clients observe `modifier-profile.updated`.
|
||||||
// endpoints that this e2e would drive.
|
//
|
||||||
test.fixme(
|
// Skipped when the WS server isn't running.
|
||||||
'multiplayer: propose → approve → both clients observe updated',
|
test('multiplayer: propose → approve → both clients observe updated', async ({
|
||||||
async () => {
|
browser,
|
||||||
// Implement with `test('...', async ({ browser }) => { ... })`
|
}) => {
|
||||||
// spawning two contexts, one creating the room and one joining.
|
// 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`.
|
// T2-P8 — Multiplayer proposal reject → no `modifier-profile.updated`.
|
||||||
//
|
//
|
||||||
// Same multiplayer-harness gap as T2-P7. Covered at the server
|
// Same harness as P7, but opponent sends consent=reject. Host's profile
|
||||||
// integration level in ws.modifier-profile-consent.test.ts
|
// never enters the queue; no indicators appear after the next move.
|
||||||
// ("reject → both get modifier-profile.rejected reason=rejected").
|
test('multiplayer: propose → reject → no updated broadcast', async ({
|
||||||
test.fixme(
|
browser,
|
||||||
'multiplayer: propose → reject → no updated broadcast',
|
}) => {
|
||||||
async () => {
|
let wsUp = false;
|
||||||
// See T2-P7 fixme note.
|
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.
|
* The currently active ModifierProfile, if any.
|
||||||
* Profiles attach rule modifiers to pieces by type or layout slot.
|
* 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
|
* 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.
|
* down state cleanly before the incoming preset reads the board.
|
||||||
* Ordering within each phase follows the input list's natural order.
|
* 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 {
|
setActivePresets(requests: readonly ActivationRequest[]): void {
|
||||||
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
||||||
this.activePresets.replaceAll(requests);
|
this.activePresets.replaceAll(requests);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
ModifierProfileRejectedPayload,
|
ModifierProfileRejectedPayload,
|
||||||
ModifierProfileConsentReceivedPayload,
|
ModifierProfileConsentReceivedPayload,
|
||||||
ModifierProfileQueuedPayload,
|
ModifierProfileQueuedPayload,
|
||||||
|
ModifierProfileUpdatedPayload,
|
||||||
PresetActivation,
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
|
|
@ -44,6 +45,7 @@ export type GameClientEvent =
|
||||||
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
|
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
|
||||||
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
|
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
|
||||||
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
||||||
|
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
|
||||||
| { type: "error"; payload: ErrorPayload }
|
| { type: "error"; payload: ErrorPayload }
|
||||||
| { type: "connected" }
|
| { type: "connected" }
|
||||||
| { type: "disconnected"; willReconnect: boolean };
|
| { type: "disconnected"; willReconnect: boolean };
|
||||||
|
|
@ -458,6 +460,9 @@ export class GameClient {
|
||||||
case "modifier-profile.queued":
|
case "modifier-profile.queued":
|
||||||
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
|
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
|
||||||
return;
|
return;
|
||||||
|
case "modifier-profile.updated":
|
||||||
|
this.emit({ type, payload: payload as ModifierProfileUpdatedPayload });
|
||||||
|
return;
|
||||||
case "error":
|
case "error":
|
||||||
this.emit({ type, payload: payload as ErrorPayload });
|
this.emit({ type, payload: payload as ErrorPayload });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@ export class PredictionManager {
|
||||||
this.client.on("game.state", (e) => this.applyFullState(e.payload));
|
this.client.on("game.state", (e) => this.applyFullState(e.payload));
|
||||||
this.client.on("game.delta", (e) => this.reconcile(e.payload));
|
this.client.on("game.delta", (e) => this.reconcile(e.payload));
|
||||||
this.client.on("game.presets", (e) => this.applyPresets(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) => {
|
this.client.on("error", (e) => {
|
||||||
// Fatal errors tear down the session; the app restarts from a fresh
|
// Fatal errors tear down the session; the app restarts from a fresh
|
||||||
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
|
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
|
||||||
|
|
@ -180,6 +190,14 @@ export class PredictionManager {
|
||||||
next.activePresets.clear();
|
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.baseEngine = next;
|
||||||
this.predictedEngine = null;
|
this.predictedEngine = null;
|
||||||
this.pendingMoves = [];
|
this.pendingMoves = [];
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,11 @@ export interface GameStatePayload {
|
||||||
/** Optional for wire compat with older servers. Newer ones always
|
/** Optional for wire compat with older servers. Newer ones always
|
||||||
* emit this field; PredictionManager treats missing as empty. */
|
* emit this field; PredictionManager treats missing as empty. */
|
||||||
activations?: PresetActivation[];
|
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;
|
fen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
// the selected layout so the board shows the chosen starting
|
// the selected layout so the board shows the chosen starting
|
||||||
// position the instant the user lands on /game.
|
// position the instant the user lands on /game.
|
||||||
clearAllAutoSaves();
|
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 () => {
|
const handleCreate = async () => {
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ function handleRoomJoin(
|
||||||
.error("room.join: missing session for existing room");
|
.error("room.join: missing session for existing room");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const activeProfile = session.getProfile();
|
||||||
broadcastToRoom(
|
broadcastToRoom(
|
||||||
payload.code,
|
payload.code,
|
||||||
envelope("game.state", {
|
envelope("game.state", {
|
||||||
|
|
@ -496,6 +497,7 @@ function handleRoomJoin(
|
||||||
moveHistory: [],
|
moveHistory: [],
|
||||||
activeRules: [...result.activeRules],
|
activeRules: [...result.activeRules],
|
||||||
activations: session.getPresetActivations(),
|
activations: session.getPresetActivations(),
|
||||||
|
...(activeProfile !== undefined ? { profile: activeProfile } : {}),
|
||||||
// fen is a UI convenience for v1; we haven't wired FEN generation
|
// 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
|
// on the server yet, so we send an empty string. Clients that need
|
||||||
// FEN can derive it from `facts`.
|
// FEN can derive it from `facts`.
|
||||||
|
|
@ -573,6 +575,7 @@ function handleReconnect(
|
||||||
|
|
||||||
// Snapshot: authoritative state for the returning client. The client
|
// Snapshot: authoritative state for the returning client. The client
|
||||||
// discards its local fact store and rebuilds from this frame.
|
// discards its local fact store and rebuilds from this frame.
|
||||||
|
const reconnectActiveProfile = session.getProfile();
|
||||||
sendTo(
|
sendTo(
|
||||||
ws,
|
ws,
|
||||||
envelope("game.state", {
|
envelope("game.state", {
|
||||||
|
|
@ -586,6 +589,7 @@ function handleReconnect(
|
||||||
moveHistory: [],
|
moveHistory: [],
|
||||||
activeRules: [...room.rulesetIds],
|
activeRules: [...room.rulesetIds],
|
||||||
activations: session.getPresetActivations(),
|
activations: session.getPresetActivations(),
|
||||||
|
...(reconnectActiveProfile !== undefined ? { profile: reconnectActiveProfile } : {}),
|
||||||
fen: "",
|
fen: "",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -545,6 +545,11 @@ export const GameStatePayloadSchema = z.object({
|
||||||
/** Full preset activation set authoritative on the server. Optional
|
/** Full preset activation set authoritative on the server. Optional
|
||||||
* on the wire so older servers don't break the schema check. */
|
* on the wire so older servers don't break the schema check. */
|
||||||
activations: z.array(PresetActivationSchema).optional(),
|
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(),
|
fen: z.string(),
|
||||||
});
|
});
|
||||||
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue