Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s

This commit is contained in:
2026-09-21 22:36:01 +00:00
parent 76f5b6f8ce
commit 479b993c09
10 changed files with 407 additions and 27 deletions
+46
View File
@@ -378,6 +378,52 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
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)', () => {
+35 -24
View File
@@ -246,32 +246,43 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0 && newHealth > 0) {
const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
} else {
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
// zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
if (nextDest === to.icao) {
queue.shift();
} else {
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
// v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
// intact, mirroring flyPlane's 409 without throwing in the engine.
if (nextRequiredL > type.maxFuel) {
// no chain — the grounded update below keeps the queue for a later dispatch
} else if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
} else {
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
}
}
}