From c9e9113a45c3f5ced1ca0999b3956c4e469fb83b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 20 Sep 2026 20:27:59 +0000 Subject: [PATCH] =?UTF-8?q?Slow=20sim=20to=201=C3=97=20on=20landing=20and?= =?UTF-8?q?=20resume=20last=20selected=20speed=20(ISS-057,=20v0.30.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +++ app/api/sim/route.test.ts | 17 +++++++ ...anding.spec.ts => slow-on-landing.spec.ts} | 39 ++++++++------- lib/db/client.test.ts | 6 ++- lib/db/client.ts | 9 +++- lib/db/schema.ts | 4 ++ lib/game/actions.test.ts | 28 ++++++++--- lib/game/actions.ts | 11 ++-- lib/game/seed.test.ts | 5 +- lib/game/seed.ts | 6 ++- lib/sim/engine.test.ts | 50 ++++++++++--------- lib/sim/engine.ts | 39 +++++++++------ package.json | 2 +- 13 files changed, 151 insertions(+), 73 deletions(-) rename e2e/{pause-on-landing.spec.ts => slow-on-landing.spec.ts} (75%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a6223..9807956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.30.0 — 2026-09-20 19:47 +- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore +- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1× +- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`) +- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work +- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged +- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack + ## v0.29.1 — 2026-09-20 18:22 - Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`. diff --git a/app/api/sim/route.test.ts b/app/api/sim/route.test.ts index 4412bae..c3b1b73 100644 --- a/app/api/sim/route.test.ts +++ b/app/api/sim/route.test.ts @@ -2,6 +2,8 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; import { POST } from './route'; let dir: string; @@ -84,6 +86,21 @@ describe('POST /api/sim', () => { await post({ timeScale: 1, paused: false }); // restore }); + it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => { + const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through + const row = async () => (await inst.db.select().from(schema.simState))[0]; + + await post({ timeScale: 250 }); + expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded… + + await post({ paused: true }); + expect((await row()).paused).toBe(true); + expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it + + await post({ timeScale: 1, paused: false }); // restore + expect((await row()).lastSelectedTimeScale).toBe(1); + }); + it('rejects invalid input with 400', async () => { for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) { const res = await post(body); diff --git a/e2e/pause-on-landing.spec.ts b/e2e/slow-on-landing.spec.ts similarity index 75% rename from e2e/pause-on-landing.spec.ts rename to e2e/slow-on-landing.spec.ts index 3d35bb5..ceab3df 100644 --- a/e2e/pause-on-landing.spec.ts +++ b/e2e/slow-on-landing.spec.ts @@ -1,11 +1,10 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; -// 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. +// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side +// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly) +// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at +// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×. 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) @@ -34,8 +33,8 @@ 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. +// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any +// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); @@ -78,8 +77,8 @@ async function flyHomeViaApi(page: Page): Promise { plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { - // ISS-041: the landing above paused the sim — setScale clears pause so the return leg - // fast-forwards instead of taking real-time hours. + // ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as + // lastSelectedTimeScale) 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 } }); expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) @@ -96,13 +95,13 @@ test.afterEach(async ({ page }) => { test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file -test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => { +test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', 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); - // Set 1000× via the API and verify it stuck. + // Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000). await setScale(page, SCALE); // Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed). @@ -120,18 +119,20 @@ test('last landing pauses the sim without changing speed; dispatch auto-resumes }, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` }) .toBe(true); - // …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); + // …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very + // snapshot shows it (the just-landed plane is on ground → slow; ISS-057). + expect(landedBody!.simState.paused).toBe(false); + expect(landedBody!.simState.timeScale).toBe(1); - // 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. + // A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN + // full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000 + // above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne) + // resumes it. 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 + expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause… + expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed // 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/lib/db/client.test.ts b/lib/db/client.test.ts index 5787b79..703d3dd 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -151,7 +151,8 @@ describe('openGameDb', () => { raw.exec(LEGACY_DDL); raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); raw.exec(`INSERT INTO account VALUES (20000, 10)`); - raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)'); + // time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale. + raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)'); const file = path.join(dir, 'legacy.db'); await fs.writeFile(file, Buffer.from(raw.export())); @@ -167,6 +168,9 @@ describe('openGameDb', () => { // …and the ALTER-default 0 was fixed up to a real timestamp so the first // tick-on-read resumes cleanly instead of taking one clamped catch-up jump. expect(sim?.lastRealTickMs).toBeGreaterThan(0); + // v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default. + expect(sim?.timeScale).toBe(50); + expect(sim?.lastSelectedTimeScale).toBe(50); }); it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => { diff --git a/lib/db/client.ts b/lib/db/client.ts index cb9ff97..987903e 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS sim_state ( clock_ms INTEGER NOT NULL, time_scale REAL NOT NULL, paused INTEGER NOT NULL, - last_real_tick_ms INTEGER NOT NULL DEFAULT 0 + last_real_tick_ms INTEGER NOT NULL DEFAULT 0, + last_selected_time_scale REAL NOT NULL DEFAULT 1 ); `; @@ -82,6 +83,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void { if (!cols('packages').has('weight_kg')) { raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000'); } + // v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN + // current time_scale so a legacy DB resumes at the speed it was running, not the default. + if (!cols('sim_state').has('last_selected_time_scale')) { + raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1'); + raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale'); + } } // v0.12.0 one-time migration: contracts world → packages world. Detected by the diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 0a8a7f3..ab339d2 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -61,6 +61,10 @@ export const simState = sqliteTable('sim_state', { // Real-time (Date.now()) of the last tick — the tick-on-read engine advances // clockMs by (now - lastRealTickMs) × timeScale, clamped per tick. lastRealTickMs: integer('last_real_tick_ms').notNull(), + // Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without + // touching this; flyPlane restores it when the last grounded plane takes off. Written by + // POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale. + lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1), }); export type PlaneRow = typeof planes.$inferSelect; diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 542f720..0401bf9 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -170,16 +170,32 @@ 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 }); + it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => { + const inst = await freshDb('fly-resume.db'); + // A landing slowed the sim to 1×; the player's last manually selected speed was 250. + await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 }); + + await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne + + const sim = (await inst.db.select().from(schema.simState))[0]; + expect(sim.paused).toBe(false); // dispatch still resumes a manual pause… + expect(sim.timeScale).toBe(250); // …and restores the last selected speed + }); + + it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => { + const inst = await freshDb('fly-stayslow.db'); + await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 }); + // A second grounded plane — the starter taking off leaves it on the ground. + await inst.db.insert(schema.planes).values({ + id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, + locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, + }); 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 + expect(sim.paused).toBe(false); + expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed }); it('rejects a flying plane with 409', async () => { diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 1c83a0e..fa5a614 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -125,9 +125,14 @@ 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 }); + // ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings + // slow to 1× instead of pausing), and restores the last manually selected speed when this + // dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls + // + dispatch are atomic under the sim lock. + const sets: Partial> = { paused: false }; + const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available')); + if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale; + await inst.db.update(schema.simState).set(sets); await persist(inst); } diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index de8d109..9d3585f 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -105,8 +105,9 @@ describe('buildSeed', () => { it('seeds the starting account and sim state', () => { const seed = seedWith(42); expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION }); - // lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0. - expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW }); + // lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0; + // lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057). + expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 }); }); it('is fully deterministic for a fixed RNG and inputs', () => { diff --git a/lib/game/seed.ts b/lib/game/seed.ts index 1038556..4772edb 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -62,7 +62,7 @@ export interface SeedData { plane: SeedPlane; packages: SeedPackage[]; account: { cash: number; reputation: number }; - simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number }; + simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number }; } // --- Tunables (documented so future revenue/cost work can build on them) --- @@ -225,6 +225,8 @@ export function buildSeed(input: { account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, // lastRealTickMs starts at the seed moment so the first tick-on-read advances // by ~0 instead of treating "time since install" as elapsed play time. - simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs }, + // lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been + // selected manually yet, so resume would restore 1× anyway. + simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 }, }; } diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index b1d70c3..47f9297 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -301,12 +301,13 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { }); }); -describe('pause on landing when the last plane lands (ISS-041)', () => { +describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => { const FROM = 'EDDF'; const TO = 'LEMD'; // Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 — - // any advancing tick past T0 finalizes it. timeScale is the value under test. + // any advancing tick past T0 finalizes it. timeScale is the value under test; + // lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored. async function setupLanding(name: string, timeScale: number) { const inst = await freshDb(name); const fc = await loadAirportsGeoJson(); @@ -315,7 +316,7 @@ describe('pause on landing when the last plane lands (ISS-041)', () => { const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const type = PLANE_TYPES.atr72; - await inst.db.update(schema.simState).set({ timeScale }); + await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale }); await inst.db.update(schema.planes) .set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel }) .where(eq(schema.planes.id, 'plane-starter')); @@ -323,20 +324,21 @@ describe('pause on landing when the last plane lands (ISS-041)', () => { return { inst, from, km, type }; } - it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => { - const { inst } = await setupLanding('pause-last.db', 1000); + it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => { + const { inst } = await setupLanding('slow-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.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(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down… + expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row… + expect(sim.timeScale).toBe(1); // …slowed to the floor… + expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume 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 pause', async () => { - const { inst, from, type } = await setupLanding('pause-other.db', 1000); + it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => { + const { inst, from, type } = await setupLanding('slow-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, @@ -346,26 +348,26 @@ describe('pause on landing when the last plane lands (ISS-041)', () => { vi.setSystemTime(T0 + 2_000); const sim = await tickOnce(inst); - expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it) - expect(sim.timeScale).toBe(1000); + expect(sim.paused).toBe(false); + expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading) 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 + expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed }); - it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => { - const { inst } = await setupLanding('pause-one.db', 1); + it('already at 1× → stays 1 (no-op slow)', async () => { + const { inst } = await setupLanding('slow-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.paused).toBe(false); expect(sim.timeScale).toBe(1); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); }); - it('a chained plane does NOT trigger the last-plane pause (ISS-043)', async () => { - const { inst } = await setupLanding('pause-chain.db', 1000); // full tank, arrival exactly T0 + it('a chained plane does NOT slow the sim (nothing is grounded)', async () => { + const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0 // A queued next hop with plenty of fuel → chains on landing instead of grounding. await inst.db.update(schema.planes) .set({ queue: JSON.stringify(['EDDV']) }) @@ -374,23 +376,25 @@ describe('pause on landing when the last plane lands (ISS-041)', () => { vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0) const sim = await tickOnce(inst); - expect(sim.paused).toBe(false); // still flying after the chain — no premature pause… + expect(sim.paused).toBe(false); + expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow… const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne expect(plane.destIcao).toBe('EDDV'); }); - it('paused sim with all planes landed → no change', async () => { - const inst = await freshDb('pause-paused.db'); + it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => { + const inst = await freshDb('slow-paused.db'); // The fleet is fully grounded (the seeded starter) — a paused tick must not touch the - // 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 }); + // controls even though "any plane on ground" holds: the trigger requires a landing this tick. + await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true }); vi.setSystemTime(T0 + 5_000); const sim = await tickOnce(inst); expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires expect(sim.timeScale).toBe(1000); + expect(sim.lastSelectedTimeScale).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 09d0deb..002870f 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -75,8 +75,7 @@ export async function tickOnce(inst: GameDb): Promise { let landedThisTick = 0; if (advanceMs > 0) { await expirePackages(inst, clockMs); - // ISS-041: remember the landings so the tick can pause when this was the last - // plane in flight. + // ISS-057: remember the landings so the tick can slow down when a plane is left on ground. landedThisTick = await deliverPackages(inst, clockMs); await spawnOpenPackages(inst, clockMs); // AFTER delivery on purpose: a plane that just landed gets work spawned @@ -84,18 +83,21 @@ export async function tickOnce(inst: GameDb): Promise { await spawnCoveragePackages(inst, clockMs); } - const sets: Partial> = { + const sets: Partial> = { clockMs, lastRealTickMs: nowMs, }; - // 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. + // Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded + // ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so + // any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain + // branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now; + // flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival- + // triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and + // a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an + // advancing tick — a manual speed change while planes sit grounded survives until the next landing. if (landedThisTick > 0) { - const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying')); - if (flying.length === 0) sets.paused = true; + const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available')); + if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE; } // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. @@ -132,8 +134,8 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise { const [planeRows, packageRows] = await Promise.all([ inst.db.select().from(schema.planes), @@ -320,13 +322,20 @@ export function runAction(fn: (inst: GameDb) => Promise): Promise { // Tick first so the returned state reflects current time, then apply the new // controls. timeScale is clamped to [1, 1000]; validation of raw input happens in -// the route handler (400 on non-number / non-boolean). +// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale +// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when +// the last grounded plane takes off. A {paused}-only post never touches it, so the +// last manual intent wins without an extra flag. export function applyTimeControls(input: SimControlsInput): Promise { return withLock(async () => { const inst = await getGameDb(); await tickOnce(inst); - const sets: Partial> = {}; - if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale); + const sets: Partial> = {}; + if (input.timeScale !== undefined) { + const scale = clampTimeScale(input.timeScale); + sets.timeScale = scale; + sets.lastSelectedTimeScale = scale; + } if (input.paused !== undefined) sets.paused = input.paused; if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets); await persist(inst); diff --git a/package.json b/package.json index cbf90d3..57e7137 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.29.1", + "version": "0.30.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0