diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a74ebe..15914de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.24.0 — 2026-09-20 09:48 +- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists +- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again +- Tests: unit — `lib/sim/engine.test.ts`: the auto-slow describe block rewritten for pause semantics (last plane lands → `paused === true` AND stored `timeScale` UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). `lib/game/actions.test.ts`: new test — dispatching on a paused sim clears `paused` and leaves `timeScale` untouched. E2E — `e2e/auto-slow.spec.ts` renamed to `e2e/pause-on-landing.spec.ts` and reworked: after the starter lands, asserts `simState.paused === true` AND `timeScale` still 1000 (NOT reset), then a subsequent `/fly` dispatch clears `paused` on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec `setScale` helper (`actions`, `hover`, `map-interactions`, `relocation`, `right-pane`) now POSTs `{ timeScale, paused: false }` — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (`sim.spec.ts` / `panes.spec.ts` freeze tests, marker-click freezes) are untouched +- Verified: `tsc --noEmit` clean; unit suite green locally (291); `npx playwright test --list` = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack + ## v0.23.2 — 2026-09-20 09:15 - **On-board package hover previews the route from the carrier's current position (ISS-040).** Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder `hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao)` in `lib/mapData.ts` (sibling of `hoverRouteGeoJson`, same shape/conventions; empty collection when the destination is missing); `components/Map.tsx`'s `applyHoverRef` looks up the carrier via the hover's `planeId` and uses it, falling back to the ICAO trace when no carrier is in state. The `[state]` effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles - Tests: unit — `lib/mapData.test.ts` gains a `hoverTraceGeoJson` block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — `e2e/hover.spec.ts`: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 77df59f..60dd141 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -66,8 +66,10 @@ async function postSim(page: Page, body: Record): Promise // Set the time scale and VERIFY from the response that it stuck — if something external ever // resets the live instance's controls mid-test, fail loudly here instead of timing out later. +// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must +// resume it or the clock stays frozen and the next wait hangs. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); @@ -118,7 +120,7 @@ async function flyHomeViaApi(page: Page): Promise { plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { - // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); @@ -225,8 +227,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100; expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6); - // Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-011: the - // arrival above auto-slown the sim to 1×, so re-set SCALE for the return leg. + // Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the + // arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause). await setScale(page, SCALE); const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); expect(homeRes.ok()).toBeTruthy(); @@ -314,8 +316,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned'); expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing - // Restore: refuel + fly home. ISS-011: the LEMD arrival above auto-slown the sim to 1×, so - // re-set SCALE for the return leg (the expiry wait's scale no longer persists past a landing). + // Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so + // re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept). await page.request.post('/api/planes/plane-starter/refuel'); await setScale(page, SCALE); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); @@ -355,8 +357,8 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { res = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(res.status()).toBe(409); - // Fly home from the hop so the fuel section starts at the hub. ISS-011: the hop arrival above - // auto-slown the sim to 1×, so re-set SCALE for this leg. + // Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above + // paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(res.ok()).toBeTruthy(); @@ -380,7 +382,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!; expect(atFar.locationIcao).toBe(FAR); // The return leg fits: the weighted burn over it is less than what's left in the tank. - // ISS-011: the FAR arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. + // ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(backRes.ok()).toBeTruthy(); diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 9733437..ccd9a3c 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -55,9 +55,10 @@ async function postSim(page: Page, body: unknown) { } // Set the time scale and VERIFY from the response that it stuck — a silent reset would -// otherwise surface as an unexplained landing-wait timeout. +// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a +// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); @@ -95,7 +96,7 @@ async function flyHomeViaApi(page: Page): Promise { .toBe(true); } if (starter.locationIcao !== HUB) { - // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); @@ -487,7 +488,7 @@ test('hovering an on-board block of a flying plane highlights its route', async }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) .toBe(true); - // ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. + // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); @@ -579,7 +580,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from c }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) .toBe(true); - // ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. + // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index e55c355..0369f8d 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -69,9 +69,10 @@ async function postSim(page: Page, body: unknown) { } // Set the time scale and VERIFY from the response that it stuck — a silent reset would -// otherwise surface as an unexplained landing-wait timeout. +// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a +// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); diff --git a/e2e/auto-slow.spec.ts b/e2e/pause-on-landing.spec.ts similarity index 72% rename from e2e/auto-slow.spec.ts rename to e2e/pause-on-landing.spec.ts index 46d7b37..3d35bb5 100644 --- a/e2e/auto-slow.spec.ts +++ b/e2e/pause-on-landing.spec.ts @@ -1,10 +1,11 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; -// v0.19.0 — ISS-011: when the LAST plane lands (no plane remains in flight) the sim auto-slows -// to 1×, server-side and persisted on the sim_state row so the UI slider reflects it on the next -// snapshot. Arrival-triggered by design: a grounded fleet at high scale is left alone, there is no -// auto-restore — the player speeds up manually again (asserted below). +// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses +// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight +// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses → +// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded +// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause. const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts) const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) @@ -33,8 +34,10 @@ async function postSim(page: Page, body: Record): Promise // Set the time scale and VERIFY from the response that it stuck — if something external ever // resets the live instance's controls mid-test, fail loudly here instead of timing out later. +// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must +// resume it or the clock stays frozen and the next wait hangs. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as SimState; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); @@ -75,7 +78,7 @@ async function flyHomeViaApi(page: Page): Promise { plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { - // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); @@ -93,8 +96,8 @@ test.afterEach(async ({ page }) => { test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file -test('auto-slow to 1× when the last plane lands; manual speed-up still works (ISS-011)', async ({ page }) => { - test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg +test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF const nearest = await nearestToHub(page); @@ -117,14 +120,19 @@ test('auto-slow to 1× when the last plane lands; manual speed-up still works (I }, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` }) .toBe(true); - // …and the server has auto-slown to 1× — persisted on sim_state, so this very snapshot shows it. - expect(landedBody!.simState.timeScale).toBe(1); + // …and the server paused the sim — persisted on sim_state, so this very snapshot shows it… + expect(landedBody!.simState.paused).toBe(true); + // …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch. + expect(landedBody!.simState.timeScale).toBe(SCALE); - // Manual override still works: POST /api/sim {timeScale: 1000} → confirmed by the response… - const post = await page.request.post('/api/sim', { data: { timeScale: SCALE } }); - expect(post.status()).toBe(200); - expect(((await post.json()) as SimState).timeScale).toBe(SCALE); - // …and by a fresh snapshot (no auto-restore of the previous scale — and no re-slow either, - // since nothing is in flight: the trigger is arrival-based, not idle-enforcement). - expect((await getState(page)).simState.timeScale).toBe(SCALE); + // A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full + // post-action snapshot, so no poll race with the client's next tick. + const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); + expect(fly2.status()).toBe(200); + const resumedBody = (await fly2.json()) as StateBody; + expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause… + expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect + + // Let the return leg finish so cleanup starts from a grounded plane. + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available'); }); diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index f5a0554..28a88f0 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -31,8 +31,10 @@ async function postSim(page: Page, body: Record): Promise // Set the time scale and VERIFY from the response that it stuck — if something external ever // resets the live instance's controls mid-test, fail loudly here instead of timing out later. +// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must +// resume it or the clock stays frozen and the next wait hangs. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); @@ -72,7 +74,7 @@ async function flyHomeViaApi(page: Page): Promise { plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { - // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 6a19213..bce694c 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -69,9 +69,10 @@ async function postSim(page: Page, body: unknown) { } // Set the time scale and VERIFY from the response that it stuck — a silent reset would -// otherwise surface as an unexplained landing-wait timeout. +// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a +// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen. async function setScale(page: Page, scale: number): Promise { - const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); + const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); @@ -176,7 +177,7 @@ async function flyHomeViaApi(page: Page): Promise { .toBe(true); } if (starter.locationIcao !== HUB) { - // ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg + // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); @@ -289,7 +290,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p await expect(onboardRow).not.toBeVisible({ timeout: 15_000 }); // Restore: fly home so the live instance stays in its onboarding state for the next run. - // ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. + // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(homeRes.status()).toBe(200); @@ -411,8 +412,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full'); // Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds - // exactly the leg requirement, so this also proves "just enough" is enough. ISS-011: the far-end - // arrival above auto-slown the sim to 1×, so re-set SCALE for this leg. + // exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end + // arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); await expect diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index f212971..8f5f35a 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -167,6 +167,18 @@ describe('flyPlane', () => { expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); }); + it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => { + const inst = await freshDb('fly-unpause.db'); + // The last landing pauses the sim at whatever scale was in use. + await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 }); + + await flyPlane(inst, 'plane-starter', 'LEMD'); + + const sim = (await inst.db.select().from(schema.simState))[0]; + expect(sim.paused).toBe(false); // dispatch auto-resumes… + expect(sim.timeScale).toBe(250); // …with the stored speed preserved + }); + it('rejects a flying plane with 409', async () => { const inst = await freshDb('fly-flying.db'); await inst.db.update(schema.planes) diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 5a208fb..4da1bc7 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -125,6 +125,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): lon: origin.lon, }) .where(eq(schema.planes.id, planeId)); + // ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside + // runAction(), so tick + unpause + dispatch are atomic under the sim lock. + await inst.db.update(schema.simState).set({ paused: false }); await persist(inst); } diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index fa68488..2045af4 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -237,7 +237,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { }); }); -describe('auto-slow to 1× when the last plane lands (ISS-011)', () => { +describe('pause on landing when the last plane lands (ISS-041)', () => { const FROM = 'EDDF'; const TO = 'LEMD'; @@ -259,19 +259,20 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => { return { inst, from, km, type }; } - it('last plane lands → stored timeScale becomes 1', async () => { - const { inst } = await setupLanding('autoslow-last.db', 1000); + it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => { + const { inst } = await setupLanding('pause-last.db', 1000); vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0) const sim = await tickOnce(inst); - expect(sim.timeScale).toBe(1); // persisted on the sim_state row… - expect((await inst.db.select().from(schema.simState))[0].timeScale).toBe(1); // …and visible to the next snapshot + expect(sim.paused).toBe(true); // persisted on the sim_state row… + expect((await inst.db.select().from(schema.simState))[0].paused).toBe(true); // …and visible to the next snapshot + expect(sim.timeScale).toBe(1000); // speed preserved — no auto-slow anymore (ISS-041) expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged }); - it('a landing while another plane is still flying → no change', async () => { - const { inst, from, type } = await setupLanding('autoslow-other.db', 1000); + it('a landing while another plane is still flying → no pause', async () => { + const { inst, from, type } = await setupLanding('pause-other.db', 1000); // A second plane on the same route departing at T0 — arrives far after this tick's clock. await inst.db.insert(schema.planes).values({ id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel, @@ -281,32 +282,35 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => { vi.setSystemTime(T0 + 2_000); const sim = await tickOnce(inst); - expect(sim.timeScale).toBe(1000); // p-b still in flight — no auto-slow + expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it) + expect(sim.timeScale).toBe(1000); const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status])); expect(byId.get('plane-starter')).toBe('available'); // the starter did land… expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle }); - it('already at 1× → stays 1', async () => { - const { inst } = await setupLanding('autoslow-one.db', 1); + it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => { + const { inst } = await setupLanding('pause-one.db', 1); vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival const sim = await tickOnce(inst); + expect(sim.paused).toBe(true); expect(sim.timeScale).toBe(1); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); }); it('paused sim with all planes landed → no change', async () => { - const inst = await freshDb('autoslow-paused.db'); + const inst = await freshDb('pause-paused.db'); // The fleet is fully grounded (the seeded starter) — a paused tick must not touch the - // scale even though "no plane in flight" holds: the trigger requires a landing this tick. + // controls even though "no plane in flight" holds: the trigger requires a landing this tick. await inst.db.update(schema.simState).set({ timeScale: 1000, paused: true }); vi.setSystemTime(T0 + 5_000); const sim = await tickOnce(inst); - expect(sim.timeScale).toBe(1000); // no landing processed while paused → trigger never fires + expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires + expect(sim.timeScale).toBe(1000); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); }); }); diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 5080acd..ecaffba 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -74,8 +74,8 @@ export async function tickOnce(inst: GameDb): Promise { let landedThisTick = 0; if (advanceMs > 0) { await expirePackages(inst, clockMs); - // ISS-011: remember the landings so the tick can auto-slow to 1× when this was - // the last plane in flight. + // ISS-041: remember the landings so the tick can pause when this was the last + // plane in flight. landedThisTick = await deliverPackages(inst, clockMs); await spawnOpenPackages(inst, clockMs); // AFTER delivery on purpose: a plane that just landed gets work spawned @@ -83,17 +83,18 @@ export async function tickOnce(inst: GameDb): Promise { await spawnCoveragePackages(inst, clockMs); } - const sets: Partial> = { + const sets: Partial> = { clockMs, lastRealTickMs: nowMs, }; - // Auto-slow (ISS-011): when ≥1 plane landed this tick and NO plane remains in flight, - // drop the stored timeScale to 1× — persisted here so the UI slider reflects it on the - // next snapshot. Arrival-triggered by design: a grounded fleet at high scale is left - // alone (the player speeds up manually again; there is no auto-restore). - if (landedThisTick > 0 && !sim.paused) { + // Pause on landing (ISS-041): when ≥1 plane landed this tick and NO plane remains in flight, + // pause the sim WITHOUT touching timeScale — speed is preserved for the next dispatch. + // Arrival-triggered by design: a grounded fleet at high scale is left alone (there is no + // auto-resume except starting a flight; see flyPlane). A paused tick never processes landings + // (advanceMs === 0), so the trigger can only fire on an advancing tick. + if (landedThisTick > 0) { const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying')); - if (flying.length === 0) sets.timeScale = MIN_TIME_SCALE; + if (flying.length === 0) sets.paused = true; } // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. diff --git a/package.json b/package.json index e88975c..3aa506e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.23.2", + "version": "0.24.0", "private": true, "scripts": { "dev": "next dev -p 3000",