From 69b708dad347b9b495ff752724a538cfac29a2c7 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 20 Sep 2026 10:39:00 +0000 Subject: [PATCH 1/2] Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 8 ++ .../planes/[planeId]/queue/cancel/route.ts | 34 +++++++ app/api/planes/[planeId]/queue/route.ts | 35 ++++++++ components/right-pane.test.ts | 1 + lib/db/client.ts | 11 ++- lib/db/schema.ts | 4 + lib/deriveEvents.test.ts | 1 + lib/game/actions.test.ts | 71 +++++++++++++++ lib/game/actions.ts | 44 ++++++++- lib/game/seed.test.ts | 1 + lib/game/seed.ts | 4 + lib/game/state.ts | 16 ++++ lib/mapData.test.ts | 18 ++-- lib/openapi.test.ts | 26 +++++- lib/openapi.ts | 89 +++++++++++++++++++ lib/sim/engine.test.ts | 80 ++++++++++++++++- lib/sim/engine.ts | 76 ++++++++++++---- package.json | 2 +- 18 files changed, 486 insertions(+), 35 deletions(-) create mode 100644 app/api/planes/[planeId]/queue/cancel/route.ts create mode 100644 app/api/planes/[planeId]/queue/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15914de..f24cb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/api/planes/[planeId]/queue/cancel/route.ts b/app/api/planes/[planeId]/queue/cancel/route.ts new file mode 100644 index 0000000..6290ca7 --- /dev/null +++ b/app/api/planes/[planeId]/queue/cancel/route.ts @@ -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; + 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)); +} diff --git a/app/api/planes/[planeId]/queue/route.ts b/app/api/planes/[planeId]/queue/route.ts new file mode 100644 index 0000000..987cc3e --- /dev/null +++ b/app/api/planes/[planeId]/queue/route.ts @@ -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; + 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)); +} diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index 0073783..f08f846 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -24,6 +24,7 @@ function plane(over: Partial & Pick): PlaneState { departureSimTime: null, lat: 50, lon: 8.5, + queue: [], ...over, }; } diff --git a/lib/db/client.ts b/lib/db/client.ts index 2ec2925..3d179a3 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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 { 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); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 31b3a46..4ea1a20 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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 diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts index 7f19cf2..8d52892 100644 --- a/lib/deriveEvents.test.ts +++ b/lib/deriveEvents.test.ts @@ -22,6 +22,7 @@ const basePlane = (id: string): PlaneState => ({ departureSimTime: null, lat: 50.03, lon: 8.56, + queue: [], }); const basePackage = (id: string): PackageState => ({ diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 8f5f35a..140e43c 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -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; diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 4da1bc7..8744644 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -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 { + 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 { + 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 diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 4d8c291..4d5ddfb 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -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 }); }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index b572bdf..cef39a5 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -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)); diff --git a/lib/game/state.ts b/lib/game/state.ts index 0f491c6..0218db6 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -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 { departureSimTime: p.departureSimTime, lat: live?.lat ?? p.lat, lon: live?.lon ?? p.lon, + queue: parseIcaoQueue(p.queue), }; }), packages: packageRows.map((p) => ({ diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 2f3e129..92304c0 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -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, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 3e662bc..2167f66 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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)[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'); diff --git a/lib/openapi.ts b/lib/openapi.ts index dc66167..71a63ae 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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: { diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 2045af4..317ea88 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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 diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index ecaffba..09d0deb 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -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 { 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 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 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; diff --git a/package.json b/package.json index 3aa506e..f467ce1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.24.0", + "version": "0.25.0", "private": true, "scripts": { "dev": "next dev -p 3000", From 537c494daa6e006233d68e660377c90edc9086e1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 20 Sep 2026 10:57:57 +0000 Subject: [PATCH 2/2] ISS-043: add queue endpoints to e2e OpenAPI path list --- e2e/api.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 8c2057a..414e094 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page expect(body.version).toBe(pkg.version); }); -test('GET /api/openapi serves the spec with all ten endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { const res = await page.request.get('/api/openapi'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); @@ -28,6 +28,8 @@ test('GET /api/openapi serves the spec with all ten endpoints and the package.js '/api/packages/{id}/abandon', '/api/packages/{id}/load', '/api/planes/{planeId}/fly', + '/api/planes/{planeId}/queue', + '/api/planes/{planeId}/queue/cancel', '/api/planes/{planeId}/refuel', '/api/sim', '/api/state',