diff --git a/CHANGELOG.md b/CHANGELOG.md index 9807956..7f82dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.31.0 — 2026-09-20 21:23 +- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane +- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`) +- **New test hook: POST /api/test/add-cash (ISS-055).** Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body `{ amount?: number }` (positive finite, default €1M), gated on `FLIGHT_TEST_HOOKS=1` exactly like its siblings (`app/api/test/create-package`, `/expire-package`; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen +- **New serial e2e spec `e2e/two-planes.spec.ts` (ISS-055).** Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't `plane-starter`: **load** (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), **fly** (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves `paused === false && timeScale === 1`, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes `lastSelectedTimeScale` = 1000 in the /fly response), **refuel** (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), **relocate** (moves one plane, starter untouched), **queue** (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), **sell** (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1 +- Verified: `tsc --noEmit` clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); `npx playwright test --list` = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack + ## v0.30.0 — 2026-09-20 19:47 - **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore - **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1× diff --git a/app/api/test/add-cash/route.ts b/app/api/test/add-cash/route.ts new file mode 100644 index 0000000..32e15ff --- /dev/null +++ b/app/api/test/add-cash/route.ts @@ -0,0 +1,58 @@ +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.31.0, ISS-055) — grants cash so the e2e suite can afford a second +// plane (ATR €200k vs the €20k starting balance). 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/create-package — same gate, same conventions. +// +// POST /api/test/add-cash +// body { amount?: number } — a positive finite number when provided; defaults to €1M. +// Adds to account.cash (rounded to cents) and returns the full state snapshot. + +const DEFAULT_AMOUNT = 1_000_000; + +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 }); + } + } + let amount = DEFAULT_AMOUNT; + if (input.amount !== undefined) { + if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) { + return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 }); + } + amount = input.amount; + } + + 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((account.cash + 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/components/LeftPane.tsx b/components/LeftPane.tsx index 8280a82..b67a431 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -466,14 +466,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh add control reusing this pane's already-fetched airport options (no second fetch). */}
Queue + {/* ISS-055 (v0.31.0): testids scoped by plane id — with two on-board blocks the + unscoped ids duplicated in the DOM and broke Playwright strict mode. */} {plane.queue.map((icao, i) => ( -
+
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})