feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
CI / build + unit + e2e (pull_request) Failing after 5m28s
CI / build + unit + e2e (pull_request) Failing after 5m28s
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.
Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.
OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
This commit is contained in:
+50
-7
@@ -4,6 +4,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { persist, type GameDb } from '@/lib/db/client';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot } from './state';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
@@ -105,9 +106,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||
const burnL = type.burnPerKm * distanceKm;
|
||||
if (burnL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
|
||||
const requiredL = requiredFuelFor(type.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`);
|
||||
}
|
||||
|
||||
// runAction() ticked first, so this is current game time.
|
||||
@@ -225,8 +227,19 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
||||
|
||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
||||
|
||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter.
|
||||
export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
|
||||
// enough fuel for one loaded package's destination leg — required liters per the
|
||||
// weighted burn model, 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`);
|
||||
@@ -234,14 +247,44 @@ export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
|
||||
}
|
||||
|
||||
const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const mode = opts.mode ?? 'full';
|
||||
let liters: number; // how much fuel to add
|
||||
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);
|
||||
const requiredL = requiredFuelFor(type.burnPerKm, 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`);
|
||||
}
|
||||
liters = Math.max(0, requiredL - plane.fuel);
|
||||
} else {
|
||||
liters = plane.maxFuel - plane.fuel;
|
||||
}
|
||||
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
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: plane.maxFuel }).where(eq(schema.planes.id, planeId));
|
||||
// Full mode lands EXACTLY on maxFuel (as before v0.16.0), not fuel + liters — the sum can
|
||||
// drift a ulp for fractional tanks and clients/tests compare against maxFuel exactly.
|
||||
const targetFuel = mode === 'forFlight' ? plane.fuel + liters : plane.maxFuel;
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user