feat: v0.19.0 — ISS-011 auto-slow to 1x when all planes have landed
CI / build + unit + e2e (pull_request) Successful in 6m14s

This commit is contained in:
2026-09-19 18:07:42 +00:00
parent f0602453ea
commit 86379fc9c6
9 changed files with 261 additions and 7 deletions
+74
View File
@@ -237,6 +237,80 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
});
describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
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.
async function setupLanding(name: string, timeScale: number) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
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.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, km, type };
}
it('last plane lands → stored timeScale becomes 1', async () => {
const { inst } = await setupLanding('autoslow-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((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);
// 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,
locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0,
});
vi.setSystemTime(T0 + 2_000);
const sim = await tickOnce(inst);
expect(sim.timeScale).toBe(1000); // p-b still in flight — no auto-slow
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);
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.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');
// 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.
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((await inst.db.select().from(schema.planes))[0].status).toBe('available');
});
});
describe('spawnOpenPackages', () => {
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
const inst = await freshDb('spawn.db');
+18 -2
View File
@@ -71,17 +71,33 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
const clockMs = Math.round(sim.clockMs + advanceMs);
let landedThisTick = 0;
if (advanceMs > 0) {
await expirePackages(inst, clockMs);
await deliverPackages(inst, clockMs);
// ISS-011: remember the landings so the tick can auto-slow to 1× 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
// at its new location within the same tick.
await spawnCoveragePackages(inst, clockMs);
}
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
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) {
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying'));
if (flying.length === 0) sets.timeScale = MIN_TIME_SCALE;
}
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
await inst.db.update(schema.simState).set(sets);
await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing after tick');