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:
@@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
import { HUB_ICAO } from './seed';
|
||||
import {
|
||||
@@ -412,4 +414,86 @@ describe('refuelPlane', () => {
|
||||
const inst = await freshDb('refuel-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
|
||||
// --- forFlight mode (v0.16.0) ---
|
||||
|
||||
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
|
||||
const inst = await freshDb('refuel-forflight-ok.db');
|
||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
||||
|
||||
// Partial tank below the requirement so L > 0.
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
||||
|
||||
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
});
|
||||
|
||||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
||||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
||||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
||||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
|
||||
await insertOpenPackage(inst, 'p-x');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
|
||||
|
||||
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
|
||||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
||||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an expired package with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-expired.db');
|
||||
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an unknown package id with 404', async () => {
|
||||
const inst = await freshDb('refuel-ff-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
|
||||
});
|
||||
|
||||
it('forFlight rejects a missing packageId with 400', async () => {
|
||||
const inst = await freshDb('refuel-ff-nopkg.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
|
||||
});
|
||||
|
||||
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-nofit.db');
|
||||
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
|
||||
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
|
||||
await loadPackage(inst, 'p-far', 'plane-starter');
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(
|
||||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
||||
409,
|
||||
/does not fit/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+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 });
|
||||
}
|
||||
|
||||
+10
-2
@@ -6,8 +6,11 @@ export interface PlaneType {
|
||||
name: string;
|
||||
// Fuel capacity, liters.
|
||||
maxFuel: number;
|
||||
// Fuel burn per km of flight, liters/km (cruise).
|
||||
// Fuel burn per km of flight at a FULL tank, liters/km (cruise) — the weighted burn
|
||||
// model (v0.16.0) calibrates its rate constant to this value at F = maxFuel.
|
||||
burnPerKm: number;
|
||||
// Operating empty weight in kg (v0.16.0) — feeds the weight-proportional burn model.
|
||||
emptyWeightKg: number;
|
||||
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
|
||||
speedKmh: number;
|
||||
// Market price in € (v0.8.0): buying costs `price`, selling pays out
|
||||
@@ -22,7 +25,8 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
id: 'atr72',
|
||||
name: 'ATR 72-600',
|
||||
maxFuel: 6300,
|
||||
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
|
||||
burnPerKm: 1.75, // full-tank rate: ~893 L/h ≈ 714 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L); the weighted model burns less as fuel drops off
|
||||
emptyWeightKg: 9_300, // ATR 72-600 operating empty weight
|
||||
speedKmh: 510,
|
||||
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
|
||||
// a second ATR takes sustained play to afford, as intended for the MVP sink.
|
||||
@@ -49,6 +53,9 @@ export interface CatalogEntry {
|
||||
price: number;
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
// Weighted burn model inputs (v0.16.0) — clients can mirror the closed forms in
|
||||
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
|
||||
emptyWeightKg: number;
|
||||
speedKmh: number;
|
||||
capacity: number;
|
||||
}
|
||||
@@ -62,6 +69,7 @@ export function catalogEntries(): CatalogEntry[] {
|
||||
price: t.price,
|
||||
maxFuel: t.maxFuel,
|
||||
burnPerKm: t.burnPerKm,
|
||||
emptyWeightKg: t.emptyWeightKg,
|
||||
speedKmh: t.speedKmh,
|
||||
capacity: t.capacity,
|
||||
}));
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { haversineKm } from '@/lib/geo';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement';
|
||||
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||
return { ...pos, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) };
|
||||
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user