Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1)
CI / build + unit + e2e (pull_request) Failing after 6m37s
CI / build + unit + e2e (pull_request) Failing after 6m37s
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
covering the first queued leg (same requiredFuelFor rule as /fly), the plane
immediately starts that leg from the arrival airport (stays 'flying',
departureSimTime = arrival instant, queue shortened). Short tank → grounds
safely with the queue kept intact; empty queue → unchanged grounding. Cargo
delivery for the reached airport runs first and is unaffected. Ordering kept:
delivery still runs before tickOnce's pause check, so a chained plane counts
as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
(append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
by index; active in-flight leg never affected; 400 non-integer/out-of-range,
404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
right-pane, mapData and seed tests.
v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## v0.25.0 — 2026-09-20 10:36
|
||||
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
|
||||
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
|
||||
- **New endpoints (ISS-043).** `POST /api/planes/{planeId}/queue` body `{destIcao}` appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); `POST /api/planes/{planeId}/queue/cancel` body `{index}` removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through `performAction()` like every other action and return the full state snapshot. New actions `queueLeg`/`cancelQueuedLeg` in `lib/game/actions.ts`; routes under `app/api/planes/[planeId]/queue/`
|
||||
- **Snapshot + spec (ISS-043).** `PlaneState.queue: string[]` added to the `/api/state` snapshot (`lib/game/state.ts`, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — `PlaneState.queue`, `QueueLegRequest`, `CancelQueuedLegRequest`, both new paths (200→snapshot, 400, 404)
|
||||
- Tests: unit — `lib/sim/engine.test.ts`: auto-chain block in `deliverPackages` (lands with queue + enough fuel → `'flying'` to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. `lib/game/actions.test.ts`: `queueLeg` (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and `cancelQueuedLeg` (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). `lib/openapi.test.ts`: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: `queue: []` added to the `PlaneState`/seed fixtures in `deriveEvents`, `right-pane`, `mapData`, `seed` tests
|
||||
- Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: `tsc --noEmit` clean; unit suite green locally; `npx playwright test --list` unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.24.0 — 2026-09-20 09:48
|
||||
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
|
||||
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cancelQueuedLeg, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/planes/:planeId/queue/cancel (v0.25.0, ISS-043) — body { index: number }.
|
||||
// Removes a NOT-yet-started leg from the plane's queue by index; only queued legs
|
||||
// are cancellable, the active in-flight leg is never affected. 400 missing/
|
||||
// non-integer index, unknown fields, or an index outside [0, queue.length); 404
|
||||
// unknown plane id. On success returns the full state snapshot (plane.queue updated).
|
||||
|
||||
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 !== 'index') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(input.index)) {
|
||||
return Response.json({ error: 'index must be an integer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const index = input.index as number;
|
||||
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { performAction, queueLeg } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/planes/:planeId/queue (v0.25.0, ISS-043) — body { destIcao: string }.
|
||||
// Appends a NOT-yet-started leg to the plane's flight queue; works whether the
|
||||
// plane is grounded or flying (pre-building a route). Nothing auto-starts here —
|
||||
// queued legs chain on landing when fuel covers them. 400 missing/non-string
|
||||
// destIcao, unknown fields, or an ICAO not in the airport dataset; 404 unknown
|
||||
// plane id. On success returns the full state snapshot (plane.queue updated).
|
||||
|
||||
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 !== 'destIcao') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof input.destIcao !== 'string' || input.destIcao.length === 0) {
|
||||
return Response.json({ error: 'destIcao must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const destIcao = input.destIcao;
|
||||
return performAction((inst) => queueLeg(inst, planeId, destIcao));
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
departureSimTime: null,
|
||||
lat: 50,
|
||||
lon: 8.5,
|
||||
queue: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
+9
-2
@@ -30,7 +30,8 @@ CREATE TABLE IF NOT EXISTS planes (
|
||||
dest_icao TEXT,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
departure_sim_time INTEGER
|
||||
departure_sim_time INTEGER,
|
||||
queue TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -72,6 +73,10 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
if (!cols('planes').has('dest_icao')) {
|
||||
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
|
||||
}
|
||||
// v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs.
|
||||
if (!cols('planes').has('queue')) {
|
||||
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||
@@ -159,7 +164,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||
const data = buildSeed({ airports, rng, nowMs: Date.now() });
|
||||
|
||||
await instance.db.insert(schema.planes).values(data.plane);
|
||||
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||
const { queue, ...planeCols } = data.plane;
|
||||
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
|
||||
await instance.db.insert(schema.packages).values(data.packages);
|
||||
await instance.db.insert(schema.account).values(data.account);
|
||||
await instance.db.insert(schema.simState).values(data.simState);
|
||||
|
||||
@@ -20,6 +20,10 @@ export const planes = sqliteTable('planes', {
|
||||
// flying, `fuel` is frozen at its departure value — live position/fuel are pure
|
||||
// functions of the clock (lib/sim/movement.ts), recomputed on read.
|
||||
departureSimTime: integer('departure_sim_time'),
|
||||
// Queue of upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered JSON array
|
||||
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||
queue: text('queue').notNull().default('[]'),
|
||||
});
|
||||
|
||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||
|
||||
@@ -22,6 +22,7 @@ const basePlane = (id: string): PlaneState => ({
|
||||
departureSimTime: null,
|
||||
lat: 50.03,
|
||||
lon: 8.56,
|
||||
queue: [],
|
||||
});
|
||||
|
||||
const basePackage = (id: string): PackageState => ({
|
||||
|
||||
@@ -13,9 +13,11 @@ import { HUB_ICAO } from './seed';
|
||||
import {
|
||||
abandonPackage,
|
||||
buyPlane,
|
||||
cancelQueuedLeg,
|
||||
flyPlane,
|
||||
GameActionError,
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
sellPlane,
|
||||
} from './actions';
|
||||
@@ -206,6 +208,75 @@ describe('flyPlane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||
const inst = await freshDb('qleg-ok.db');
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||||
});
|
||||
|
||||
it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => {
|
||||
const inst = await freshDb('qleg-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying'); // still on its active leg…
|
||||
expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight
|
||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('qleg-ghost.db');
|
||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
||||
});
|
||||
|
||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||
const inst = await freshDb('qleg-unknownicao.db');
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||
const inst = await freshDb('qcancel-ok.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
||||
|
||||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range index with 400', async () => {
|
||||
const inst = await freshDb('qcancel-oob.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
||||
});
|
||||
|
||||
it('rejects cancelling from an empty queue with 400', async () => {
|
||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('qcancel-ghost.db');
|
||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abandonPackage', () => {
|
||||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
||||
const REWARD = 1_000;
|
||||
|
||||
+43
-1
@@ -6,7 +6,7 @@ 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 { getStateSnapshot, parseIcaoQueue } from './state';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
import { abandonPenalty } from './penalty';
|
||||
import { HUB_ICAO } from './seed';
|
||||
@@ -131,6 +131,48 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Queue (POST /api/planes/:id/queue {destIcao}) — ISS-043 part 1 ---
|
||||
|
||||
// Append a NOT-yet-started leg to the plane's queue. Works whether the plane is
|
||||
// grounded or flying (you can pre-build a route); nothing auto-starts here — the
|
||||
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
||||
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||||
// in the airport dataset.
|
||||
export async function queueLeg(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];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||||
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||
}
|
||||
|
||||
const queue = parseIcaoQueue(plane.queue);
|
||||
queue.push(destIcao);
|
||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Queue cancel (POST /api/planes/:id/queue/cancel {index}) — ISS-043 part 1 ---
|
||||
|
||||
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
||||
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||
// known plane, integer index within [0, queue.length).
|
||||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
|
||||
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = planeRows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
|
||||
const queue = parseIcaoQueue(plane.queue);
|
||||
if (index < 0 || index >= queue.length) {
|
||||
throw new GameActionError(400, `queue index ${index} out of range — the queue holds ${queue.length} legs`);
|
||||
}
|
||||
queue.splice(index, 1);
|
||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Abandon (POST /api/packages/:id/abandon) ---
|
||||
|
||||
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('buildSeed', () => {
|
||||
destIcao: null,
|
||||
lat: 50.0333,
|
||||
lon: 8.5706,
|
||||
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface SeedPlane {
|
||||
destIcao: null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||||
// column as JSON text on insert; defaults to an empty queue.
|
||||
queue?: string[];
|
||||
}
|
||||
|
||||
export interface SeedPackage {
|
||||
@@ -182,6 +185,7 @@ export function buildSeed(input: {
|
||||
destIcao: null,
|
||||
lat: hub.lat,
|
||||
lon: hub.lon,
|
||||
queue: [], // no queued legs on first run (v0.25.0)
|
||||
};
|
||||
|
||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||
|
||||
@@ -24,6 +24,21 @@ export interface PlaneState {
|
||||
departureSimTime: number | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
||||
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||
queue: string[];
|
||||
}
|
||||
|
||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
|
||||
export function parseIcaoQueue(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const v: unknown = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackageState {
|
||||
@@ -108,6 +123,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
departureSimTime: p.departureSimTime,
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
queue: parseIcaoQueue(p.queue),
|
||||
};
|
||||
}),
|
||||
packages: packageRows.map((p) => ({
|
||||
|
||||
+9
-9
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
|
||||
snapshot({
|
||||
catalog: [],
|
||||
planes: [
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 },
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
|
||||
+24
-2
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => {
|
||||
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/market/buy-plane',
|
||||
@@ -11,6 +11,8 @@ describe('OpenAPI spec', () => {
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
@@ -120,6 +122,7 @@ describe('OpenAPI spec', () => {
|
||||
'lon',
|
||||
'maxFuel',
|
||||
'model',
|
||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||
'status',
|
||||
]);
|
||||
|
||||
@@ -169,7 +172,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(error)).toEqual(['error']);
|
||||
});
|
||||
|
||||
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
// The spec object is a literal type; these helpers view it as plain JSON.
|
||||
const post = (p: string) =>
|
||||
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||
@@ -183,6 +186,8 @@ describe('OpenAPI spec', () => {
|
||||
'/api/packages/{id}/load',
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
@@ -196,6 +201,8 @@ describe('OpenAPI spec', () => {
|
||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying
|
||||
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
||||
@@ -208,6 +215,12 @@ describe('OpenAPI spec', () => {
|
||||
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/FlyPlaneRequest',
|
||||
);
|
||||
expect(post('/api/planes/{planeId}/queue').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/QueueLegRequest', // v0.25.0 (ISS-043)
|
||||
);
|
||||
expect(post('/api/planes/{planeId}/queue/cancel').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/CancelQueuedLegRequest', // v0.25.0 (ISS-043)
|
||||
);
|
||||
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/BuyPlaneRequest',
|
||||
);
|
||||
@@ -224,6 +237,8 @@ describe('OpenAPI spec', () => {
|
||||
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
|
||||
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
|
||||
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
||||
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
||||
|
||||
// Request schema shapes.
|
||||
@@ -233,6 +248,13 @@ describe('OpenAPI spec', () => {
|
||||
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
|
||||
expect(fly.required).toEqual(['destIcao']);
|
||||
expect(fly.properties.destIcao.type).toBe('string');
|
||||
// v0.25.0 (ISS-043): the queue request schemas mirror fly's destIcao shape.
|
||||
const queueLeg = openApiSpec.components.schemas.QueueLegRequest;
|
||||
expect(queueLeg.required).toEqual(['destIcao']);
|
||||
expect(queueLeg.properties.destIcao.type).toBe('string');
|
||||
const cancelQueuedLeg = openApiSpec.components.schemas.CancelQueuedLegRequest;
|
||||
expect(cancelQueuedLeg.required).toEqual(['index']);
|
||||
expect(cancelQueuedLeg.properties.index.type).toBe('integer');
|
||||
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
|
||||
expect(buy.required).toEqual(['model']);
|
||||
expect(buy.properties.model.type).toBe('string');
|
||||
|
||||
@@ -204,6 +204,74 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/queue': {
|
||||
post: {
|
||||
summary: 'Queue a flight leg for a plane',
|
||||
description:
|
||||
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).',
|
||||
operationId: 'queueLeg',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/QueueLegRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Queued — full state snapshot (plane.queue now includes the new leg)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/queue/cancel': {
|
||||
post: {
|
||||
summary: 'Cancel a queued flight leg',
|
||||
description:
|
||||
'Remove a NOT-yet-started leg from the plane\'s queue by index (v0.25.0, ISS-043). Only queued legs are cancellable — the active in-flight leg (destIcao) is never affected.',
|
||||
operationId: 'cancelQueuedLeg',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/CancelQueuedLegRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Cancelled — full state snapshot (plane.queue without the removed leg)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/market/buy-plane': {
|
||||
post: {
|
||||
summary: 'Buy a plane from the market',
|
||||
@@ -441,6 +509,20 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['destIcao'],
|
||||
},
|
||||
QueueLegRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — appended to the queue as a not-yet-started leg (v0.25.0).' },
|
||||
},
|
||||
required: ['destIcao'],
|
||||
},
|
||||
CancelQueuedLegRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
index: { type: 'integer', description: 'Zero-based position of the queued (not-yet-started) leg to remove; must be within [0, queue.length) (v0.25.0).' },
|
||||
},
|
||||
required: ['index'],
|
||||
},
|
||||
BuyPlaneRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -502,6 +584,12 @@ export const openApiSpec = {
|
||||
},
|
||||
lat: { type: 'number' },
|
||||
lon: { type: 'number' },
|
||||
queue: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
@@ -516,6 +604,7 @@ export const openApiSpec = {
|
||||
'departureSimTime',
|
||||
'lat',
|
||||
'lon',
|
||||
'queue',
|
||||
],
|
||||
},
|
||||
PackageState: {
|
||||
|
||||
+79
-1
@@ -9,7 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAtWeighted } from './movement';
|
||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
|
||||
import {
|
||||
clampTimeScale,
|
||||
deliverPackages,
|
||||
@@ -235,6 +235,68 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0 });
|
||||
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
|
||||
});
|
||||
|
||||
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
|
||||
|
||||
it('auto-chains to the next queued leg when fuel covers it', async () => {
|
||||
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
|
||||
|
||||
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
|
||||
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying'); // chained — still airborne, no ground stop
|
||||
expect(plane.destIcao).toBe('EHAM'); // first queued leg is now the active one
|
||||
expect(plane.departureSimTime).toBe(clock); // departed at the arrival instant…
|
||||
expect(plane.locationIcao).toBe(TO); // …from the airport just landed at
|
||||
expect(plane.lat).toBe(to.lat);
|
||||
expect(plane.lon).toBe(to.lon);
|
||||
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, km, type } = await setupFlight('chain-nofuel.db');
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||
|
||||
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({
|
||||
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
|
||||
queue: JSON.stringify(['EHAM']),
|
||||
})
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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
|
||||
});
|
||||
|
||||
it('grounds as before when the queue is empty', async () => {
|
||||
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
@@ -300,6 +362,22 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
|
||||
it('a chained plane does NOT trigger the last-plane pause (ISS-043)', async () => {
|
||||
const { inst } = await setupLanding('pause-chain.db', 1000); // full tank, arrival exactly T0
|
||||
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ queue: JSON.stringify(['EDDV']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(false); // still flying after the chain — no premature pause…
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
|
||||
expect(plane.destIcao).toBe('EDDV');
|
||||
});
|
||||
|
||||
it('paused sim with all planes landed → no change', async () => {
|
||||
const inst = await freshDb('pause-paused.db');
|
||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
||||
|
||||
+57
-19
@@ -5,6 +5,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { parseIcaoQueue } from '@/lib/game/state';
|
||||
import {
|
||||
generatePackage,
|
||||
MARKET_TARGET_OPEN_PACKAGES,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
type Rng,
|
||||
type SeedAirport,
|
||||
} from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAtWeighted } from './movement';
|
||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
|
||||
|
||||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||||
@@ -122,12 +123,17 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
||||
}
|
||||
|
||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||
// ground the plane at its dest_icao with its remaining fuel, then for every
|
||||
// package bound to it and destined for that airport — 'loaded' ones still inside
|
||||
// their deadline are delivered (cash += reward + reputation gain per package),
|
||||
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
|
||||
// on the plane for a later leg. Idempotent — a finalized plane leaves the
|
||||
// 'flying' state, so it can never be awarded twice. Returns planes landed.
|
||||
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||
// 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).
|
||||
// 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 pause check on purpose —
|
||||
// a chained plane counts as still flying there, so chaining never pauses early.
|
||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, packageRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
@@ -149,19 +155,10 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||
|
||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({
|
||||
status: 'available',
|
||||
locationIcao: to.icao,
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
|
||||
departureSimTime: null,
|
||||
destIcao: null,
|
||||
})
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
|
||||
|
||||
// Deliver every bound package whose destination is the airport just reached.
|
||||
// Deliver every bound package whose destination is the airport just reached —
|
||||
// runs whether or not a queued leg chains next (ISS-043).
|
||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||
for (const pk of onBoard) {
|
||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||
@@ -178,6 +175,47 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
const queue = parseIcaoQueue(p.queue);
|
||||
let chainedTo: string | null = null;
|
||||
if (queue.length > 0) {
|
||||
const nextDest = queue[0];
|
||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
|
||||
queue.shift();
|
||||
chainedTo = nextDest;
|
||||
}
|
||||
}
|
||||
await inst.db.update(schema.planes)
|
||||
.set(
|
||||
chainedTo
|
||||
? {
|
||||
status: 'flying',
|
||||
locationIcao: to.icao,
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
destIcao: chainedTo,
|
||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||
queue: JSON.stringify(queue),
|
||||
}
|
||||
: {
|
||||
status: 'available',
|
||||
locationIcao: to.icao,
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
departureSimTime: null,
|
||||
destIcao: null,
|
||||
queue: JSON.stringify(queue),
|
||||
},
|
||||
)
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.24.0",
|
||||
"version": "0.25.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user