Slow sim to 1× on landing and resume last selected speed (ISS-057, v0.30.0)
CI / build + unit + e2e (pull_request) Successful in 8m57s

This commit is contained in:
2026-09-20 20:27:59 +00:00
parent 5e7ec903d3
commit c9e9113a45
13 changed files with 151 additions and 73 deletions
+5 -1
View File
@@ -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 () => {
+8 -1
View File
@@ -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
+4
View File
@@ -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;
+22 -6
View File
@@ -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 () => {
+8 -3
View File
@@ -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<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { 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);
}
+3 -2
View File
@@ -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', () => {
+4 -2
View File
@@ -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 },
};
}
+27 -23
View File
@@ -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');
});
});
+24 -15
View File
@@ -75,8 +75,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
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<schema.SimStateRow> {
await spawnCoveragePackages(inst, clockMs);
}
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale' | 'paused'>> = {
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
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<num
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
// leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's pause check on purpose —
// a chained plane counts as still flying there, so chaining never pauses early.
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes),
@@ -320,13 +322,20 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
// 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<schema.SimStateRow> {
return withLock(async () => {
const inst = await getGameDb();
await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
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);