import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { openGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { SPECIALS } from '@/lib/game/specials'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { clampTimeScale, deliverPackages, expirePackages, finishRepairs, MAX_TICK_ELAPSED_MS, MAX_TIME_SCALE, MIN_TIME_SCALE, reputationGainFor, spawnCoveragePackages, spawnOpenPackages, tickOnce, } from './engine'; // Engine tests run against throwaway DB files (openGameDb) with fake timers so // Date.now() — the only wall-clock input to a tick — is fully controlled. The // public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered // by app/api/sim/route.test.ts and the e2e suite. let dir: string; const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open beforeAll(async () => { vi.useFakeTimers(); vi.setSystemTime(T0); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-')); }); afterAll(async () => { vi.useRealTimers(); await fs.rm(dir, { recursive: true, force: true }); }); async function freshDb(name: string) { vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now" return openGameDb(path.join(dir, name)); } describe('tickOnce — clock advance', () => { it('advances the clock by elapsed real ms × timeScale', async () => { const inst = await freshDb('scale.db'); vi.setSystemTime(T0 + 1_000); // one real second later await inst.db.update(schema.simState).set({ timeScale: 5 }); const sim = await tickOnce(inst); expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5 expect(sim.lastRealTickMs).toBe(T0 + 1_000); }); it('freezes the clock while paused but keeps tracking real time', async () => { const inst = await freshDb('pause.db'); await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 }); vi.setSystemTime(T0 + 3_600_000); // an hour of real pause let sim = await tickOnce(inst); expect(sim.clockMs).toBe(T0); // frozen — no advance at all expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // Unpause: resumes from where it stopped, with NO catch-up for the pause. await inst.db.update(schema.simState).set({ paused: false }); vi.setSystemTime(T0 + 3_600_500); sim = await tickOnce(inst); expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60 }); it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => { const inst = await freshDb('clamp.db'); vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour const sim = await tickOnce(inst); // timeScale is the seeded 1 expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward vi.setSystemTime(T0); // reset for later tests in this file }); it('does not advance when two ticks land in the same millisecond', async () => { const inst = await freshDb('same-ms.db'); vi.setSystemTime(T0 + 1_000); const a = await tickOnce(inst); const b = await tickOnce(inst); // no real time passed since `a` expect(b.clockMs).toBe(a.clockMs); }); }); describe('expirePackages', () => { it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { const inst = await freshDb('expiry.db'); const clock = T0 + 100_000; await inst.db.insert(schema.packages).values([ { id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 }, { id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 }, { id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 }, ]); const n = await expirePackages(inst, clock); expect(n).toBe(2); // the open and the loaded one const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status])); expect(byId.get('p-open-past')).toBe('expired'); expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot) expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips }); it('a deadline exactly at the clock counts as expired', async () => { const inst = await freshDb('expiry-edge.db'); await inst.db.insert(schema.packages).values({ id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0, }); expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive }); }); describe('deliverPackages (v0.12.0 arrival delivery)', () => { const FROM = 'EDDF'; const TO = 'LEMD'; async function setupFlight(name: string) { 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; // A manual flight (v0.12.0 fly): plane flying with dest_icao set and its fuel // frozen at departure — no contract involved anymore. await inst.db.update(schema.planes) .set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel }) .where(eq(schema.planes.id, 'plane-starter')); return { inst, from, to, km, type }; } it('does nothing before the arrival time', async () => { const { inst, km, type } = await setupFlight('deliver-early.db'); const accountBefore = (await inst.db.select().from(schema.account))[0]!; const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival expect(await deliverPackages(inst, clock)).toBe(0); const plane = (await inst.db.select().from(schema.planes))[0]; const account = (await inst.db.select().from(schema.account))[0]!; expect(plane.status).toBe('flying'); expect(account.cash).toBe(accountBefore.cash); // no award before landing }); it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db'); const accountBefore = (await inst.db.select().from(schema.account))[0]!; // Three packages on the plane: one in-deadline for TO, one expired for TO, // one loaded for a DIFFERENT airport (stays bound for a later leg). await inst.db.insert(schema.packages).values([ { id: 'p-pay', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers', cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-late', originIcao: FROM, destIcao: TO, reward: 7_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 3_600_000, status: 'expired', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, ]); const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); expect(plane.locationIcao).toBe(TO); expect(plane.lat).toBe(to.lat); expect(plane.lon).toBe(to.lon); // Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted // (v0.36.0) by the departure airport's weather vs the route heading. const w = weatherFor(FROM); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6); expect(plane.departureSimTime).toBeNull(); expect(plane.destIcao).toBeNull(); // flight context cleared on landing const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p])); expect(byId.get('p-pay')!.status).toBe('delivered'); expect(byId.get('p-late')!.status).toBe('expired'); // still expired — just unbound… expect(byId.get('p-late')!.planeId).toBeNull(); // …slot freed, no reward expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo… expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg const account = (await inst.db.select().from(schema.account))[0]!; expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out expect(account.reputation).toBeCloseTo( Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, 9, ); // A later tick must not award again — the plane is no longer 'flying'. expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0); const accountAfter = (await inst.db.select().from(schema.account))[0]!; expect(accountAfter.cash).toBe(account.cash); expect(accountAfter.reputation).toBe(account.reputation); }); it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => { const { inst, km } = await setupFlight('deliver-multi.db'); const accountBefore = (await inst.db.select().from(schema.account))[0]!; await inst.db.insert(schema.packages).values([ { id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, { id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, ]); const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); expect(await deliverPackages(inst, clock)).toBe(1); const account = (await inst.db.select().from(schema.account))[0]!; expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards expect(account.reputation).toBeCloseTo( Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100, 9, ); // rep per delivered package }); it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => { const { inst, km } = await setupFlight('deliver-special.db'); const accountBefore = (await inst.db.select().from(schema.account))[0]!; await inst.db.insert(schema.packages).values([ { id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, ]); const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); const account = (await inst.db.select().from(schema.account))[0]!; expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // Distance-based gain + the registry rep bonus — double-rounded like every other award. expect(account.reputation).toBeCloseTo( Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100, 9, ); }); it('leaves planes without route info alone', async () => { const inst = await freshDb('deliver-noroute.db'); // Flying plane whose dest_icao is missing — defensive skip, no crash. await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0 }); expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0); }); // --- Auto-chain on landing (v0.25.0, ISS-043) --- it('auto-chains to the next queued leg when fuel covers it', async () => { const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD const fc = await loadAirportsGeoJson(); const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties; const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon); // Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs // wind-adjusted (v0.36.0) exactly as deliverPackages computes them. const wFrom = weatherFor(FROM); const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1); const wTo = weatherFor(TO); // the chained leg departs from the arrival airport const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon)); expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel); await inst.db.update(schema.planes) .set({ queue: JSON.stringify(['EHAM', 'EDDV']) }) .where(eq(schema.planes.id, 'plane-starter')); const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); // chained — still airborne, no ground stop expect(plane.destIcao).toBe('EHAM'); // first queued leg is now the active one expect(plane.departureSimTime).toBe(clock); // departed at the arrival instant… expect(plane.locationIcao).toBe(TO); // …from the airport just landed at expect(plane.lat).toBe(to.lat); expect(plane.lon).toBe(to.lon); expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order }); it('auto-refuels a short tank and chains the next queued leg when cash allows (ISS-082)', async () => { const { inst, from, to, km, type } = await setupFlight('chain-autorefuel.db'); // Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty; the LEMD→EHAM hop needs a top-up. const w = weatherFor(FROM); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km); await inst.db.update(schema.planes) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); // Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted // requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity. const fc = await loadAirportsGeoJson(); const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties; const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran const wTo = weatherFor(TO); // the chained leg departs from the arrival airport const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon)); const nextRequiredL = requiredFuelFor( type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, nextP.lat, nextP.lon), ); const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const liters = Math.max(0, target - landedFuel); expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); // chained — the top-up funded it… expect(plane.destIcao).toBe('EHAM'); expect(plane.departureSimTime).toBe(clock); expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level const account = (await inst.db.select().from(schema.account))[0]!; expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost }); it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => { const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db'); // Same empty-tank landing as above, but the account is broke — the top-up is unaffordable. const w = weatherFor(FROM); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km); await inst.db.update(schema.planes) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.account).set({ cash: 0 }); const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); // grounded safely… expect(plane.destIcao).toBeNull(); expect(plane.departureSimTime).toBeNull(); expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0); }); it('grounds as before when the queue is empty', async () => { const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]' expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); // unchanged landing behavior… expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty }); it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => { const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD const fc = await loadAirportsGeoJson(); const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range // Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted, // exactly as deliverPackages computes it) — so no top-up could ever make it flyable. const wTo = weatherFor(TO); const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon)); const nextRequiredL = requiredFuelFor( type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, farP.lat, farP.lon), ); expect(nextRequiredL).toBeGreaterThan(type.maxFuel); await inst.db.update(schema.planes) .set({ queue: JSON.stringify(['FGSL']) }) .where(eq(schema.planes.id, 'plane-starter')); const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained… expect(plane.destIcao).toBeNull(); expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch }); it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => { const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD // Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a // zero-distance self-hop (the phantom hop). The guard drops it and grounds instead. await inst.db.update(schema.planes) .set({ queue: JSON.stringify(['LEMD', 'EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown… expect(plane.destIcao).toBeNull(); expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch }); }); describe('wear on landed legs (v0.39.0, ISS-069)', () => { const FROM = 'EDDF'; const TO = 'LEMD'; // Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above. async function setupFlight(name: string) { 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.planes) .set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel }) .where(eq(schema.planes.id, 'plane-starter')); return { inst, from, to, km, type }; } it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => { const { inst, km } = await setupFlight('wear-leg.db'); expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085) }); it('clamps at 0 across multiple legs', async () => { const { inst, from, to, km, type } = await setupFlight('wear-clamp.db'); // Start below one leg's wear so the first landing clamps… await inst.db.update(schema.planes) .set({ health: WEAR_PER_KM * km / 2 }) .where(eq(schema.planes.id, 'plane-starter')); expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); let plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.health).toBe(0); // clamped — never negative… // …and a second leg keeps it pinned at 0. const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties; const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon); await inst.db.update(schema.planes) .set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel }) .where(eq(schema.planes.id, 'plane-starter')); expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1); plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.health).toBe(0); // still clamped after the second leg }); it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => { const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD // Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves // enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken // guard can explain why it doesn't chain. await inst.db.update(schema.planes) .set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.health).toBe(0); // broken by this landing's wear… expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel… expect(plane.destIcao).toBeNull(); expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch }); }); describe('finishRepairs (v0.39.0, ISS-069)', () => { it('flips repairing planes back to available once the gate passes, clearing the gate', async () => { const inst = await freshDb('repair-finish.db'); await inst.db.update(schema.planes) .set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 }) .where(eq(schema.planes.id, 'plane-starter')); expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet… let plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('repairing'); expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared }); it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => { const inst = await freshDb('repair-tick.db'); await inst.db.update(schema.planes) .set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 }) .where(eq(schema.planes.id, 'plane-starter')); vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying await tickOnce(inst); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); expect(plane.repairUntilSimTime ?? null).toBeNull(); }); it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => { const inst = await freshDb('repair-curve.db'); // Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration. const gate = T0 + REPAIR_DURATION_MS; await inst.db.update(schema.planes) .set({ status: 'repairing', health: 42, repairUntilSimTime: gate }) .where(eq(schema.planes.id, 'plane-starter')); // Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50… expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate… let plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('repairing'); expect(plane.health).toBe(50); // …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put. const inst2 = await freshDb('repair-curve-floor.db'); await inst2.db.update(schema.planes) .set({ status: 'repairing', health: 42, repairUntilSimTime: gate }) .where(eq(schema.planes.id, 'plane-starter')); expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0); const plane2 = (await inst2.db.select().from(schema.planes))[0]; expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor // At the gate: exactly 100 and back in service. expect(await finishRepairs(inst, gate)).toBe(1); plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); expect(plane.health).toBe(100); expect(plane.repairUntilSimTime ?? null).toBeNull(); }); }); 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; // 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(); 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, 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')); return { inst, from, km, type }; } 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(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 → 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, locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0, }); vi.setSystemTime(T0 + 2_000); const sim = await tickOnce(inst); 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 — still slowed }); 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(false); expect(sim.timeScale).toBe(1); expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); }); 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']) }) .where(eq(schema.planes.id, 'plane-starter')); 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); 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('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 "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'); }); }); describe('spawnOpenPackages', () => { it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => { const inst = await freshDb('spawn.db'); const clock = T0 + 123_456; // Leave exactly 3 open packages (the rest of the seeded set is "done"). const all = await inst.db.select().from(schema.packages); for (const p of all.slice(3)) { await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id)); } const needed = await spawnOpenPackages(inst, clock, mulberry32(7)); expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3); const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES); const fc = await loadAirportsGeoJson(); const icaoSet = new Set(fc.features.map((f) => f.properties.icao)); for (const p of openRows.slice(3)) { // the spawned ones expect(p.createdSimTime).toBe(clock); // created at game time, not real time expect(icaoSet.has(p.originIcao)).toBe(true); expect(icaoSet.has(p.destIcao)).toBe(true); expect(p.originIcao).not.toBe(p.destIcao); expect(p.planeId).toBeNull(); // Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0). const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1; expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1); expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale); } // Already at target → nothing more to spawn. expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0); }); it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => { const a = await freshDb('spawn-a.db'); const b = await freshDb('spawn-b.db'); const clock = T0 + 999; // Wipe all packages in both so both spawn from zero with the same RNG. for (const inst of [a, b]) { const rows = await inst.db.select().from(schema.packages); for (const r of rows) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, r.id)); } } expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); const strip = (rows: schema.PackageRow[]) => rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); expect(strip(await a.db.select().from(schema.packages))).toEqual( strip(await b.db.select().from(schema.packages)), ); }); }); describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => { // The seeded starter plane is grounded at EDDF with no seeded package from // there, so a fresh DB always has exactly one uncovered airport: EDDF. it('spawns an open package originating at the airport of each uncovered grounded plane', async () => { const inst = await freshDb('coverage.db'); const clock = T0 + 5_000; // Wipe all seeded packages so coverage is the only source of new ones. for (const p of await inst.db.select().from(schema.packages)) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } const n = await spawnCoveragePackages(inst, clock, mulberry32(7)); expect(n).toBe(1); // only EDDF is uncovered (single grounded plane) const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); expect(openRows.length).toBe(1); expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport expect(openRows[0].createdSimTime).toBe(clock); }); it('does nothing when every grounded airport already has an open package', async () => { const inst = await freshDb('coverage-covered.db'); for (const p of await inst.db.select().from(schema.packages)) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } // One open package from EDDF covers the starter plane's airport. await inst.db.insert(schema.packages).values({ id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0, }); expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added }); it('spawns one package per uncovered airport when planes sit at several airports', async () => { const inst = await freshDb('coverage-multi.db'); for (const p of await inst.db.select().from(schema.packages)) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } // A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn. await inst.db.insert(schema.planes).values([ { id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null }, { id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null }, ]); const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7)); expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD const origins = (await inst.db.select().from(schema.packages)) .filter((p) => p.status === 'open') .map((p) => p.originIcao) .sort(); expect(origins).toEqual(['EDDF', 'LEMD']); }); it('ignores flying planes — they cannot take new work until they land', async () => { const inst = await freshDb('coverage-flying.db'); for (const p of await inst.db.select().from(schema.packages)) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes }); it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => { const inst = await freshDb('coverage-tick.db'); vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1 await tickOnce(inst); const fromEdff = (await inst.db.select().from(schema.packages)).filter( (p) => p.status === 'open' && p.originIcao === 'EDDF', ); expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline }); }); describe('clampTimeScale — v0.14.0 ladder bounds [1, 1000]', () => { it('clamps below-minimum values to MIN_TIME_SCALE', () => { expect(clampTimeScale(0)).toBe(MIN_TIME_SCALE); expect(clampTimeScale(-5)).toBe(MIN_TIME_SCALE); expect(clampTimeScale(0.1)).toBe(MIN_TIME_SCALE); }); it('clamps above-maximum values to MAX_TIME_SCALE', () => { expect(clampTimeScale(1001)).toBe(MAX_TIME_SCALE); expect(clampTimeScale(2000)).toBe(MAX_TIME_SCALE); expect(clampTimeScale(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIME_SCALE); }); it('passes in-range values through unclamped — ladder and off-ladder alike', () => { for (const s of [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]) { expect(clampTimeScale(s)).toBe(s); // the full v0.14.0 slider ladder } expect(clampTimeScale(37)).toBe(37); // off-ladder but in range (e.g. a legacy value) }); it('keeps the endpoints exact', () => { expect(clampTimeScale(MIN_TIME_SCALE)).toBe(MIN_TIME_SCALE); expect(clampTimeScale(MAX_TIME_SCALE)).toBe(MAX_TIME_SCALE); }); }); // Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the // engine composes arrivalTimeFor; tests just need the duration for fixtures). function travelMs(km: number, speedKmh: number): number { return (km / speedKmh) * 3_600_000; }