Auto-refuel on dispatch; remove manual refueling UI/API/action (ISS-082, v0.42.0)
CI / build + unit + e2e (pull_request) Successful in 17m25s
CI / build + unit + e2e (pull_request) Successful in 17m25s
This commit is contained in:
+58
-9
@@ -8,7 +8,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { BROKEN_BURN_MULTIPLIER, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||
@@ -298,19 +298,65 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
||||
});
|
||||
|
||||
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
|
||||
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, cannot start LEMD→EHAM.
|
||||
// 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: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
|
||||
queue: JSON.stringify(['EHAM']),
|
||||
})
|
||||
.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), post-wear health,
|
||||
// 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 newHealth = Math.max(0, 100 - WEAR_PER_KM * km);
|
||||
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 * conditionBurnMultiplier(newHealth),
|
||||
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);
|
||||
|
||||
@@ -318,7 +364,10 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
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 refuel + re-dispatch
|
||||
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 () => {
|
||||
|
||||
+25
-7
@@ -5,7 +5,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { parseIcaoQueue } from '@/lib/game/state';
|
||||
import {
|
||||
@@ -152,8 +152,10 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
|
||||
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
||||
// immediately from the arrival airport; otherwise ground the plane there with
|
||||
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
||||
// immediately from the arrival airport; with a short tank, ISS-082 (v0.42.0) tops
|
||||
// up to 150% of the requirement (capped at tank capacity) when cash allows and
|
||||
// chains anyway — only an unaffordable top-up grounds the plane there with its
|
||||
// remaining fuel and the queue kept intact for a later dispatch.
|
||||
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||||
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||||
@@ -219,11 +221,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
|
||||
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
||||
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
||||
// (requiredFuelFor is independent of current load). Otherwise ground here; an
|
||||
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
|
||||
// intact either way so a grounded plane can refuel and re-dispatch.
|
||||
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
||||
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
||||
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
|
||||
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way.
|
||||
const queue = parseIcaoQueue(p.queue);
|
||||
let chainedTo: string | null = null;
|
||||
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||
if (queue.length > 0) {
|
||||
const nextDest = queue[0];
|
||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||
@@ -241,6 +245,20 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
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) {
|
||||
queue.shift();
|
||||
chainedTo = nextDest;
|
||||
topUpFuel = target;
|
||||
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,7 +270,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
locationIcao: to.icao,
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
fuel: topUpFuel ?? landedFuel, // ISS-082: the post-top-up level when a top-up funded the chain
|
||||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||||
destIcao: chainedTo,
|
||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||
|
||||
Reference in New Issue
Block a user