817 lines
42 KiB
TypeScript
817 lines
42 KiB
TypeScript
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, STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||
import {
|
||
deliverPackages,
|
||
expirePackages,
|
||
finishRepairs,
|
||
MAX_TICK_ELAPSED_MS,
|
||
reputationGainFor,
|
||
shouldAdvanceTime,
|
||
spawnCoveragePackages,
|
||
spawnOpenPackages,
|
||
tickOnce,
|
||
WORLD_TIME_SCALE,
|
||
} 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()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
||
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
|
||
|
||
// #135: fresh DBs seed a GROUNDED starter, so any test that needs clock advance must put a plane in
|
||
// flight first (a fully grounded fleet freezes the world) — departureSimTime at the game-clock
|
||
// origin keeps its arrival hours away.
|
||
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
}
|
||
|
||
let dir: string;
|
||
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
||
|
||
beforeAll(async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(T0);
|
||
// #135 (v0.62.0): this file exercises the FLIGHT_TEST_HOOKS branch of tickOnce — stored
|
||
// sim.timeScale overrides are only honored when the env var is set, exactly like CI.
|
||
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
|
||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
||
});
|
||
|
||
afterAll(async () => {
|
||
vi.useRealTimers();
|
||
vi.unstubAllEnvs();
|
||
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');
|
||
await makeWorldActive(inst); // a fully grounded fleet freezes the clock (#135)
|
||
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(STARTING_CLOCK_MS + 5_000); // 1 s × 5
|
||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||
});
|
||
|
||
it('production (no FLIGHT_TEST_HOOKS): a legacy stored timeScale is ignored — always WORLD_TIME_SCALE (#135)', async () => {
|
||
const inst = await freshDb('prod-rate.db');
|
||
await makeWorldActive(inst);
|
||
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // what a pre-v0.52.0 save holds
|
||
|
||
vi.setSystemTime(T0 + 1_000);
|
||
const saved = process.env.FLIGHT_TEST_HOOKS;
|
||
delete process.env.FLIGHT_TEST_HOOKS;
|
||
let sim: schema.SimStateRow;
|
||
try {
|
||
sim = await tickOnce(inst);
|
||
} finally {
|
||
if (saved !== undefined) process.env.FLIGHT_TEST_HOOKS = saved; // restore the stubbed value
|
||
}
|
||
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // ×24, NOT ×1000
|
||
});
|
||
|
||
it('a fully grounded fleet freezes the clock, then resumes with no catch-up (#135)', async () => {
|
||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → frozen
|
||
|
||
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
||
let sim = await tickOnce(inst);
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no fast-forward, no idle creep
|
||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
||
|
||
// A plane takes off: resumes from where it stopped, with NO catch-up for the frozen time.
|
||
await inst.db.update(schema.simState).set({ timeScale: 60 });
|
||
await makeWorldActive(inst);
|
||
vi.setSystemTime(T0 + 3_600_500);
|
||
sim = await tickOnce(inst);
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
|
||
});
|
||
|
||
it('a grounded fleet ignores any raised timeScale — frozen regardless (#135)', async () => {
|
||
const inst = await freshDb('idle-scale.db');
|
||
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
|
||
|
||
vi.setSystemTime(T0 + 1_000);
|
||
const sim = await tickOnce(inst);
|
||
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen, NOT ×1000
|
||
});
|
||
|
||
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
|
||
const inst = await freshDb('clamp.db');
|
||
await makeWorldActive(inst);
|
||
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
|
||
|
||
const sim = await tickOnce(inst); // timeScale is the seeded WORLD_TIME_SCALE
|
||
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * WORLD_TIME_SCALE); // 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');
|
||
await makeWorldActive(inst);
|
||
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');
|
||
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
|
||
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
|
||
const clock = STARTING_CLOCK_MS + 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: STARTING_CLOCK_MS },
|
||
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||
]);
|
||
|
||
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: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
|
||
});
|
||
expect(await expirePackages(inst, STARTING_CLOCK_MS + 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');
|
||
// The repairing plane keeps the world advancing (ISS-117); scale pinned to 1 for clean math.
|
||
await inst.db.update(schema.simState).set({ timeScale: 1 });
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + 1_500 })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock STARTING_CLOCK_MS+2000 ≥ gate
|
||
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('grounded fleet freezes the world clock (#135)', () => {
|
||
it('pins WORLD_TIME_SCALE = 24 — the single fixed world rate (ISS-117)', () => {
|
||
expect(WORLD_TIME_SCALE).toBe(24);
|
||
});
|
||
|
||
it('shouldAdvanceTime: grounded/empty fleets are frozen; any flying or repairing plane advances', () => {
|
||
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
|
||
expect(shouldAdvanceTime([{ status: 'repairing' }])).toEqual({ advance: true, flying: 0, repairing: 1 });
|
||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
|
||
});
|
||
|
||
it('a fully grounded fleet stays frozen but keeps tracking real time', async () => {
|
||
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → frozen
|
||
vi.setSystemTime(T0 + 3_600_000);
|
||
|
||
const sim = await tickOnce(inst);
|
||
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no idle creep
|
||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
|
||
});
|
||
|
||
it('a repairing plane keeps the world advancing', async () => {
|
||
const inst = await freshDb('idle-repair.db');
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + REPAIR_DURATION_MS })
|
||
.where(eq(schema.planes.id, 'plane-starter'));
|
||
|
||
vi.setSystemTime(T0 + 1_000);
|
||
const sim = await tickOnce(inst);
|
||
|
||
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
||
});
|
||
|
||
it('a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)', async () => {
|
||
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
|
||
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen).
|
||
for (const p of await inst.db.select().from(schema.packages)) {
|
||
if (p.status === 'open') {
|
||
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id));
|
||
}
|
||
}
|
||
|
||
vi.setSystemTime(T0 + 1_000); // a background tick while the fleet is grounded
|
||
const sim = await tickOnce(inst);
|
||
|
||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // freeze preserved…
|
||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||
expect(openRows.length).toBeGreaterThanOrEqual(MARKET_TARGET_OPEN_PACKAGES); // …but the market is topped back up…
|
||
expect(openRows.some((p) => p.originIcao === 'EDDF')).toBe(true); // …including coverage for the grounded starter's airport
|
||
});
|
||
});
|
||
|
||
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');
|
||
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
|
||
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here.
|
||
await inst.db.insert(schema.planes).values({
|
||
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
||
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
|
||
});
|
||
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
||
|
||
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
|
||
});
|
||
});
|
||
|
||
// 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;
|
||
}
|