Auto-refuel on dispatch; remove manual refueling UI/API/action (ISS-082, v0.42.0)
CI / build + unit + e2e (pull_request) Successful in 17m25s

This commit is contained in:
2026-09-21 15:19:39 +00:00
parent b2d03846f5
commit 64da8e67bf
28 changed files with 479 additions and 1008 deletions
+29 -86
View File
@@ -25,9 +25,9 @@ import { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed';
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
// load a package onto a grounded plane at its origin, fly to any airport, deliver
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
// load a package onto a grounded plane at its origin, fly to any airport (dispatch
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
// grounded; plus buy/sell planes. Each function validates and mutates ONE throwaway-safe GameDb instance
// (persisted at the end); routes wrap them in engine.runAction() so tick +
// validate + mutate are atomic under the sim lock. Validation failures throw
// GameActionError carrying the HTTP status:
@@ -102,7 +102,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
// loaded packages' destinations or pure repositioning. On arrival the engine
// delivers whatever is bound for that airport. Validation: plane 'available',
// destination in the airport dataset, enough fuel for the leg.
// destination in the airport dataset, enough fuel for the leg. ISS-082 (v0.42.0):
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
@@ -125,8 +129,22 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health);
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
// any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
}
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
if (requiredL > target) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} L`);
}
// runAction() ticked first, so this is current game time.
@@ -140,8 +158,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
departureSimTime: sim.clockMs,
lat: origin.lat,
lon: origin.lon,
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
// slow to 1× instead of pausing), and restores the last manually selected speed when this
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
@@ -331,91 +353,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
return { payout };
}
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions {
mode?: 'full' | 'forFlight';
packageId?: string;
}
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
// leg doesn't fit a full tank).
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
}
const mode = opts.mode ?? 'full';
let targetFuel: number; // tank level after the refuel
if (mode === 'forFlight') {
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
}
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
);
if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
}
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// → free no-op, tank untouched.
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
} else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel;
}
const liters = Math.max(0, targetFuel - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
}
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
await persist(inst);
return { cost };
}
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing'
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
// repair is a free no-op (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
// repair is a free no-op. 404 unknown plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];