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 +

+

+ VOIDSTRIKE +

+

+ Match accepted. Establishing simulation and synchronizing battlefield state. +

+
+ +
+
+ Route + Worker + Render + Sync +
+
+
+
+
+ {status} + Live +
+
+
+ + +
+ ); +} diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 1680580e..5e20d71c 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation'; import { HUD } from '@/components/game/HUD'; import { MultiplayerOverlay } from '@/components/game/MultiplayerOverlay'; import { useGameSetupStore } from '@/store/gameSetupStore'; +import { useMultiplayerStore } from '@/store/multiplayerStore'; +import { GameLoadingFallback } from './GameLoadingFallback'; import { registerGamePageUnmount } from './gamePageLifecycle'; // Dynamic import for WebGPU game canvas (Three.js + Phaser overlay) @@ -13,7 +15,7 @@ import { registerGamePageUnmount } from './gamePageLifecycle'; // No SSR - both Three.js and Phaser require browser const WebGPUGameCanvas = dynamic( () => import('@/components/game/WebGPUGameCanvas').then((mod) => mod.WebGPUGameCanvas), - { ssr: false, loading: () => null } + { ssr: false, loading: () => } ); // Simple black screen fallback - no content to prevent flash @@ -25,6 +27,7 @@ export default function GamePage() { const router = useRouter(); const { gameStarted, endGame } = useGameSetupStore(); const [mounted, setMounted] = useState(false); + const routeFallback = gameStarted ? : ; // Hydration mount pattern: intentionally triggers re-render after client hydration // to avoid SSR/client mismatch when rendering browser-only content @@ -40,18 +43,19 @@ export default function GamePage() { } return registerGamePageUnmount(() => { + useMultiplayerStore.getState().reset(); endGame(); }); }, [gameStarted, router, endGame]); - // Start with black screen, prevent any flash + // Keep an immediate route-level fallback visible while the game page hydrates if (!mounted || !gameStarted) { - return ; + return routeFallback; } return (
- }> + diff --git a/src/app/game/setup/lobbySessionPolicy.ts b/src/app/game/setup/lobbySessionPolicy.ts new file mode 100644 index 00000000..3977d38a --- /dev/null +++ b/src/app/game/setup/lobbySessionPolicy.ts @@ -0,0 +1,16 @@ +import type { PlayerSlot } from '@/store/gameSetupStore'; + +export function shouldEnableLobbyNetworking( + playerSlots: PlayerSlot[], + isPublicLobby: boolean +): boolean { + if (isPublicLobby) { + return true; + } + + return playerSlots.some((slot) => slot.type === 'open' || slot.isGuest === true); +} + +export function shouldPreserveLobbySessionOnUnmount(gameStarted: boolean): boolean { + return gameStarted; +} diff --git a/src/app/game/setup/page.tsx b/src/app/game/setup/page.tsx index a60ccfed..9d1bc215 100644 --- a/src/app/game/setup/page.tsx +++ b/src/app/game/setup/page.tsx @@ -189,19 +189,23 @@ export default function GameSetupPage() { {/* Header actions */}
- {isHost && lobbyStatus === 'hosting' && ( + {!isGuestMode && ( <> diff --git a/src/components/game/hooks/useWorkerBridge.ts b/src/components/game/hooks/useWorkerBridge.ts index eb063869..345761e5 100644 --- a/src/components/game/hooks/useWorkerBridge.ts +++ b/src/components/game/hooks/useWorkerBridge.ts @@ -29,7 +29,7 @@ import { } from '@/store/gameSetupStore'; import { useUIStore } from '@/store/uiStore'; import { useGameStore } from '@/store/gameStore'; -import { isMultiplayerMode } from '@/store/multiplayerStore'; +import { isMultiplayerMode, useMultiplayerStore } from '@/store/multiplayerStore'; import { MapData } from '@/data/maps'; import { debugInitialization, debugNetworking } from '@/utils/debugLogger'; import { syncWorkerPlayerResources } from './syncWorkerPlayerResources'; @@ -62,6 +62,23 @@ export interface UseWorkerBridgeReturn { getGameTime: () => number; } +type VoidstrikeMultiplayerDebug = { + requestChecksum: () => Promise<{ tick: number; checksum: string }>; + getStatus: () => { + connectionStatus: ReturnType['connectionStatus']; + desyncState: ReturnType['desyncState']; + desyncTick: ReturnType['desyncTick']; + localPlayerId: string | null; + fogOfWar: boolean; + renderTick: number; + renderGameTime: number; + }; +}; + +type VoidstrikeGlobal = typeof globalThis & { + __voidstrikeMultiplayerDebug__?: VoidstrikeMultiplayerDebug; +}; + export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseWorkerBridgeReturn { // Refs const workerBridgeRef = useRef(null); @@ -82,6 +99,45 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW const mapRef = useRef(map); mapRef.current = map; + const installMultiplayerDebugApi = useCallback((bridge: WorkerBridge) => { + const debugTarget = globalThis as VoidstrikeGlobal; + debugTarget.__voidstrikeMultiplayerDebug__ = { + requestChecksum: () => + new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + unsubscribe(); + reject(new Error('Checksum request timed out')); + }, 5000); + + const unsubscribe = bridge.eventBus.on<{ tick: number; checksum: string }>( + 'checksum', + ({ tick, checksum }) => { + window.clearTimeout(timeout); + unsubscribe(); + resolve({ tick, checksum }); + } + ); + + bridge.requestChecksum(); + }), + getStatus: () => { + const multiplayerState = useMultiplayerStore.getState(); + const gameSetupState = useGameSetupStore.getState(); + const adapter = RenderStateWorldAdapter.getInstance(); + + return { + connectionStatus: multiplayerState.connectionStatus, + desyncState: multiplayerState.desyncState, + desyncTick: multiplayerState.desyncTick, + localPlayerId: gameSetupState.localPlayerId, + fogOfWar: gameSetupState.fogOfWar, + renderTick: adapter.getTick(), + renderGameTime: adapter.getGameTime(), + }; + }, + }; + }, []); + // Handle render state updates from worker const handleRenderState = useCallback((state: RenderState) => { if ( @@ -166,6 +222,7 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW mapHeight, tickRate: 20, isMultiplayer, + multiplayerMessageHandling: isMultiplayer ? 'worker' : 'main-thread', playerId: localPlayerId ?? 'spectator', aiEnabled: !isMultiplayer, aiDifficulty: 'medium', @@ -195,6 +252,7 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW eventUnsubscribersRef.current.push(unsubscribeLocalPlayer); await bridge.initialize(); + installMultiplayerDebugApi(bridge); bridge.setDebugSettings(useUIStore.getState().debugSettings); bridge.setTerrainGrid(currentMap.terrain); @@ -213,6 +271,7 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW mapHeight, tickRate: 20, isMultiplayer, + multiplayerMessageHandling: 'worker', playerId: localPlayerId ?? 'spectator', aiEnabled: false, }); @@ -251,7 +310,13 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW initializePromiseRef.current = initPromise; return initPromise; - }, [handleRenderState, handleGameEvent, handleGameOver, handleWorkerError]); + }, [ + handleRenderState, + handleGameEvent, + handleGameOver, + handleWorkerError, + installMultiplayerDebugApi, + ]); // Spawn initial entities const spawnEntities = useCallback(async () => { @@ -294,6 +359,7 @@ export function useWorkerBridge({ map, onGameOver }: UseWorkerBridgeProps): UseW WorkerBridge.resetInstance(); workerBridgeRef.current = null; } + delete (globalThis as VoidstrikeGlobal).__voidstrikeMultiplayerDebug__; RenderStateWorldAdapter.resetInstance(); worldProviderRef.current = null; eventBusRef.current = null; diff --git a/src/engine/core/Game.ts b/src/engine/core/Game.ts index f531c17e..a021e511 100644 --- a/src/engine/core/Game.ts +++ b/src/engine/core/Game.ts @@ -52,6 +52,7 @@ import { commandIdGenerator } from '../network/types'; import type { GameStatePort } from './GameStatePort'; import { ZustandStateAdapter } from '@/adapters/ZustandStateAdapter'; import { getCommandSigningManager, resetCommandSigningManager } from '../network/CommandSigning'; +import { shouldHandleMultiplayerMessagesOnMainThread } from './multiplayerMessageHandling'; // Re-export types for backwards compatibility export type { GameState, TerrainCell, GameConfig } from './GameCore'; @@ -132,7 +133,7 @@ export class Game extends GameCore { this.audioSystem = new AudioSystem(this as IGameInstance); // Initialize desync detection for multiplayer - if (this.config.isMultiplayer) { + if (shouldHandleMultiplayerMessagesOnMainThread(this.config)) { this.desyncDetection = new DesyncDetectionManager(this as IGameInstance, { enabled: true, pauseOnDesync: false, diff --git a/src/engine/core/GameCore.ts b/src/engine/core/GameCore.ts index 176f028e..1010bed8 100644 --- a/src/engine/core/GameCore.ts +++ b/src/engine/core/GameCore.ts @@ -56,6 +56,12 @@ export interface GameConfig { mapHeight: number; tickRate: number; isMultiplayer: boolean; + /** + * Selects which runtime owns inbound multiplayer command handling. + * - `main-thread`: direct Game instance handles lockstep networking + * - `worker`: WorkerBridge/GameWorker handle multiplayer, main thread stays UI-only + */ + multiplayerMessageHandling: 'main-thread' | 'worker'; playerId: string; aiEnabled: boolean; aiDifficulty: 'easy' | 'medium' | 'hard' | 'insane'; @@ -67,6 +73,7 @@ export const DEFAULT_CONFIG: GameConfig = { mapHeight: 128, tickRate: 20, isMultiplayer: false, + multiplayerMessageHandling: 'main-thread', playerId: 'player1', aiEnabled: true, aiDifficulty: 'medium', diff --git a/src/engine/core/multiplayerMessageHandling.ts b/src/engine/core/multiplayerMessageHandling.ts new file mode 100644 index 00000000..8b84c330 --- /dev/null +++ b/src/engine/core/multiplayerMessageHandling.ts @@ -0,0 +1,12 @@ +import type { GameConfig } from './GameCore'; + +/** + * In worker mode, WorkerBridge forwards inbound peer commands to GameWorker and the + * main-thread Game instance must stay out of lockstep validation to avoid duplicate + * processing against a non-advancing local tick. + */ +export function shouldHandleMultiplayerMessagesOnMainThread( + config: Pick +): boolean { + return config.isMultiplayer && config.multiplayerMessageHandling !== 'worker'; +} diff --git a/src/hooks/useLobbySync.ts b/src/hooks/useLobbySync.ts index 90d6593e..df0a69a0 100644 --- a/src/hooks/useLobbySync.ts +++ b/src/hooks/useLobbySync.ts @@ -4,10 +4,16 @@ import { useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useLobby, LobbyState } from '@/hooks/useMultiplayer'; import { useMultiplayerStore } from '@/store/multiplayerStore'; -import { useGameSetupStore, PlayerSlot, StartingResources, GameSpeed } from '@/store/gameSetupStore'; +import { + useGameSetupStore, + PlayerSlot, + StartingResources, + GameSpeed, +} from '@/store/gameSetupStore'; import { ALL_MAPS } from '@/data/maps'; import { Game } from '@/engine/core/Game'; import { debugInitialization } from '@/utils/debugLogger'; +import { shouldEnableLobbyNetworking } from '@/app/game/setup/lobbySessionPolicy'; export interface UseLobbyOptions { playerName: string; @@ -75,16 +81,22 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo } = useGameSetupStore(); // Lobby hook callbacks - const handleGuestJoin = useCallback((guestName: string) => { - return fillOpenSlotWithGuest(guestName); - }, [fillOpenSlotWithGuest]); - - const handleGuestLeave = useCallback((slotId: string) => { - removeGuest(slotId); - }, [removeGuest]); + const handleGuestJoin = useCallback( + (guestName: string) => { + return fillOpenSlotWithGuest(guestName); + }, + [fillOpenSlotWithGuest] + ); + + const handleGuestLeave = useCallback( + (slotId: string) => { + removeGuest(slotId); + }, + [removeGuest] + ); // Only enable Nostr when multiplayer is needed - const needsMultiplayer = playerSlots.some(s => s.type === 'open') || isPublicLobby; + const needsMultiplayer = shouldEnableLobbyNetworking(playerSlots, isPublicLobby); const { status: lobbyStatus, @@ -110,16 +122,12 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo }); // Multiplayer store - const { - setMultiplayer, - setConnected, - setHost, - } = useMultiplayerStore(); + const { setMultiplayer, setConnected, setHost } = useMultiplayerStore(); // Derived state - const hasOpenSlot = playerSlots.some(s => s.type === 'open'); + const hasOpenSlot = playerSlots.some((s) => s.type === 'open'); const hasGuests = guests.length > 0; - const guestSlotCount = playerSlots.filter(s => s.isGuest).length; + const guestSlotCount = playerSlots.filter((s) => s.isGuest).length; const isGuestMode = lobbyStatus === 'connected' && !isHost; // When connected as guest, set up multiplayer store @@ -135,7 +143,7 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo // When hosting with connected guests, set up multiplayer // Note: Data channels are set up via addPeer() in useMultiplayer.ts useEffect(() => { - if (isHost && guests.some(g => g.dataChannel)) { + if (isHost && guests.some((g) => g.dataChannel)) { setMultiplayer(true); setConnected(true); setHost(true); @@ -145,7 +153,7 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo // Send lobby state to guests whenever it changes (host only) useEffect(() => { if (!isHost) return; - const hasConnectedGuests = guests.some(g => g.dataChannel?.readyState === 'open'); + const hasConnectedGuests = guests.some((g) => g.dataChannel?.readyState === 'open'); if (!hasConnectedGuests) return; const lobbyState: LobbyState = { @@ -157,7 +165,16 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo }; sendLobbyState(lobbyState); - }, [isHost, guests, playerSlots, selectedMapId, startingResources, gameSpeed, fogOfWar, sendLobbyState]); + }, [ + isHost, + guests, + playerSlots, + selectedMapId, + startingResources, + gameSpeed, + fogOfWar, + sendLobbyState, + ]); // Publish public lobby listing when checkbox is enabled (host only) useEffect(() => { @@ -167,8 +184,8 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo const map = ALL_MAPS[selectedMapId]; if (!map) return; - const openSlots = playerSlots.filter(s => s.type === 'open').length; - const activeSlots = playerSlots.filter(s => s.type === 'human' || s.type === 'ai').length; + const openSlots = playerSlots.filter((s) => s.type === 'open').length; + const activeSlots = playerSlots.filter((s) => s.type === 'human' || s.type === 'ai').length; publishPublicListing({ hostName: playerName || 'Unknown Host', @@ -187,7 +204,16 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo const interval = setInterval(publish, 60000); return () => clearInterval(interval); - }, [isHost, isPublicLobby, lobbyStatus, lobbyCode, playerSlots, playerName, selectedMapId, publishPublicListing]); + }, [ + isHost, + isPublicLobby, + lobbyStatus, + lobbyCode, + playerSlots, + playerName, + selectedMapId, + publishPublicListing, + ]); // Register game start callback (guest only) useEffect(() => { @@ -215,14 +241,20 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo }); }, [isHost, onGameStart, receivedLobbyState, mySlotId, startGame, router]); - // Compute display values based on mode - const displayPlayerSlots = isGuestMode && receivedLobbyState ? receivedLobbyState.playerSlots : playerSlots; - const displayMapId = isGuestMode && receivedLobbyState ? receivedLobbyState.selectedMapId : selectedMapId; - const displayStartingResources = isGuestMode && receivedLobbyState ? receivedLobbyState.startingResources : startingResources; - const displayGameSpeed = isGuestMode && receivedLobbyState ? receivedLobbyState.gameSpeed : gameSpeed; - const displayFogOfWar = isGuestMode && receivedLobbyState ? receivedLobbyState.fogOfWar : fogOfWar; - const displayActivePlayerCount = displayPlayerSlots.filter(s => s.type === 'human' || s.type === 'ai').length; + const displayPlayerSlots = + isGuestMode && receivedLobbyState ? receivedLobbyState.playerSlots : playerSlots; + const displayMapId = + isGuestMode && receivedLobbyState ? receivedLobbyState.selectedMapId : selectedMapId; + const displayStartingResources = + isGuestMode && receivedLobbyState ? receivedLobbyState.startingResources : startingResources; + const displayGameSpeed = + isGuestMode && receivedLobbyState ? receivedLobbyState.gameSpeed : gameSpeed; + const displayFogOfWar = + isGuestMode && receivedLobbyState ? receivedLobbyState.fogOfWar : fogOfWar; + const displayActivePlayerCount = displayPlayerSlots.filter( + (s) => s.type === 'human' || s.type === 'ai' + ).length; return { displayPlayerSlots, @@ -239,7 +271,7 @@ export function useLobbySync({ playerName, isPublicLobby }: UseLobbyOptions): Lo lobbyCode, lobbyError, - guests: guests.map(g => ({ pubkey: g.pubkey, name: g.name, slotId: g.slotId })), + guests: guests.map((g) => ({ pubkey: g.pubkey, name: g.name, slotId: g.slotId })), hasOpenSlot, hasGuests, guestSlotCount, diff --git a/src/hooks/useMultiplayer.ts b/src/hooks/useMultiplayer.ts index 18459c6d..f9a0f172 100644 --- a/src/hooks/useMultiplayer.ts +++ b/src/hooks/useMultiplayer.ts @@ -18,13 +18,19 @@ import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey } from 'nost import { debugNetworking } from '@/utils/debugLogger'; import type { Filter, NostrEvent } from 'nostr-tools'; import { getRelays } from '@/engine/network/p2p/NostrRelays'; -import type { PlayerSlot, StartingResources, GameSpeed } from '@/store/gameSetupStore'; +import { + useGameSetupStore, + type PlayerSlot, + type StartingResources, + type GameSpeed, +} from '@/store/gameSetupStore'; import { useMultiplayerStore } from '@/store/multiplayerStore'; import { CommandSigningManager, getCommandSigningManager, resetCommandSigningManager, } from '@/engine/network/CommandSigning'; +import { shouldPreserveLobbySessionOnUnmount } from '@/app/game/setup/lobbySessionPolicy'; // Short code alphabet (no confusing chars) const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ'; @@ -248,6 +254,7 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { // Store joined lobby info for reconnection const joinedCodeRef = useRef(null); const joinedNameRef = useRef(null); + const hostPeerIdRef = useRef(null); // Track when reconnection is in progress to prevent state conflicts with multiplayerStore const isReconnectingRef = useRef(false); const [mySlotId, setMySlotId] = useState(null); @@ -268,6 +275,62 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { const onGuestLeaveRef = useRef(onGuestLeave); onGuestLeaveRef.current = onGuestLeave; + const cleanupLobbySession = useCallback(() => { + try { + subRef.current?.close(); + subRef.current = null; + } catch { + /* ignore */ + } + + if (joinTimeoutRef.current) { + clearTimeout(joinTimeoutRef.current); + joinTimeoutRef.current = null; + } + + if (pcRef.current) { + try { + pcRef.current.close(); + } catch { + /* ignore */ + } + pcRef.current = null; + } + + guestsRef.current.forEach((guest) => { + try { + guest.pc.close(); + } catch { + /* ignore */ + } + }); + + // Don't explicitly close the pool - nostr-tools throws unhandled errors + // when websockets are already closing. Let browser garbage collect instead. + poolRef.current = null; + secretKeyRef.current = null; + pubkeyRef.current = null; + relaysRef.current = []; + joinedCodeRef.current = null; + joinedNameRef.current = null; + hostPeerIdRef.current = null; + gameStartCallbackRef.current = null; + isReconnectingRef.current = false; + + resetCommandSigningManager(); + signingManagerRef.current = null; + }, []); + + const cleanupLobbyForEffectExit = useCallback(() => { + if (shouldPreserveLobbySessionOnUnmount(useGameSetupStore.getState().gameStarted)) { + debugNetworking.log('[Lobby] Preserving active session across route transition to /game'); + return; + } + + useMultiplayerStore.getState().setSessionCleanupCallback(null); + cleanupLobbySession(); + }, [cleanupLobbySession]); + // Handle incoming messages on the host connection (guest mode) useEffect(() => { if (!hostConnection) return; @@ -303,11 +366,13 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { // Initialize lobby (host mode) - only when enabled useEffect(() => { + useMultiplayerStore.getState().setSessionCleanupCallback(cleanupLobbySession); + // Skip initialization in single-player mode if (!enabled) { setStatus('disabled'); setLobbyCode(null); - return; + return cleanupLobbyForEffectExit; } let mounted = true; @@ -546,38 +611,9 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { return () => { mounted = false; - // Cleanup subscriptions - close quietly - try { - subRef.current?.close(); - subRef.current = null; - } catch { - /* ignore */ - } - // Clear any pending join timeout to prevent stale state updates - if (joinTimeoutRef.current) { - clearTimeout(joinTimeoutRef.current); - joinTimeoutRef.current = null; - } - // Don't explicitly close the pool - nostr-tools throws unhandled errors - // when websockets are already closing. Let browser garbage collect instead. - poolRef.current = null; - // Clear refs - secretKeyRef.current = null; - pubkeyRef.current = null; - relaysRef.current = []; - // Close peer connections - guestsRef.current.forEach((g) => { - try { - g.pc.close(); - } catch { - /* ignore */ - } - }); - // Reset command signing manager - resetCommandSigningManager(); - signingManagerRef.current = null; + cleanupLobbyForEffectExit(); }; - }, [enabled, onGuestJoin]); // Re-initialize when enabled changes or guest handler changes + }, [enabled, onGuestJoin, cleanupLobbySession, cleanupLobbyForEffectExit]); // Re-initialize when enabled changes or guest handler changes const joinLobby = useCallback(async (code: string, playerName: string) => { try { @@ -643,6 +679,7 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { try { const data = JSON.parse(event.content); const hostPubkey = event.pubkey; + hostPeerIdRef.current = hostPubkey; // Create peer connection const pc = new RTCPeerConnection({ iceServers: getIceServersSync() }); @@ -969,7 +1006,10 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { } return () => { - // Clean up on unmount + if (shouldPreserveLobbySessionOnUnmount(useGameSetupStore.getState().gameStarted)) { + return; + } + useMultiplayerStore.getState().setReconnectCallback(null); isReconnectingRef.current = false; }; @@ -979,12 +1019,12 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { useEffect(() => { if (hostConnection && hostConnection.readyState === 'open') { const store = useMultiplayerStore.getState(); - const hostPubkey = pubkeyRef.current ? `host-${pubkeyRef.current.slice(0, 8)}` : 'host'; + const hostPeerId = hostPeerIdRef.current ?? 'host'; debugNetworking.log('[Lobby] Syncing host connection to multiplayerStore'); // Use addPeer for consistency with multi-peer architecture - store.addPeer(hostPubkey, hostConnection); + store.addPeer(hostPeerId, hostConnection); store.setMultiplayer(true); store.setHost(false); } @@ -997,7 +1037,7 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { } const store = useMultiplayerStore.getState(); - const hostPubkey = pubkeyRef.current ? `host-${pubkeyRef.current.slice(0, 8)}` : 'host'; + const hostPeerId = hostPeerIdRef.current ?? 'host'; // Find the host's slot - the first human player that isn't a guest const hostSlot = receivedLobbyState.playerSlots.find( @@ -1005,8 +1045,8 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn { ); if (hostSlot) { - debugNetworking.log(`[Lobby] Setting peer-slot mapping: ${hostPubkey} -> ${hostSlot.id}`); - store.setPeerSlotMapping(hostPubkey, hostSlot.id); + debugNetworking.log(`[Lobby] Setting peer-slot mapping: ${hostPeerId} -> ${hostSlot.id}`); + store.setPeerSlotMapping(hostPeerId, hostSlot.id); } }, [hostConnection, receivedLobbyState]); diff --git a/src/store/multiplayerStore.ts b/src/store/multiplayerStore.ts index 47b6ae70..84c3fb80 100644 --- a/src/store/multiplayerStore.ts +++ b/src/store/multiplayerStore.ts @@ -104,6 +104,7 @@ export interface MultiplayerState { // Reconnection callback (set by lobby hook) reconnectCallback: (() => Promise) | null; + sessionCleanupCallback: (() => void) | null; // Called after successful reconnection to trigger game-level sync onReconnectedCallback: (() => void) | null; @@ -127,6 +128,7 @@ export interface MultiplayerState { setRemotePeerId: (id: string | null) => void; setDataChannel: (channel: RTCDataChannel | null) => void; setReconnectCallback: (callback: (() => Promise) | null) => void; + setSessionCleanupCallback: (callback: (() => void) | null) => void; setOnReconnectedCallback: (callback: (() => void) | null) => void; // Multi-peer actions @@ -216,6 +218,7 @@ const initialState = { dataChannel: null, peerChannels: new Map(), reconnectCallback: null, + sessionCleanupCallback: null, onReconnectedCallback: null, messageHandlers: [] as ((data: unknown) => void)[], // Latency measurement @@ -255,6 +258,7 @@ export const useMultiplayerStore = create((set, get) => ({ } }, setReconnectCallback: (callback) => set({ reconnectCallback: callback }), + setSessionCleanupCallback: (callback) => set({ sessionCleanupCallback: callback }), setOnReconnectedCallback: (callback) => set({ onReconnectedCallback: callback }), // Multi-peer management for 8-player support @@ -1021,7 +1025,7 @@ export const useMultiplayerStore = create((set, get) => ({ }, reset: () => { - const { dataChannel, pingInterval, peerChannels } = get(); + const { dataChannel, pingInterval, peerChannels, sessionCleanupCallback } = get(); // Close all peer channels with proper cleanup for (const peer of peerChannels.values()) { @@ -1053,6 +1057,12 @@ export const useMultiplayerStore = create((set, get) => ({ clearInterval(pingInterval); } + try { + sessionCleanupCallback?.(); + } catch (e) { + debugNetworking.error('[Multiplayer] Failed to clean up preserved lobby session:', e); + } + set({ ...initialState, messageHandlers: [], diff --git a/tests/app/game/GameLoadingFallback.test.ts b/tests/app/game/GameLoadingFallback.test.ts new file mode 100644 index 00000000..c470ea9a --- /dev/null +++ b/tests/app/game/GameLoadingFallback.test.ts @@ -0,0 +1,17 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { GameLoadingFallback } from '@/app/game/GameLoadingFallback'; + +describe('GameLoadingFallback', () => { + it('renders an immediate loading shell with a progress bar', () => { + const html = renderToStaticMarkup( + createElement(GameLoadingFallback, { status: 'Synchronizing battlefield state' }) + ); + + expect(html).toContain('VOIDSTRIKE'); + expect(html).toContain('Synchronizing battlefield state'); + expect(html).toContain('role="progressbar"'); + expect(html).toContain('Loading progress'); + }); +}); diff --git a/tests/app/game/setup/lobbySessionPolicy.test.ts b/tests/app/game/setup/lobbySessionPolicy.test.ts new file mode 100644 index 00000000..97ac8ed6 --- /dev/null +++ b/tests/app/game/setup/lobbySessionPolicy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + shouldEnableLobbyNetworking, + shouldPreserveLobbySessionOnUnmount, +} from '@/app/game/setup/lobbySessionPolicy'; +import type { PlayerSlot } from '@/store/gameSetupStore'; + +function createSlot(overrides: Partial): PlayerSlot { + return { + id: 'player1', + type: 'human', + faction: 'dominion', + colorId: 'blue', + aiDifficulty: 'medium', + name: 'Player 1', + team: 0, + ...overrides, + }; +} + +describe('lobbySessionPolicy', () => { + it('enables lobby networking when the host exposes an open slot', () => { + expect( + shouldEnableLobbyNetworking( + [createSlot({ id: 'player1' }), createSlot({ id: 'player2', type: 'open' })], + false + ) + ).toBe(true); + }); + + it('keeps lobby networking enabled after an open slot is filled by a guest', () => { + expect( + shouldEnableLobbyNetworking( + [createSlot({ id: 'player1' }), createSlot({ id: 'player2', isGuest: true })], + false + ) + ).toBe(true); + }); + + it('disables lobby networking for a local-only setup', () => { + expect( + shouldEnableLobbyNetworking( + [createSlot({ id: 'player1' }), createSlot({ id: 'player2', type: 'ai' })], + false + ) + ).toBe(false); + }); + + it('always enables lobby networking for public lobbies', () => { + expect( + shouldEnableLobbyNetworking( + [createSlot({ id: 'player1' }), createSlot({ id: 'player2', type: 'ai' })], + true + ) + ).toBe(true); + }); + + it('preserves the session only during a real game transition', () => { + expect(shouldPreserveLobbySessionOnUnmount(true)).toBe(true); + expect(shouldPreserveLobbySessionOnUnmount(false)).toBe(false); + }); +}); diff --git a/tests/engine/core/multiplayerMessageHandling.test.ts b/tests/engine/core/multiplayerMessageHandling.test.ts new file mode 100644 index 00000000..0ec0aec1 --- /dev/null +++ b/tests/engine/core/multiplayerMessageHandling.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { shouldHandleMultiplayerMessagesOnMainThread } from '@/engine/core/multiplayerMessageHandling'; + +describe('shouldHandleMultiplayerMessagesOnMainThread', () => { + it('returns false when multiplayer is disabled', () => { + expect( + shouldHandleMultiplayerMessagesOnMainThread({ + isMultiplayer: false, + multiplayerMessageHandling: 'main-thread', + }) + ).toBe(false); + }); + + it('returns true for direct main-thread multiplayer handling', () => { + expect( + shouldHandleMultiplayerMessagesOnMainThread({ + isMultiplayer: true, + multiplayerMessageHandling: 'main-thread', + }) + ).toBe(true); + }); + + it('returns false when the worker owns multiplayer command handling', () => { + expect( + shouldHandleMultiplayerMessagesOnMainThread({ + isMultiplayer: true, + multiplayerMessageHandling: 'worker', + }) + ).toBe(false); + }); +}); diff --git a/tests/store/multiplayerStore.test.ts b/tests/store/multiplayerStore.test.ts new file mode 100644 index 00000000..0589ed78 --- /dev/null +++ b/tests/store/multiplayerStore.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useMultiplayerStore } from '@/store/multiplayerStore'; + +describe('multiplayerStore.reset', () => { + afterEach(() => { + useMultiplayerStore.getState().reset(); + }); + + it('runs the preserved session cleanup callback on reset', () => { + const cleanup = vi.fn(); + + useMultiplayerStore.getState().setSessionCleanupCallback(cleanup); + useMultiplayerStore.getState().reset(); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(useMultiplayerStore.getState().sessionCleanupCallback).toBeNull(); + }); +});