From f793982ce5362c484fe34f1fadf8efdd612c3f0b Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 21:21:58 -0400 Subject: [PATCH 1/2] fix(world-lab): reset run counter after world setup --- .../src/components/world-lab.test.tsx | 84 +++++++++++++++++++ apps/world-lab/src/components/world-lab.tsx | 7 +- docs/TESTING.md | 3 + 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 9210624..5f89fc6 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1982,6 +1982,90 @@ describe('WorldLab', () => { } }, 15_000); + it('starts a fresh trail-hunter experiment after setup even when the prior experiment was at tick 50', async () => { + vi.useFakeTimers(); + const at50 = simulationSnapshotSchema.parse({ + ...initial, + tickNumber: 50, + turnNumber: 400, + experiment: { ...initial.experiment, totalCompletedTurns: 400 }, + }); + const fresh = simulationSnapshotSchema.parse({ + ...initial, + scenario: { + ...initial.scenario, + objectiveVersion: 'durable-influence-v3', + capabilities: { + ...initial.scenario.capabilities, + simulatedPlayerPressure: true, + }, + simulatedPlayer: { + enabled: true, + profile: 'trail-hunter-v1', + seed: 'reset-trail-hunter', + }, + }, + }); + let tickRequests = 0; + let appliedProfile: string | undefined; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/setup/preview')) + return jsonResponse(previewWorldSetup(JSON.parse(String(init?.body)))); + if (url.endsWith('/experiment/setup')) { + appliedProfile = JSON.parse(String(init?.body)).simulatedPlayer.profile; + return jsonResponse({ snapshot: fresh }); + } + if (url.includes('/tick?mutationId=') && init?.method === 'POST') { + tickRequests += 1; + return jsonResponse(completeTickResponse(fresh, 1)); + } + return jsonResponse(at50); + }), + ); + + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('Tick 50')).toBeInTheDocument(); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'World setup' })); + await user.click( + screen.getByRole('checkbox', { + name: 'Enable simulated player pressure', + }), + ); + await user.selectOptions( + screen.getByLabelText('Simulated player profile'), + 'trail-hunter-v1', + ); + await user.click(screen.getByRole('button', { name: 'Preview' })); + await user.click( + screen.getByRole('button', { name: 'Apply / Create Experiment' }), + ); + expect(appliedProfile).toBe('trail-hunter-v1'); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('Tick 0')).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText('Tick target'), '50'); + await user.click(screen.getByRole('button', { name: 'Run to tick 50' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(tickRequests).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + it('keeps run targets absolute and makes current or past targets unavailable', async () => { window.sessionStorage.setItem('hexzero.world-lab.run-target', '25'); const at50 = simulationSnapshotSchema.parse({ diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index b309f7d..d54718f 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -1372,13 +1372,8 @@ export function WorldLab() { } onClose={() => setSetupOpen(false)} onApplied={(next) => { - setSnapshot(next); + applySnapshot(next); setSelectedCell(null); - setSelectedAgentId((selected) => - next.world.agents.some(({ id }) => id === selected) - ? selected - : (next.world.agents[0]?.id ?? null), - ); setExportAgentIds((selected) => selected.filter((id) => next.world.agents.some((agent) => agent.id === id), diff --git a/docs/TESTING.md b/docs/TESTING.md index 86307e2..4f017f5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -67,6 +67,9 @@ these safe worker failure reasons. Cost display tests separate provider-reported cost from admission exposure and show that TypeSafe Jev monetary cost remains unknown without provider billing data. The browser suite checks that a long swarm activity log scrolls inside the fixed-height bottom dock. +World Lab component coverage also checks that applying a fresh Trail hunter +scenario after a tick-50 experiment resets the run counter so the next Run to +tick 50 command sends its first Game API tick request. Attempt-budget tests use deterministic providers and cover whole-roster tick admission, retry permits, cancellation finalization, and the distinction From f5dc973d0fbc677bca0be8ac50531828bab692d1 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 21:49:00 -0400 Subject: [PATCH 2/2] test(world-lab): make run reset regression deterministic --- .../src/components/world-lab.test.tsx | 106 ++++++++++-------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 5f89fc6..5829855 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1983,51 +1983,68 @@ describe('WorldLab', () => { }, 15_000); it('starts a fresh trail-hunter experiment after setup even when the prior experiment was at tick 50', async () => { - vi.useFakeTimers(); - const at50 = simulationSnapshotSchema.parse({ - ...initial, - tickNumber: 50, - turnNumber: 400, - experiment: { ...initial.experiment, totalCompletedTurns: 400 }, - }); - const fresh = simulationSnapshotSchema.parse({ - ...initial, - scenario: { - ...initial.scenario, - objectiveVersion: 'durable-influence-v3', - capabilities: { - ...initial.scenario.capabilities, - simulatedPlayerPressure: true, + try { + const at50 = simulationSnapshotSchema.parse({ + ...initial, + tickNumber: 50, + turnNumber: 400, + virtualTime: '2026-08-13T16:10:00.000Z', + lastTickIntervalMinutes: 5, + resolutionOrder: initial.world.agents.map(({ id }) => id), + experiment: { ...initial.experiment, totalCompletedTurns: 400 }, + }); + const fresh = simulationSnapshotSchema.parse({ + ...initial, + scenario: { + ...initial.scenario, + objectiveVersion: 'durable-influence-v3', + capabilities: { + ...initial.scenario.capabilities, + simulatedPlayerPressure: true, + }, + simulatedPlayer: { + enabled: true, + profile: 'trail-hunter-v1', + seed: 'reset-trail-hunter', + }, }, - simulatedPlayer: { - enabled: true, - profile: 'trail-hunter-v1', - seed: 'reset-trail-hunter', + world: { + ...initial.world, + simulatedPlayer: { + profile: 'trail-hunter-v1', + currentCell: initial.world.hexes[0]!.cell, + metrics: { + movements: 0, + cellsDisinfected: 0, + blockedDisinfections: 0, + }, + }, }, - }, - }); - let tickRequests = 0; - let appliedProfile: string | undefined; - vi.stubGlobal( - 'fetch', - vi.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith('/setup/preview')) - return jsonResponse(previewWorldSetup(JSON.parse(String(init?.body)))); - if (url.endsWith('/experiment/setup')) { - appliedProfile = JSON.parse(String(init?.body)).simulatedPlayer.profile; - return jsonResponse({ snapshot: fresh }); - } - if (url.includes('/tick?mutationId=') && init?.method === 'POST') { - tickRequests += 1; - return jsonResponse(completeTickResponse(fresh, 1)); - } - return jsonResponse(at50); - }), - ); + }); + let tickRequests = 0; + let appliedProfile: string | undefined; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/setup/preview')) + return jsonResponse( + previewWorldSetup(JSON.parse(String(init?.body))), + ); + if (url.endsWith('/experiment/setup')) { + appliedProfile = JSON.parse(String(init?.body)).simulatedPlayer + .profile; + return jsonResponse({ snapshot: fresh }); + } + if (url.includes('/tick?mutationId=') && init?.method === 'POST') { + tickRequests += 1; + return jsonResponse(completeTickResponse(fresh, 1)); + } + return jsonResponse(at50); + }), + ); - try { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const user = userEvent.setup(); render(); await act(async () => { await Promise.resolve(); @@ -2057,10 +2074,9 @@ describe('WorldLab', () => { expect(screen.getByText('Tick 0')).toBeInTheDocument(); await user.selectOptions(screen.getByLabelText('Tick target'), '50'); await user.click(screen.getByRole('button', { name: 'Run to tick 50' })); - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); + await waitFor(() => { + expect(tickRequests).toBe(1); }); - expect(tickRequests).toBe(1); } finally { vi.useRealTimers(); }