Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
CI / build + unit + e2e (pull_request) Failing after 12m59s
This commit is contained in:
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user