v0.65.0: per-airport job pools with real-time refresh — open jobs spawn per pool airport (grounded/flying/queued locations) in a fixed POOL_BANDS composition, expire on the wall clock after JOB_LIFETIME_MS even while the world is frozen, and refill taken slots after a per-band cooldown; /api/state + SSE filter open jobs to pool origins and expose expiresRealMs for the right-pane countdown
CI / build + unit + e2e (pull_request) Failing after 17m26s

This commit is contained in:
2026-09-23 13:09:49 +00:00
parent 9c2c64fe89
commit 12d900b22a
24 changed files with 556 additions and 344 deletions
+127 -94
View File
@@ -10,17 +10,17 @@ 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 { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
deliverPackages,
expirePackages,
expireLoadedPackages,
expireOpenPackages,
finishRepairs,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
shouldAdvanceTime,
spawnCoveragePackages,
spawnOpenPackages,
spawnPoolPackages,
tickOnce,
WORLD_TIME_SCALE,
} from './engine';
@@ -140,41 +140,70 @@ describe('tickOnce — clock advance', () => {
});
});
describe('expirePackages', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db');
describe('expireLoadedPackages (sim clock)', () => {
it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry-loaded.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-loaded-future', 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 n = await expireLoadedPackages(inst, clock);
expect(n).toBe(1); // only the loaded one — open jobs live on the wall clock now (v0.65.0)
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-loaded-future')).toBe('loaded'); // deadline not reached yet
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');
const inst = await freshDb('expiry-loaded-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: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS,
});
expect(await expireLoadedPackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
});
});
describe('expireOpenPackages (v0.65.0 wall clock)', () => {
it('flips open packages past their real-time lifetime; fresh ones and terminal statuses never', async () => {
const inst = await freshDb('expiry-open.db');
// T0 is the fake Date.now() at DB open — seeded rows carry createdRealMs = T0, so they are FRESH
// (a full JOB_LIFETIME_MS away) and only the aged row below can expire.
await inst.db.insert(schema.packages).values([
{ id: 'p-open-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
{ id: 'p-open-fresh', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 },
{ id: 'p-delivered-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 },
]);
const n = await expireOpenPackages(inst, T0);
expect(n).toBe(1); // only the aged open one — loaded jobs are sim-clock territory now
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
expect(byId.get('p-open-aged')).toBe('expired');
expect(byId.get('p-open-fresh')).toBe('open'); // lifetime not reached yet
expect(byId.get('p-delivered-aged')).toBe('delivered'); // terminal — never flips
});
it('a lifetime exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-open-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,
createdRealMs: T0 - JOB_LIFETIME_MS,
});
expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
expect(await expireOpenPackages(inst, T0)).toBe(1); // <= is inclusive
});
});
@@ -642,9 +671,11 @@ describe('grounded fleet freezes the world clock (#135)', () => {
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 () => {
it('a frozen tick still refills the pool 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).
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS.
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));
@@ -656,14 +687,14 @@ describe('grounded fleet freezes the world clock (#135)', () => {
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
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // …but the starter's pool is refilled to its full composition…
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // …all at 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');
describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
it('tops each pool airport up to the POOL_BANDS composition using the shared seed builder', async () => {
const inst = await freshDb('pool.db');
const clock = T0 + 123_456;
// Leave exactly 3 open packages (the rest of the seeded set is "done").
@@ -672,16 +703,18 @@ describe('spawnOpenPackages', () => {
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 needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0);
expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // single grounded plane → one pool
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(p.createdRealMs).toBe(T0); // …and stamped with the injected wall clock
expect(icaoSet.has(p.originIcao)).toBe(true);
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
@@ -693,15 +726,36 @@ describe('spawnOpenPackages', () => {
}
// Already at target → nothing more to spawn.
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
expect(await spawnPoolPackages(inst, clock, mulberry32(7), T0)).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');
it('keeps the band composition per airport — weights stay inside their band', async () => {
const inst = await freshDb('pool-composition.db');
// Wipe all seeded packages so the spawner builds the pool from zero with a known RNG.
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 spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT); // one pool (EDDF) at full composition
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
const byBand = [0, 0, 0, 0];
for (const p of openRows) {
const b = poolBandOf(p.weightKg);
byBand[b]++;
expect(p.weightKg).toBeGreaterThanOrEqual(POOL_BANDS[b].minKg); // weight stays in its band…
expect(p.weightKg).toBeLessThanOrEqual(POOL_BANDS[b].maxKg); // …on both ends
}
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
});
it('is deterministic for an injected RNG and wall clock', async () => {
const a = await freshDb('pool-a.db');
const b = await freshDb('pool-b.db');
const clock = T0 + 999;
// Wipe all packages in both so both spawn from zero with the same RNG.
// Wipe all packages in both so both spawn from zero with the same RNG and now.
for (const inst of [a, b]) {
const rows = await inst.db.select().from(schema.packages);
for (const r of rows) {
@@ -709,8 +763,8 @@ describe('spawnOpenPackages', () => {
}
}
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const strip = (rows: schema.PackageRow[]) =>
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
@@ -718,82 +772,62 @@ describe('spawnOpenPackages', () => {
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.
it('spawns for flying destinations and queued airports, not just grounded locations', async () => {
const inst = await freshDb('pool-membership.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));
}
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,
// Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is
// {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it.
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
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,
});
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 n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set
const origins = (await inst.db.select().from(schema.packages))
.filter((p) => p.status === 'open')
.map((p) => p.originIcao)
.sort();
expect(origins).toEqual(['EDDF', 'LEMD']);
expect(origins).toEqual(
[...Array(POOL_TARGET_PER_AIRPORT).fill('EHAM'), ...Array(POOL_TARGET_PER_AIRPORT).fill('LEMD')].sort(),
);
});
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'));
it('holds taken slots for their refill delay, then refills them', async () => {
const inst = await freshDb('pool-cooldown.db');
// Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport
// cooldown — see the ponytail note in spawnPoolPackages).
const all = await inst.db.select().from(schema.packages);
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
// The small slot frees first (SMALL_REFILL_DELAY_MS) — exactly its missing count refills; the heavy
// one is still held by REFILL_DELAY_MS.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + SMALL_REFILL_DELAY_MS)).toBe(1);
// The heavy slot frees at REFILL_DELAY_MS — the last empty band tops up too.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + REFILL_DELAY_MS)).toBe(1);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // pool back to full composition
});
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db');
it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => {
const inst = await freshDb('pool-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.
// grounded — pools at EDDF (grounded) and LEMD (flying destination). 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,
@@ -802,10 +836,9 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
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
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
});
});