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
CI / build + unit + e2e (pull_request) Failing after 17m26s
This commit is contained in:
+127
-94
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user