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:
@@ -63,17 +63,17 @@ describe('POST /api/planes/:planeId/fly', () => {
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
});
|
||||
|
||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
||||
it('rejects a leg that does not fit even a full tank with 409 (ISS-082 backstop)', async () => {
|
||||
const { db } = await getGameDb();
|
||||
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
|
||||
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
// UNNT → LPPD ≈ 7670 km needs ~10k L — beyond the ATR's 6300 L tank, so no auto-refuel can make it flyable.
|
||||
await db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const res = await post('plane-starter', { destIcao: 'LEMD' });
|
||||
const res = await post('plane-starter', { destIcao: 'LPPD' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||
|
||||
// Refill for later tests.
|
||||
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
// Put the starter back at the hub for later tests.
|
||||
await db.update(schema.planes).set({ locationIcao: 'EDDF' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
});
|
||||
|
||||
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { HUB_ICAO } from '@/lib/game/seed';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-refuel-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (planeId: string, body?: unknown) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/planes/${planeId}/refuel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ planeId }) },
|
||||
);
|
||||
|
||||
describe('POST /api/planes/:planeId/refuel', () => {
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const res = await post('ghost');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post('plane-starter');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
account: { cash: number };
|
||||
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
|
||||
};
|
||||
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
|
||||
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const res = await post('plane-starter');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
|
||||
|
||||
// Ground it again for later tests.
|
||||
await db.update(schema.planes)
|
||||
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
});
|
||||
|
||||
it('rejects the fill-up with 409 when cash is below the cost', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
||||
|
||||
const res = await post('plane-starter');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
||||
});
|
||||
|
||||
it('accepts an explicit {mode:"full"} body exactly like no body', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor
|
||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const res = await post('plane-starter', { mode: 'full' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> };
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
});
|
||||
|
||||
it('rejects an unknown mode with 400', async () => {
|
||||
const res = await post('plane-starter', { mode: 'half' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/mode/);
|
||||
});
|
||||
|
||||
it('rejects forFlight without a packageId with 400', async () => {
|
||||
const res = await post('plane-starter', { mode: 'forFlight' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/packageId/);
|
||||
});
|
||||
|
||||
it('rejects an unknown body field with 400', async () => {
|
||||
const res = await post('plane-starter', { liters: 100 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON with 400', async () => {
|
||||
const res = await POST(
|
||||
new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }),
|
||||
{ params: Promise.resolve({ planeId: 'plane-starter' }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.account).set({ cash: 50_000 });
|
||||
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
|
||||
await db.insert(schema.packages).values({
|
||||
id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
||||
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
|
||||
});
|
||||
|
||||
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;
|
||||
// Same wind-adjusted requirement the server computes (v0.36.0).
|
||||
const w = weatherFor(HUB_ICAO);
|
||||
const required = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
km,
|
||||
);
|
||||
|
||||
const startFuel = Math.round(required / 2); // partial tank so L > 0
|
||||
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
account: { cash: number };
|
||||
planes: Array<{ id: string; fuel: number }>;
|
||||
};
|
||||
// ISS-071: target is 150% of the requirement, capped at tank capacity.
|
||||
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
|
||||
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
|
||||
|
||||
// Clean up the test package.
|
||||
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { performAction, refuelPlane } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/planes/:planeId/refuel (v0.8.0; optional body v0.16.0) — fills a grounded
|
||||
// plane at the fuel price per added liter and deducts the cost from cash. The body is
|
||||
// OPTIONAL: no body, {} or {mode:'full'} fill to maxFuel (the original behavior);
|
||||
// {mode:'forFlight', packageId} adds just enough for that loaded package's destination
|
||||
// leg. 400 bad body (unknown field, unknown mode, forFlight without packageId); 404
|
||||
// unknown plane/package id; 409 if the plane is flying, the package isn't loaded on it
|
||||
// (or is expired), the leg doesn't fit a full tank, or cash is insufficient. On success
|
||||
// returns the full state snapshot (fuel raised by exactly the added liters).
|
||||
|
||||
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||
const { planeId } = await ctx.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'mode' && key !== 'packageId') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const mode = input.mode ?? 'full';
|
||||
if (mode !== 'full' && mode !== 'forFlight') {
|
||||
return Response.json({ error: 'mode must be "full" or "forFlight"' }, { status: 400 });
|
||||
}
|
||||
let packageId: string | undefined;
|
||||
if (input.packageId !== undefined) {
|
||||
if (typeof input.packageId !== 'string' || input.packageId.length === 0) {
|
||||
return Response.json({ error: 'packageId must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
packageId = input.packageId;
|
||||
}
|
||||
if (mode === 'forFlight' && !packageId) {
|
||||
return Response.json({ error: 'packageId is required when mode is "forFlight"' }, { status: 400 });
|
||||
}
|
||||
|
||||
return performAction((inst) => refuelPlane(inst, planeId, { mode, packageId }));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
// TEST-ONLY hook (v0.42.0, ISS-082) — lets the e2e suite force a plane's fuel level now that the
|
||||
// manual /refuel endpoint is gone (dispatch auto-refuels instead). Disabled unless FLIGHT_TEST_HOOKS=1
|
||||
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here. Sibling of
|
||||
// /api/test/set-health — same gate, same conventions.
|
||||
//
|
||||
// POST /api/test/set-fuel
|
||||
// body { planeId: string, fuel: number } — both required; planeId must name an existing plane
|
||||
// (404 otherwise) and fuel a finite number in [0, the plane's maxFuel] (400 outside). Sets the
|
||||
// planes row's fuel directly and returns the full state snapshot after ticking.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'planeId' && key !== 'fuel') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const planeId = input.planeId;
|
||||
if (typeof planeId !== 'string' || planeId.length === 0) {
|
||||
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
const fuel = input.fuel;
|
||||
if (typeof fuel !== 'number' || !Number.isFinite(fuel)) {
|
||||
return Response.json({ error: 'fuel is required and must be a finite number' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
if (!rows[0]) {
|
||||
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
|
||||
}
|
||||
if (fuel < 0 || fuel > rows[0].maxFuel) {
|
||||
return Response.json({ error: `fuel must be in [0, maxFuel] — this plane's tank holds ${rows[0].maxFuel} L` }, { status: 400 });
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user