diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index 0e2775e8..415d16a1 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -939,6 +939,8 @@ Debug categories are toggled in the Options menu and forwarded to the game worke `useWorkerBridge` also mirrors the local player's authoritative `renderState.playerResources` snapshot into the main-thread Zustand store on each worker update, so the HUD and command-cost checks stay in sync with worker-side gathering, refunds, supply, and spend events. +When worker mode is active in multiplayer, `useWorkerBridge` creates the main-thread `Game` in a UI-only proxy configuration. `WorkerBridge` remains the sole ingress for inbound peer commands, while the worker-owned simulation performs lockstep validation and checksum tracking. This avoids duplicate command processing on a non-authoritative main-thread shadow game. + For live pathfinding investigations on a local build, the browser can also stream structured movement telemetry into `output/live-pathfinding.jsonl` through `POST /api/debug/pathfinding`. `GameplayInputHandler` logs the clicked screen/world target, `PathfindingSystem` logs path requests/results, and `WorkerGame` emits short-lived per-unit snapshots plus stall events back through `WorkerBridge`, so local reproductions can be inspected after the fact without relying on browser DevTools history. ### Performance Workers @@ -1203,7 +1205,7 @@ The game uses Three.js WebGPU Renderer with automatic WebGL fallback, powered by Key Components: - `src/app/game/setup/page.tsx` - Pregame lobby UI; the `Start Game` control now stays disabled until the client hydrates so the first click cannot be dropped before the page becomes interactive -- `src/app/game/page.tsx` - App Router entry for gameplay; gates `/game` on `gameStarted` and defers teardown through `gamePageLifecycle.ts` so React Strict Mode remount probes do not immediately clear the active session during the first lobby start in development +- `src/app/game/page.tsx` - App Router entry for gameplay; gates `/game` on `gameStarted`, shows an immediate lightweight loading shell during hydration/dynamic import handoff, and defers teardown through `gamePageLifecycle.ts` so React Strict Mode remount probes do not immediately clear the active session during the first lobby start in development - `public/sw.js` / `src/components/pwa/ServiceWorkerRegistrar.tsx` - PWA shell caching; navigation HTML now uses a network-first strategy with cache fallback so regular browser sessions do not stay pinned to stale lobby bundles after a deploy - `WebGPUGameCanvas.tsx` - Main game canvas (WebGPU with WebGL fallback) - `OverlayScene.ts` - Phaser 4 scene for 2D effects overlay and transient `ui:error` alerts emitted by command-card and gameplay validation diff --git a/docs/architecture/networking.md b/docs/architecture/networking.md index 5c37a988..4b8ac074 100644 --- a/docs/architecture/networking.md +++ b/docs/architecture/networking.md @@ -76,6 +76,13 @@ Lockstep simulation now treats economy state as player-scoped engine data. Synch | **Lobby Discovery** | Nostr subscriptions | | **Player Identity** | Ed25519 keypairs (nostr-tools) | +### Lobby Session Lifecycle + +- Lobby networking stays enabled while a guest occupies a formerly `Open` slot, so private code-join games do not tear down their signaling session as soon as the last slot is filled. +- Active lobby sessions are preserved across the `/game/setup` to `/game` route transition and only torn down when the actual game page exits, preventing false disconnects during match start. +- Guest-side peer mapping uses the host's real signaling pubkey, which keeps signed lockstep commands verifiable after the match begins. +- In browser worker mode, `WorkerBridge` is the only main-thread consumer for inbound multiplayer commands. The UI-side `Game` instance stays in proxy mode, which prevents duplicate command validation against a stale `currentTick === 0` shadow instance. + --- ## Architecture Overview diff --git a/progress.md b/progress.md index 55730c95..b130c7e0 100644 --- a/progress.md +++ b/progress.md @@ -218,6 +218,31 @@ Original prompt: cant get this app to start locally - regenerated the older script as well; shipped `contested_frontier` matches the older generator, while the other ranked maps do not - Implication: - the current 4-player `Scorched Basin` issue is unlikely to be caused by stale/legacy map JSON, because the shipped `scorched_basin.json` already aligns with the LLM-generated structure and bundled pathfinding connectivity tests pass + +- Investigated and fixed the multiplayer lobby-start regression reported on 2026-03-28 where guests could join but then got stuck loading or hit `Connection Lost`, while the host saw the remote player as immediately defeated. +- Root causes confirmed in live two-browser repros: + - `Join Game` was only reachable from a fresh setup page after enabling public-host mode because the header action was incorrectly gated on `lobbyStatus === 'hosting'` + - private code-join lobbies tore themselves down as soon as the last `Open` slot was filled because networking enablement only looked for remaining `open` slots or a public lobby flag + - the `/game/setup` lobby hook closed active peer/signing state during the `/game/setup -> /game` navigation, disconnecting the guest right after `Start Game` + - guest-side multiplayer store wiring used a synthetic host peer ID instead of the host's real signaling pubkey, which would break signed command verification once the match was running +- Applied fixes: + - added `src/app/game/setup/lobbySessionPolicy.ts` and switched `useLobbySync` to keep networking alive for connected guest slots + - updated `src/hooks/useMultiplayer.ts` to preserve active lobby sessions and reconnect callbacks across the setup-to-game transition, defer real teardown to game exit, and map the guest's host peer to the real host pubkey + - updated `src/store/multiplayerStore.ts` and `src/app/game/page.tsx` so real multiplayer cleanup runs on actual `/game` exit instead of on setup-page unmount + - updated `src/app/game/setup/page.tsx` so `Join Game` and `Browse Lobbies` are available from a fresh setup page without requiring public-host mode +- Added regressions: + - `tests/app/game/setup/lobbySessionPolicy.test.ts` + - `tests/store/multiplayerStore.test.ts` +- Verified: + - `npm test -- tests/app/game/setup/lobbySessionPolicy.test.ts tests/app/game/setup/getStartGameButtonState.test.ts tests/app/game/gamePageLifecycle.test.ts tests/store/multiplayerStore.test.ts` + - `npx eslint src/app/game/setup/lobbySessionPolicy.ts src/hooks/useLobbySync.ts src/hooks/useMultiplayer.ts src/store/multiplayerStore.ts src/app/game/page.tsx src/app/game/setup/page.tsx tests/app/game/setup/lobbySessionPolicy.test.ts tests/store/multiplayerStore.test.ts` + - `npm run build` + - live headed Playwright two-browser verification in `next dev`: + - private code join: `output/playwright/multiplayer-verify-private-1774741115339/result.json` + - public-host code join: `output/playwright/multiplayer-verify-public-1774741179155/result.json` + - live headed Playwright two-browser verification in production (`next start`): + - private code join: `output/playwright/multiplayer-verify-private-prod-1774741262397/result.json` +- `npm run type-check` is still blocked by pre-existing unrelated test-harness errors in `tests/engine/input/handlers/buildingInputHandler.test.ts` and `tests/engine/workers/gameWorker.test.ts`; the multiplayer changes themselves build and lint cleanly. - `contested_frontier` is the only ranked shipped map that still appears to be on the older non-LLM layout - Additional catalog note: - `battle_arena` is correctly hidden from the regular lobby via `isSpecialMode: true` @@ -232,3 +257,49 @@ Original prompt: cant get this app to start locally - the validator now reports all six spawn cells as `terrain=ramp elev=220` rather than `ground`, though they remain walkable and the existing connectivity regression still passes - Follow-up worth doing in a live match: - spectate a `Contested Frontier` game after the swap to confirm AI movement around main-base exits and initial worker behavior still looks sane with spawn cells marked as ramp terrain + +- Investigated multiplayer command transmission and determinism under sustained live play on 2026-03-28 after the earlier lobby-start regression fix. +- Additional root cause found during deeper multiplayer tracing: + - the UI-thread proxy `Game` instance could still register inbound multiplayer handlers even when the worker-owned simulation was already handling them, which produced duplicate verification paths and false `[Game] SECURITY:` rejections for otherwise valid remote commands +- Applied follow-up multiplayer determinism fixes: + - added `src/engine/core/multiplayerMessageHandling.ts` plus a `multiplayerMessageHandling` ownership flag in `src/engine/core/GameCore.ts` + - updated `src/engine/core/Game.ts` and `src/components/game/hooks/useWorkerBridge.ts` so multiplayer inbound message handling is owned by the worker in worker-bridge matches and only by the main thread in direct-mode matches + - added a browser debug hook in `src/components/game/hooks/useWorkerBridge.ts` exposing `globalThis.__voidstrikeMultiplayerDebug__` so live browser automation can request authoritative simulation checksums and read multiplayer sync state from the running worker + - added `scripts/verify-multiplayer-checksum.js` to spin up a real 2-human + 2-AI match, issue commands from both humans, and verify remote action visibility plus checksum parity over time + - added `tests/engine/core/multiplayerMessageHandling.test.ts` to lock the ownership split between main-thread and worker-managed multiplayer sessions +- Sustained production verification passed on `http://127.0.0.1:3308`: + - scenario: `Scorched Basin`, 2 humans + 2 AI, `High` resources, `Fastest` speed, fog disabled + - host created a private lobby, guest joined by code, and both human players issued live commands including move/hold/stop plus repeated move orders during the five-minute run + - command visibility checks showed the same commanded remote unit state on both clients for host-issued and guest-issued actions, confirming network transmission and remote render-state updates + - authoritative checksum checkpoints matched on both clients throughout the run: + - initial: tick `20`, checksum `767486150` + - minute 1: tick `1285`, checksum `282837654` + - minute 2: tick `2495`, checksum `1162993385` + - minute 3: tick `3700`, checksum `3528400692` + - minute 4: tick `4885`, checksum `3938796473` + - final: tick `6085`, checksum `2918746334` + - final multiplayer state on both clients remained `connectionStatus: connected` and `desyncState: synced`, with the host still bound to `player1` and the guest still bound to `player2` + - no `Connection Lost`, no `Game Desynchronized`, and no `[Game] SECURITY:` / `CRITICAL` log entries were emitted during the run +- Verification artifacts: + - result bundle: `output/playwright/multiplayer-checksum-five-minute-1774744461322/` + - summary JSON: `output/playwright/multiplayer-checksum-five-minute-1774744461322/result.json` + - logs: `output/playwright/multiplayer-checksum-five-minute-1774744461322/host.log` and `output/playwright/multiplayer-checksum-five-minute-1774744461322/guest.log` + - captured end-state visuals/text: `host-final.png`, `guest-final.png`, `host-final.txt`, `guest-final.txt` + +- Investigated the follow-up report that the guest seemed to start a multiplayer match without a loading progress bar on 2026-03-28. +- Findings from live two-browser production captures: + - the in-canvas `LoadingScreen` already rendered correctly for the guest once `WebGPUGameCanvas` mounted + - the real UX gap was earlier in the `/game` route handoff: both host and guest could briefly show the plain route-level black fallback before the game canvas chunk mounted, making the guest transition look like “no progress bar” if the black frame happened to last longer on that machine +- Applied fix: + - added `src/app/game/GameLoadingFallback.tsx`, a lightweight route-level loading shell with an immediate visible progress bar + - updated `src/app/game/page.tsx` so both the pre-hydration state and the dynamic import fallback use `GameLoadingFallback` instead of the blank black screen whenever `gameStarted` is true + - added `tests/app/game/GameLoadingFallback.test.ts` to lock the presence of the immediate loading shell and progress bar markup + - updated `docs/architecture/OVERVIEW.md` to document that `/game` now shows a lightweight loading shell during the route-to-canvas handoff +- Verified: + - `npm test -- tests/app/game/GameLoadingFallback.test.ts tests/app/game/setup/lobbySessionPolicy.test.ts tests/app/game/setup/getStartGameButtonState.test.ts tests/app/game/gamePageLifecycle.test.ts tests/store/multiplayerStore.test.ts tests/engine/core/multiplayerMessageHandling.test.ts` + - `npx eslint src/app/game/page.tsx src/app/game/GameLoadingFallback.tsx src/app/game/setup/lobbySessionPolicy.ts src/hooks/useLobbySync.ts src/hooks/useMultiplayer.ts src/store/multiplayerStore.ts src/components/game/hooks/useWorkerBridge.ts src/engine/core/Game.ts src/engine/core/GameCore.ts src/engine/core/multiplayerMessageHandling.ts tests/app/game/GameLoadingFallback.test.ts tests/app/game/setup/lobbySessionPolicy.test.ts tests/store/multiplayerStore.test.ts tests/engine/core/multiplayerMessageHandling.test.ts` + - `npm run build` + - fresh production (`next start`) two-browser loading-handoff capture on `http://127.0.0.1:3309`: + - artifact bundle: `output/playwright/host-guest-loading-compare-fixed-1774745792096/` + - both `host-100ms.png` and `guest-100ms.png` now show the new immediate loading shell instead of a blank screen + - by `200ms`, the regular in-canvas loading screen is already visible and progressing on both clients diff --git a/scripts/verify-multiplayer-checksum.js b/scripts/verify-multiplayer-checksum.js new file mode 100644 index 00000000..990260b5 --- /dev/null +++ b/scripts/verify-multiplayer-checksum.js @@ -0,0 +1,662 @@ +const fs = require('fs'); +const fsp = fs.promises; +const path = require('path'); +const { + chromium, +} = require('/Users/braedonsaunders/.codex/skills/develop-web-game/node_modules/playwright'); + +const baseUrl = process.env.VOIDSTRIKE_BASE_URL || 'http://127.0.0.1:3308'; +const outDir = path.join( + process.cwd(), + 'output', + 'playwright', + 'multiplayer-checksum-five-minute-' + Date.now() +); +const testDurationMs = 5 * 60 * 1000; + +function round(value) { + if (value === null || value === undefined || Number.isNaN(value)) return null; + return Number(value.toFixed(3)); +} + +function normalizeQueue(queue = []) { + return queue.map((item) => ({ + type: item.type, + targetX: round(item.targetX), + targetY: round(item.targetY), + targetEntityId: item.targetEntityId ?? null, + })); +} + +function commandView(unit) { + if (!unit) return null; + return { + state: unit.state, + targetX: round(unit.targetX), + targetY: round(unit.targetY), + targetEntityId: unit.targetEntityId ?? null, + queue: normalizeQueue(unit.queue), + isDead: unit.isDead, + }; +} + +function meaningfulChange(previous, current) { + if (!previous || !current) return true; + if (JSON.stringify(commandView(previous)) !== JSON.stringify(commandView(current))) { + return true; + } + const dx = current.x - previous.x; + const dy = current.y - previous.y; + return Math.hypot(dx, dy) > 0.5; +} + +async function bodyText(page) { + return page.locator('body').innerText(); +} + +async function getStatus(page) { + return page.evaluate(() => globalThis.__voidstrikeMultiplayerDebug__.getStatus()); +} + +async function requestChecksum(page) { + return page.evaluate( + async () => await globalThis.__voidstrikeMultiplayerDebug__.requestChecksum() + ); +} + +async function waitForRenderWarm(page) { + await page.waitForFunction( + () => + !!globalThis.__voidstrikeMultiplayerDebug__ && + globalThis.__voidstrikeMultiplayerDebug__.getStatus().renderTick >= 20, + { timeout: 45000 } + ); +} + +async function ensureHealthy(page, name) { + if (!page.url().includes('/game')) { + throw new Error(name + ' left /game: ' + page.url()); + } + const text = await bodyText(page); + if (text.includes('Connection Lost')) { + throw new Error(name + ' displayed Connection Lost'); + } + if (text.includes('Game Desynchronized')) { + throw new Error(name + ' displayed Game Desynchronized'); + } +} + +async function captureSnapshot(page, name) { + const debug = await page.evaluate(() => { + const r = (value) => { + if (value === null || value === undefined || Number.isNaN(value)) return null; + return Number(value.toFixed(3)); + }; + const adapter = globalThis.__voidstrike_RenderStateWorldAdapter__; + if (!adapter || !adapter.currentRenderState) { + return null; + } + + const rs = adapter.currentRenderState; + return { + tick: rs.tick, + gameTime: r(rs.gameTime), + units: rs.units + .map((unit) => ({ + id: unit.id, + playerId: unit.playerId, + unitId: unit.unitId, + x: r(unit.x), + y: r(unit.y), + z: r(unit.z), + state: unit.state, + health: r(unit.health), + shield: r(unit.shield), + isDead: unit.isDead, + targetEntityId: unit.targetEntityId, + targetX: r(unit.targetX), + targetY: r(unit.targetY), + speed: r(unit.speed), + queue: unit.commandQueue.map((cmd) => ({ + type: cmd.type, + targetX: r(cmd.targetX), + targetY: r(cmd.targetY), + targetEntityId: cmd.targetEntityId ?? null, + })), + })) + .sort((a, b) => a.id - b.id), + }; + }); + + if (!debug) { + throw new Error(name + ' snapshot unavailable'); + } + + return debug; +} + +async function waitForPlayerAction( + primaryPage, + secondaryPage, + playerId, + previousPrimarySnap, + previousSecondarySnap, + label +) { + const deadline = Date.now() + 20000; + let lastError = 'not started'; + + while (Date.now() < deadline) { + const [primarySnap, secondarySnap, primaryStatus, secondaryStatus] = await Promise.all([ + captureSnapshot(primaryPage, 'primary'), + captureSnapshot(secondaryPage, 'secondary'), + getStatus(primaryPage), + getStatus(secondaryPage), + ]); + + if (primaryStatus.desyncState !== 'synced' || secondaryStatus.desyncState !== 'synced') { + throw new Error( + label + ' entered desync: ' + JSON.stringify({ primaryStatus, secondaryStatus }) + ); + } + if ( + primaryStatus.connectionStatus !== 'connected' || + secondaryStatus.connectionStatus !== 'connected' + ) { + throw new Error( + label + ' lost connection: ' + JSON.stringify({ primaryStatus, secondaryStatus }) + ); + } + + const primaryUnits = primarySnap.units.filter((unit) => unit.playerId === playerId); + let matchingPair = null; + + for (const primaryUnit of primaryUnits) { + const secondaryUnit = secondarySnap.units.find((unit) => unit.id === primaryUnit.id) ?? null; + const previousPrimaryUnit = + previousPrimarySnap.units.find((unit) => unit.id === primaryUnit.id) ?? null; + const previousSecondaryUnit = + previousSecondarySnap.units.find((unit) => unit.id === primaryUnit.id) ?? null; + + if (!secondaryUnit || !previousPrimaryUnit || !previousSecondaryUnit) { + continue; + } + + if (JSON.stringify(commandView(primaryUnit)) !== JSON.stringify(commandView(secondaryUnit))) { + continue; + } + + if ( + !meaningfulChange(previousPrimaryUnit, primaryUnit) || + !meaningfulChange(previousSecondaryUnit, secondaryUnit) + ) { + continue; + } + + matchingPair = { primaryUnit, secondaryUnit }; + break; + } + + if (!matchingPair) { + lastError = 'no changed unit found for ' + playerId; + await primaryPage.waitForTimeout(100); + continue; + } + + return { + primaryUnit: matchingPair.primaryUnit, + secondaryUnit: matchingPair.secondaryUnit, + primarySnap, + secondarySnap, + primaryStatus, + secondaryStatus, + }; + } + + throw new Error(label + ' did not display consistently: ' + lastError); +} + +async function ensureWorkerSelected(page) { + const buildBasic = page.getByRole('button', { name: 'Build Basic' }); + if (await buildBasic.isVisible().catch(() => false)) { + return; + } + + const idleButton = page.getByRole('button', { name: /Idle \(/ }); + await idleButton.waitFor({ timeout: 15000 }); + await idleButton.click(); + await buildBasic.waitFor({ timeout: 10000 }); +} + +async function issueMove(page, position) { + await ensureWorkerSelected(page); + await page.keyboard.press('M'); + await page.getByText('Move - Click canvas or minimap, ESC to cancel').waitFor({ timeout: 5000 }); + await page.locator('canvas').first().click({ position }); + await page + .getByText('Move - Click canvas or minimap, ESC to cancel') + .waitFor({ state: 'hidden', timeout: 5000 }); +} + +async function issueHold(page) { + await ensureWorkerSelected(page); + await page.keyboard.press('H'); +} + +async function issueStop(page) { + await ensureWorkerSelected(page); + await page.keyboard.press('S'); +} + +async function toggleFogOff(page) { + const fogRow = page.getByText('Fog of War', { exact: true }).locator('..'); + await fogRow.locator('button').click(); +} + +async function configureLobby(host, guest) { + await Promise.all([ + host.goto(baseUrl + '/game/setup', { waitUntil: 'domcontentloaded' }), + guest.goto(baseUrl + '/game/setup', { waitUntil: 'domcontentloaded' }), + ]); + + await host.locator('input').first().fill('HostChecksumFive'); + await guest.locator('input').first().fill('GuestChecksumFive'); + await host.getByPlaceholder('Search maps...').fill('Scorched'); + await host.getByText('Scorched Basin').first().click(); + await host + .locator('select') + .filter({ has: host.locator('option[value="high"]') }) + .first() + .selectOption('high'); + await host + .locator('select') + .filter({ has: host.locator('option[value="fastest"]') }) + .first() + .selectOption('fastest'); + await toggleFogOff(host); + await host.getByRole('button', { name: '+ Add Player' }).click(); + await host.getByRole('button', { name: '+ Add Player' }).click(); + const playerTypeSelects = host + .locator('select') + .filter({ has: host.locator('option[value="open"]') }); + await playerTypeSelects.nth(1).selectOption('open'); + + const code = ( + (await host.locator('button[title="Click to copy"] .font-mono').textContent()) || '' + ).trim(); + if (!/^[A-Z]{4}$/.test(code)) { + throw new Error('Unexpected lobby code: ' + code); + } + + await guest.getByRole('button', { name: 'Join Game' }).first().click(); + await guest.locator('input[placeholder="XXXX"]').fill(code); + await guest.getByRole('button', { name: /^Join$/ }).click(); + await guest.getByText('Connected to Lobby').waitFor({ timeout: 30000 }); + await host.getByText('GuestChecksumFive').waitFor({ timeout: 30000 }); + await host.getByRole('button', { name: 'Start Game' }).click(); + + return code; +} + +async function run() { + await fsp.mkdir(outDir, { recursive: true }); + console.log('OUTDIR ' + outDir); + + const hostLog = []; + const guestLog = []; + const securityErrors = []; + const checkpoints = []; + const actions = []; + let failure = null; + let browser; + let hostContext; + let guestContext; + let host; + let guest; + + try { + browser = await chromium.launch({ headless: false, args: ['--window-size=1440,1000'] }); + hostContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + guestContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + host = await hostContext.newPage(); + guest = await guestContext.newPage(); + + function hookLogging(page, name, sink) { + page.on('console', (msg) => { + const entry = + '[' + new Date().toISOString() + '] console.' + msg.type() + ': ' + msg.text(); + sink.push(entry); + if (entry.includes('[Game] SECURITY:')) { + securityErrors.push({ name, entry }); + } + }); + page.on('pageerror', (err) => { + sink.push( + '[' + + new Date().toISOString() + + '] pageerror: ' + + (err.stack || err.message || String(err)) + ); + }); + page.on('requestfailed', (req) => { + const details = req.failure(); + sink.push( + '[' + + new Date().toISOString() + + '] requestfailed: ' + + req.method() + + ' ' + + req.url() + + ' :: ' + + (details ? details.errorText : 'unknown') + ); + }); + } + + hookLogging(host, 'host', hostLog); + hookLogging(guest, 'guest', guestLog); + + const code = await configureLobby(host, guest); + await Promise.all([ + host.waitForURL(/\/game(?:\?|$)/, { timeout: 30000 }), + guest.waitForURL(/\/game(?:\?|$)/, { timeout: 30000 }), + ]); + await Promise.all([waitForRenderWarm(host), waitForRenderWarm(guest)]); + + const [hostStatus, guestStatus, hostChecksum, guestChecksum, hostInitial, guestInitial] = + await Promise.all([ + getStatus(host), + getStatus(guest), + requestChecksum(host), + requestChecksum(guest), + captureSnapshot(host, 'host'), + captureSnapshot(guest, 'guest'), + ]); + + if ( + hostStatus.connectionStatus !== 'connected' || + guestStatus.connectionStatus !== 'connected' + ) { + throw new Error('Initial connection status not connected'); + } + if (hostStatus.desyncState !== 'synced' || guestStatus.desyncState !== 'synced') { + throw new Error('Initial desync state not synced'); + } + if (hostStatus.localPlayerId !== 'player1' || guestStatus.localPlayerId !== 'player2') { + throw new Error( + 'Unexpected local player IDs: ' + JSON.stringify({ hostStatus, guestStatus }) + ); + } + if (hostStatus.fogOfWar !== false || guestStatus.fogOfWar !== false) { + throw new Error('Fog of war was not disabled on both clients'); + } + + checkpoints.push({ + label: 'initial', + hostStatus, + guestStatus, + hostChecksum, + guestChecksum, + hostTick: hostInitial.tick, + guestTick: guestInitial.tick, + }); + + let lastHostSnapshot = hostInitial; + let lastGuestSnapshot = guestInitial; + + await issueMove(host, { x: 980, y: 420 }); + actions.push({ who: 'host', action: 'move', atMs: 0 }); + const hostMove = await waitForPlayerAction( + host, + guest, + 'player1', + lastHostSnapshot, + lastGuestSnapshot, + 'host move' + ); + lastHostSnapshot = hostMove.primarySnap; + lastGuestSnapshot = hostMove.secondarySnap; + checkpoints.push({ + label: 'host-move', + hostUnit: hostMove.primaryUnit, + guestUnit: hostMove.secondaryUnit, + }); + + await host.waitForTimeout(800); + await issueHold(host); + actions.push({ who: 'host', action: 'hold', atMs: 800 }); + const hostHold = await waitForPlayerAction( + host, + guest, + 'player1', + lastHostSnapshot, + lastGuestSnapshot, + 'host hold' + ); + lastHostSnapshot = hostHold.primarySnap; + lastGuestSnapshot = hostHold.secondarySnap; + checkpoints.push({ + label: 'host-hold', + hostUnit: hostHold.primaryUnit, + guestUnit: hostHold.secondaryUnit, + }); + + await issueMove(guest, { x: 720, y: 520 }); + actions.push({ who: 'guest', action: 'move', atMs: 0 }); + const guestMove = await waitForPlayerAction( + guest, + host, + 'player2', + lastGuestSnapshot, + lastHostSnapshot, + 'guest move' + ); + lastGuestSnapshot = guestMove.primarySnap; + lastHostSnapshot = guestMove.secondarySnap; + checkpoints.push({ + label: 'guest-move', + guestUnit: guestMove.primaryUnit, + hostUnit: guestMove.secondaryUnit, + }); + + await guest.waitForTimeout(800); + await issueStop(guest); + actions.push({ who: 'guest', action: 'stop', atMs: 800 }); + const guestStop = await waitForPlayerAction( + guest, + host, + 'player2', + lastGuestSnapshot, + lastHostSnapshot, + 'guest stop' + ); + lastGuestSnapshot = guestStop.primarySnap; + lastHostSnapshot = guestStop.secondarySnap; + checkpoints.push({ + label: 'guest-stop', + guestUnit: guestStop.primaryUnit, + hostUnit: guestStop.secondaryUnit, + }); + + const start = Date.now(); + let nextHostMoveAt = 30000; + let nextGuestMoveAt = 45000; + let nextMinuteMark = 60000; + let minuteCounter = 1; + let hostMoveIndex = 0; + let guestMoveIndex = 0; + const hostMovePositions = [ + { x: 860, y: 340 }, + { x: 1080, y: 560 }, + { x: 760, y: 500 }, + { x: 980, y: 420 }, + ]; + const guestMovePositions = [ + { x: 620, y: 400 }, + { x: 820, y: 300 }, + { x: 900, y: 540 }, + { x: 720, y: 520 }, + ]; + + while (Date.now() - start < testDurationMs) { + const elapsed = Date.now() - start; + + if (elapsed >= nextHostMoveAt) { + await issueMove(host, hostMovePositions[hostMoveIndex % hostMovePositions.length]); + actions.push({ who: 'host', action: 'move', atMs: elapsed }); + const synced = await waitForPlayerAction( + host, + guest, + 'player1', + lastHostSnapshot, + lastGuestSnapshot, + 'host move loop' + ); + lastHostSnapshot = synced.primarySnap; + lastGuestSnapshot = synced.secondarySnap; + hostMoveIndex += 1; + nextHostMoveAt += 30000; + } + + if (elapsed >= nextGuestMoveAt) { + await issueMove(guest, guestMovePositions[guestMoveIndex % guestMovePositions.length]); + actions.push({ who: 'guest', action: 'move', atMs: elapsed }); + const synced = await waitForPlayerAction( + guest, + host, + 'player2', + lastGuestSnapshot, + lastHostSnapshot, + 'guest move loop' + ); + lastGuestSnapshot = synced.primarySnap; + lastHostSnapshot = synced.secondarySnap; + guestMoveIndex += 1; + nextGuestMoveAt += 30000; + } + + if (elapsed >= nextMinuteMark) { + const [minuteHostStatus, minuteGuestStatus, minuteHostChecksum, minuteGuestChecksum] = + await Promise.all([ + getStatus(host), + getStatus(guest), + requestChecksum(host), + requestChecksum(guest), + ]); + if ( + minuteHostStatus.connectionStatus !== 'connected' || + minuteGuestStatus.connectionStatus !== 'connected' + ) { + throw new Error('Connection dropped during minute checkpoint ' + minuteCounter); + } + if ( + minuteHostStatus.desyncState !== 'synced' || + minuteGuestStatus.desyncState !== 'synced' + ) { + throw new Error( + 'Desync detected during minute checkpoint ' + + minuteCounter + + ': ' + + JSON.stringify({ minuteHostStatus, minuteGuestStatus }) + ); + } + checkpoints.push({ + label: 'minute-' + minuteCounter, + hostStatus: minuteHostStatus, + guestStatus: minuteGuestStatus, + hostChecksum: minuteHostChecksum, + guestChecksum: minuteGuestChecksum, + }); + console.log( + 'minute ' + + minuteCounter + + ': hostTick=' + + minuteHostChecksum.tick + + ' guestTick=' + + minuteGuestChecksum.tick + + ' hostChecksum=' + + minuteHostChecksum.checksum + + ' guestChecksum=' + + minuteGuestChecksum.checksum + ); + minuteCounter += 1; + nextMinuteMark += 60000; + } + + await Promise.all([ensureHealthy(host, 'host'), ensureHealthy(guest, 'guest')]); + await host.waitForTimeout(1000); + } + + const [finalHostStatus, finalGuestStatus, finalHostChecksum, finalGuestChecksum] = + await Promise.all([ + getStatus(host), + getStatus(guest), + requestChecksum(host), + requestChecksum(guest), + ]); + if (finalHostStatus.desyncState !== 'synced' || finalGuestStatus.desyncState !== 'synced') { + throw new Error('Final desync state not synced'); + } + if (securityErrors.length > 0) { + throw new Error('Security errors observed: ' + JSON.stringify(securityErrors.slice(0, 5))); + } + + const result = { + outDir, + scenario: '2 humans + 2 ai, 5 minute checksum-backed multiplayer verification', + code, + realDurationMs: Date.now() - start, + checkpoints, + actions, + finalHostStatus, + finalGuestStatus, + finalHostChecksum, + finalGuestChecksum, + securityErrors, + hostUrl: host.url(), + guestUrl: guest.url(), + failure: null, + }; + + await fsp.writeFile(path.join(outDir, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + failure = error && error.stack ? error.stack : String(error); + console.error(failure); + } finally { + try { + await Promise.all([ + fsp.writeFile(path.join(outDir, 'host.log'), hostLog.join('\n')), + fsp.writeFile(path.join(outDir, 'guest.log'), guestLog.join('\n')), + fsp.writeFile( + path.join(outDir, 'partial.json'), + JSON.stringify({ outDir, checkpoints, actions, securityErrors, failure }, null, 2) + ), + ]); + if (host && guest) { + await Promise.allSettled([ + host.screenshot({ path: path.join(outDir, 'host-final.png'), fullPage: true }), + guest.screenshot({ path: path.join(outDir, 'guest-final.png'), fullPage: true }), + fsp.writeFile(path.join(outDir, 'host-final.txt'), await bodyText(host)), + fsp.writeFile(path.join(outDir, 'guest-final.txt'), await bodyText(guest)), + ]); + } + } catch (artifactError) { + console.error( + 'artifact capture failed: ' + + (artifactError && artifactError.stack ? artifactError.stack : String(artifactError)) + ); + } + + await Promise.allSettled([hostContext?.close(), guestContext?.close(), browser?.close()]); + + if (failure) { + process.exitCode = 1; + } + } +} + +run().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exitCode = 1; +}); diff --git a/src/app/game/GameLoadingFallback.tsx b/src/app/game/GameLoadingFallback.tsx new file mode 100644 index 00000000..f44eb309 --- /dev/null +++ b/src/app/game/GameLoadingFallback.tsx @@ -0,0 +1,95 @@ +interface GameLoadingFallbackProps { + status?: string; +} + +export function GameLoadingFallback({ + status = 'Preparing battlefield', +}: GameLoadingFallbackProps) { + return ( +
+ Initializing Combat Systems +
++ Match accepted. Establishing simulation and synchronizing battlefield state. +
+