From 479b993c091f8de586db622b5da3dc02bf36458a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 21 Sep 2026 22:36:01 +0000 Subject: [PATCH 1/2] Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088) --- CHANGELOG.md | 7 ++ app/api/test/set-cash/route.ts | 54 ++++++++++++ e2e/queue.spec.ts | 153 +++++++++++++++++++++++++++++++++ e2e/state.spec.ts | 46 ++++++++++ e2e/two-planes.spec.ts | 14 ++- lib/game/actions.test.ts | 41 +++++++++ lib/game/actions.ts | 12 +++ lib/sim/engine.test.ts | 46 ++++++++++ lib/sim/engine.ts | 59 +++++++------ package.json | 2 +- 10 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 app/api/test/set-cash/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b98dc8c..14d630f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.46.0 — 2026-09-21 22:32 +- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update +- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick) +- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs +- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths +- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a + ## v0.45.0 — 2026-09-21 21:12 - **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up) - **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change diff --git a/app/api/test/set-cash/route.ts b/app/api/test/set-cash/route.ts new file mode 100644 index 0000000..584fa1b --- /dev/null +++ b/app/api/test/set-cash/route.ts @@ -0,0 +1,54 @@ +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.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite +// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it). +// 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/add-cash — same gate, same conventions; add-cash +// stays for relative grants, this one is exact. +// +// POST /api/test/set-cash +// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets +// account.cash to exactly that value 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; + for (const key of Object.keys(input)) { + if (key !== 'amount') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const amount = input.amount; + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) { + return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 }); + } + + try { + const inst = await getGameDb(); + await tickNow(); // clock is current before the snapshot we return (same as siblings) + const account = (await inst.db.select().from(schema.account))[0]; + if (!account) throw new Error('account row missing'); + await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 }); + 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 }); + } +} diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index dcf79df..73f3f0a 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -1,4 +1,11 @@ import { test, expect, type Page } from '@playwright/test'; +// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves +// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas. +import { loadAirportsGeoJson } from '../lib/airports'; +import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog'; +import { haversineKm, initialBearingDeg } from '../lib/geo'; +import { weatherFor } from '../lib/weather'; +import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement'; // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate @@ -14,7 +21,9 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de interface PlaneState { id: string; + model: string; status: 'available' | 'flying'; + fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null; @@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise { expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); } +// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the +// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state. +async function setFuel(page: Page, planeId: string, fuel: number): Promise { + expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200); +} + +// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0). +async function setCash(page: Page, amount: number): Promise { + expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200); +} + // Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon // leftover cargo so every test starts from the canonical state even after an interrupted run. async function flyHomeViaApi(page: Page): Promise { @@ -450,3 +470,136 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async return !!p && p.status === 'available' && p.locationIcao === HUB; }, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true); }); + +// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs --- + +test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => { + test.setTimeout(300_000); // self-heal + three chained legs at scale 1000 + await flyHomeViaApi(page); + + await setFuelFull(page, 'plane-starter'); + + // Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub. + for (const icao of [DEST, QUEUE, HUB]) { + expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200); + } + + // Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing. + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(flyRes.status()).toBe(200); + const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!; + expect(p0.status).toBe('flying'); + expect(p0.destIcao).toBe(DEST); + expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]); + + // Landing at DEST auto-chains EHAM (leg 2)… + await setScale(page, SCALE); + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'flying' && x.destIcao === QUEUE; + }, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true); + + // …and landing at EHAM chains the return to HUB (leg 3). + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'flying' && x.destIcao === HUB; + }, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true); + + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'available' && x.locationIcao === HUB; + }, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true); + + expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order +}); + +test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => { + test.setTimeout(300_000); // self-heal + two legs at scale 1000 + await flyHomeViaApi(page); + + const s0 = await getState(page); + const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL) + expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid + + // Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an + // exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned. + await setFuelFull(page, 'plane-starter'); + const STAGED_CASH = 20_000; + await setCash(page, STAGED_CASH); + const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } }); + expect(q.status()).toBe(200); + + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(flyRes.status()).toBe(200); + const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash; + expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact + + // Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival. + await setFuel(page, 'plane-starter', 0); + + // Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands + // empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)). + const fc = await loadAirportsGeoJson(); + const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties; + const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties; + const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties; + + const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon); + const wHub = weatherFor(HUB); + const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon)); + const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty + expect(landedFuel).toBeCloseTo(0, 6); + + const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon); + const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport + const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon)); + const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2); + expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs + + const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5)); + const liters = Math.max(0, target - landedFuel); + const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); + const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; + + // Landing at DEST auto-chains QUEUE with the top-up… + await setScale(page, SCALE); + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'flying' && x.destIcao === QUEUE; + }, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true); + + const s = await getState(page); + expect(s.planes.find((x) => x.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // departed with exactly the post-top-up level… + expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // …at exactly the rep-discounted cost + + // Let leg 2 finish so the next test starts from a known airport. + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'available' && x.locationIcao === QUEUE; + }, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true); +}); + +test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => { + test.setTimeout(300_000); // self-heal + one leg at scale 1000 + await flyHomeViaApi(page); + + await setFuelFull(page, 'plane-starter'); + const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } }); + expect(q.status()).toBe(200); + const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(flyRes.status()).toBe(200); + + // Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain. + const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); + expect(cancel.status()).toBe(200); + + await setScale(page, SCALE); + await expect.poll(async () => { + const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter'); + return !!x && x.status === 'available' && x.locationIcao === DEST; + }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); + + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; + expect(p.destIcao).toBeNull(); // no chained leg started… + expect(p.queue).toEqual([]); // …and the queue is empty +}); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index a236897..20a8e88 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort()); }); + +test('queued legs survive a container restart (v0.46.0)', async ({ page }) => { + test.setTimeout(180_000); + + // Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an + // interrupted run may not — wait it out before staging a queue (queueLeg works while flying too). + await expect + .poll(async () => { + const res = await page.request.get('/api/state'); + if (res.status() !== 200) return 'down'; + const body = (await res.json()) as StateBody; + return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing'); + }, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' }) + .toBe('available'); + + // Stage a two-leg queue on the starter… + const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } }); + expect(q1.status()).toBe(200); + const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } }); + expect(q2.status()).toBe(200); + + execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' }); + + // Wait for the app to come back, capturing the post-restart snapshot. + let after: StateBody | undefined; + await expect + .poll(async () => { + try { + const res = await page.request.get('/api/state'); + if (res.status() === 200) after = (await res.json()) as StateBody; + return !!after; + } catch { + return false; // connection refused / 502 while the container starts + } + }, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' }) + .toBe(true); + + const p = after!.planes.find((x) => x.id === 'plane-starter')!; + expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order… + + // Cleanup: cancel both legs so the starter is left with an empty queue. + for (let i = 0; i < 2; i++) { + const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); + expect(c.status()).toBe(200); + } +}); diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts index 87801bc..8cb3976 100644 --- a/e2e/two-planes.spec.ts +++ b/e2e/two-planes.spec.ts @@ -495,20 +495,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg' await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 }); await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao); - // Fly the STARTER to its first queued destination… + // Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the + // response snapshot must already show only [b] left (previously the leg stayed queued and the + // landing chained a zero-distance self-hop a→a before reaching b). const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } }); expect(flyRes.status()).toBe(200); + const flyBody = (await flyRes.json()) as StateBody; + expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]); // …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with // the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain… await setScale(page, SCALE); + const seen: Array<{ locationIcao: string; destIcao: string | null }> = []; await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER); + if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao }); return !!p && p.status === 'flying' && p.destIcao === b.icao; - }, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) + }, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) .toBe(true); + // …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state + // is either the outbound leg (HUB→a) or the chained leg (a→b). + expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]); + // …then restore speed so the chained leg fast-forwards too. await setScale(page, SCALE); await expect diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 122d026..c54c113 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -331,6 +331,34 @@ describe('flyPlane', () => { await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); }); + + // --- Queue consumption on dispatch (v0.46.0, ISS-088) --- + + it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => { + const inst = await freshDb('fly-consume.db'); + await queueLeg(inst, 'plane-starter', 'LEMD'); + await queueLeg(inst, 'plane-starter', 'EHAM'); + + await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('flying'); + expect(plane.destIcao).toBe('LEMD'); + expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order + }); + + it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => { + const inst = await freshDb('fly-skipahead.db'); + await queueLeg(inst, 'plane-starter', 'LEMD'); + await queueLeg(inst, 'plane-starter', 'EHAM'); + + await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('flying'); + expect(plane.destIcao).toBe('EHAM'); + expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved + }); }); describe('queueLeg (v0.25.0, ISS-043)', () => { @@ -671,6 +699,19 @@ describe('sellPlane', () => { expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane }); + it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => { + const inst = await freshDb('sell-queue.db'); + await inst.db.insert(schema.planes).values({ + id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, + locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, + }); + await queueLeg(inst, 'plane-extra', 'LEMD'); + + await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/); + const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0]; + expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane + }); + it('rejects selling the last remaining plane with 409', async () => { const inst = await freshDb('sell-last.db'); // only the starter exists await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 98e2832..d65d1a6 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -153,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): const sim = (await inst.db.select().from(schema.simState))[0]; if (!sim) throw new Error('sim_state row missing'); + // v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would + // chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead + // to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved. + const queue = parseIcaoQueue(plane.queue); + if (queue.length > 0 && queue[0] === destIcao) queue.shift(); + await inst.db.update(schema.planes) .set({ status: 'flying', @@ -161,6 +167,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): lat: origin.lat, lon: origin.lon, fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed) + queue: JSON.stringify(queue), }) .where(eq(schema.planes.id, planeId)); if (cost > 0) { @@ -340,6 +347,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout throw new GameActionError(409, `cannot sell a plane carrying cargo`); } + // v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first. + if (parseIcaoQueue(plane.queue).length > 0) { + throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first'); + } + const all = await inst.db.select().from(schema.planes); if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index a99082c..240a079 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -378,6 +378,52 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(plane.status).toBe('available'); // unchanged landing behavior… expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty }); + + it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => { + const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD + const fc = await loadAirportsGeoJson(); + const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range + + // Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted, + // exactly as deliverPackages computes it) — so no top-up could ever make it flyable. + const wTo = weatherFor(TO); + const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon)); + const nextRequiredL = requiredFuelFor( + type.burnPerKm * fNext, + type.emptyWeightKg, + type.maxFuel, + haversineKm(to.lat, to.lon, farP.lat, farP.lon), + ); + expect(nextRequiredL).toBeGreaterThan(type.maxFuel); + + await inst.db.update(schema.planes) + .set({ queue: JSON.stringify(['FGSL']) }) + .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 — the unflyable leg was NOT chained… + expect(plane.destIcao).toBeNull(); + expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch + }); + + it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => { + const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD + // Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a + // zero-distance self-hop (the phantom hop). The guard drops it and grounds instead. + await inst.db.update(schema.planes) + .set({ queue: JSON.stringify(['LEMD', 'EHAM']) }) + .where(eq(schema.planes.id, 'plane-starter')); + + expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown… + expect(plane.destIcao).toBeNull(); + expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch + }); }); describe('wear on landed legs (v0.39.0, ISS-069)', () => { diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 881172a..c4fb283 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -246,32 +246,43 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise 0 && newHealth > 0) { const nextDest = queue[0]; - const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain - if (next) { - // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). - const wNext = weatherFor(to.icao); - const nextRequiredL = requiredFuelFor( - type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)), - type.emptyWeightKg, - type.maxFuel, - haversineKm(to.lat, to.lon, next.lat, next.lon), - ); - if (nextRequiredL <= landedFuel) { - queue.shift(); - chainedTo = nextDest; - } else { - // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. - const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); - const liters = Math.max(0, target - landedFuel); - // Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. - const fresh = (await inst.db.select().from(schema.account))[0]; - if (!fresh) throw new Error('account row missing'); - const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; - if (cost === 0 || fresh.cash >= cost) { + // ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a + // zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this + // since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg). + if (nextDest === to.icao) { + queue.shift(); + } else { + const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain + if (next) { + // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). + const wNext = weatherFor(to.icao); + const nextRequiredL = requiredFuelFor( + type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)), + type.emptyWeightKg, + type.maxFuel, + haversineKm(to.lat, to.lon, next.lat, next.lon), + ); + // v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue + // intact, mirroring flyPlane's 409 without throwing in the engine. + if (nextRequiredL > type.maxFuel) { + // no chain — the grounded update below keeps the queue for a later dispatch + } else if (nextRequiredL <= landedFuel) { queue.shift(); chainedTo = nextDest; - topUpFuel = target; - if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); + } else { + // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. + const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); + const liters = Math.max(0, target - landedFuel); + // Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. + const fresh = (await inst.db.select().from(schema.account))[0]; + if (!fresh) throw new Error('account row missing'); + const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; + if (cost === 0 || fresh.cash >= cost) { + queue.shift(); + chainedTo = nextDest; + topUpFuel = target; + if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); + } } } } diff --git a/package.json b/package.json index 1c40e3e..f01fed3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.45.0", + "version": "0.46.0", "private": true, "scripts": { "dev": "next dev -p 3000", From b76acbacac0aa1086f7c1d2fc3059581395c2f50 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 21 Sep 2026 23:13:21 +0000 Subject: [PATCH 2/2] fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88) --- e2e/queue.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 73f3f0a..7d09d2b 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -569,8 +569,11 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou }, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true); const s = await getState(page); - expect(s.planes.find((x) => x.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // departed with exactly the post-top-up level… - expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // …at exactly the rep-discounted cost + // Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below. + const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel; + expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure… + expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound + expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost // Let leg 2 finish so the next test starts from a known airport. await expect.poll(async () => {