Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847e74b7b8 | ||
|
|
394d51d827 | ||
|
|
264778522c | ||
|
|
e55078fb5d | ||
|
|
4e585cbed1 | ||
|
|
89ad09a04d | ||
|
|
bf2c46b959 | ||
|
|
fa64e32f65 | ||
|
|
04b6f20a93 | ||
|
|
acee806180 | ||
|
|
1e3992f872 | ||
|
|
57cc6705f2 | ||
|
|
c9e9113a45 | ||
|
|
5e7ec903d3 | ||
|
|
0b946b743c |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v0.34.0 — 2026-09-21 01:26
|
||||
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
|
||||
|
||||
## v0.33.0 — 2026-09-21 00:45
|
||||
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
|
||||
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
|
||||
|
||||
## v0.32.0 — 2026-09-20 23:47
|
||||
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
|
||||
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
|
||||
|
||||
## 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×
|
||||
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
|
||||
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
|
||||
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.29.1 — 2026-09-20 18:22
|
||||
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
|
||||
|
||||
## v0.29.0 — 2026-09-20 14:27
|
||||
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
|
||||
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
@@ -84,6 +86,21 @@ describe('POST /api/sim', () => {
|
||||
await post({ timeScale: 1, paused: false }); // restore
|
||||
});
|
||||
|
||||
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
|
||||
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
|
||||
const row = async () => (await inst.db.select().from(schema.simState))[0];
|
||||
|
||||
await post({ timeScale: 250 });
|
||||
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
|
||||
|
||||
await post({ paused: true });
|
||||
expect((await row()).paused).toBe(true);
|
||||
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
|
||||
|
||||
await post({ timeScale: 1, paused: false }); // restore
|
||||
expect((await row()).lastSelectedTimeScale).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid input with 400', async () => {
|
||||
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
||||
const res = await post(body);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -376,17 +376,10 @@ body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
|
||||
.onboard-block {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
|
||||
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
|
||||
.onboard-block .contract-row {
|
||||
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
|
||||
a full-width fly-to button then refuel + abandon side by side below it; long destination names
|
||||
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
|
||||
.plane-row .contract-row {
|
||||
background: transparent;
|
||||
padding: 6px 0 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
@@ -395,7 +388,7 @@ body {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.onboard-block .contract-row .contract-sub {
|
||||
.plane-row .contract-row .contract-sub {
|
||||
margin-top: 0; /* row spacing comes from the container's gap */
|
||||
}
|
||||
|
||||
|
||||
+153
-197
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||
onRefresh: () => void;
|
||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
|
||||
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
|
||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||
onHover: (target: HoverTarget | null) => void;
|
||||
@@ -92,7 +92,7 @@ export async function retrySimPost(
|
||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
|
||||
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||
|
||||
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
||||
@@ -106,10 +106,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
// a sensible default (first grounded plane with fuel below max).
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
||||
|
||||
// ISS-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
|
||||
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
|
||||
const [queueAddDestIcao, setQueueAddDestIcao] = useState<string | null>(null);
|
||||
|
||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||
@@ -199,27 +195,23 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
const paused = state?.simState.paused ?? false;
|
||||
const timeScale = state?.simState.timeScale ?? 1;
|
||||
|
||||
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
|
||||
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
|
||||
// abandon controls, and flying planes show their current leg.
|
||||
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||
const clockMs = state?.simState.clockMs ?? 0;
|
||||
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
|
||||
// reason it's here even when grounded with nothing on board.
|
||||
const onboard = state
|
||||
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
|
||||
: [];
|
||||
|
||||
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
|
||||
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
|
||||
// the right-pane market row; with state null both fall back to null and the section isn't rendered.
|
||||
const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
|
||||
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
|
||||
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
|
||||
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||
// market row; with state null both fall back to null and the section isn't rendered.
|
||||
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||||
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
|
||||
const relocatable = state ? state.planes : [];
|
||||
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||
const relocateDest =
|
||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||
|
||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
||||
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
||||
// market row; reuses `grounded` above, and with state null falls back to null.
|
||||
// market row; grounded-only, and with state null falls back to null.
|
||||
const grounded = relocatable.filter((p) => p.status === 'available');
|
||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
||||
const refuelPlane =
|
||||
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
||||
@@ -341,185 +333,146 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
<p className="pane-muted">No planes.</p>
|
||||
) : (
|
||||
<ul data-testid="fleet-list" className="fleet-list">
|
||||
{state.planes.map((p) => (
|
||||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
||||
<li
|
||||
key={p.id}
|
||||
data-testid={`plane-${p.id}`}
|
||||
className="plane-row"
|
||||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="plane-main">
|
||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
||||
</div>
|
||||
<div className="fuel-bar" aria-hidden="true">
|
||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||
</div>
|
||||
<div className="plane-sub">
|
||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||
<span className="plane-loc">{planeLocation(p)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{state.planes.map((p) => {
|
||||
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
|
||||
// On board section's cargo rows and queue legs, so all plane info lives in one place.
|
||||
const cargo = state.packages.filter(
|
||||
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||
);
|
||||
return (
|
||||
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
|
||||
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
|
||||
// the whole block without flicker between sub-elements; cargo rows override with their
|
||||
// own package hover (below).
|
||||
<li
|
||||
key={p.id}
|
||||
data-testid={`plane-${p.id}`}
|
||||
className="plane-row"
|
||||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="plane-main">
|
||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||
{p.status === 'flying' ? (
|
||||
<>
|
||||
<span className="plane-status status-flying">in flight</span>
|
||||
{p.destName && (
|
||||
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
|
||||
{p.locationName} → {p.destName}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="fuel-bar" aria-hidden="true">
|
||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||
</div>
|
||||
<div className="plane-sub">
|
||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||
<span className="plane-loc">{planeLocation(p)}</span>
|
||||
</div>
|
||||
{cargo.map((pkg) => {
|
||||
const penalty = abandonPenalty(pkg, clockMs);
|
||||
return (
|
||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
||||
// mouseleave fires before the parent's).
|
||||
<div
|
||||
key={pkg.id}
|
||||
data-testid={`onboard-package-${pkg.id}`}
|
||||
className="contract-row"
|
||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
|
||||
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`fly-to-${pkg.id}`}
|
||||
className="pane-btn onboard-fly-btn"
|
||||
disabled={actionsBusy}
|
||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||||
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||
>
|
||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
||||
Fly to {pkg.destName}
|
||||
</span>
|
||||
</button>
|
||||
<div className="onboard-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`refuel-for-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || p.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
||||
>
|
||||
Refuel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`abandon-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || p.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||
>
|
||||
Abandon{' '}
|
||||
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||
<span>{formatCash(pkg.reward)}</span>
|
||||
{pkg.status === 'expired' ? (
|
||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||
) : (
|
||||
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
|
||||
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
||||
/queue while a plane is flying, so no separate entry point is needed.
|
||||
ISS-054 (v0.33.0): rendered only when non-empty. */}
|
||||
{p.queue.length > 0 && (
|
||||
<div className="queue-subsection">
|
||||
<span className="queue-heading">Queue</span>
|
||||
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
|
||||
duplicated in the DOM and broke Playwright strict mode. */}
|
||||
{p.queue.map((icao, i) => (
|
||||
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
|
||||
<span className="contract-route">
|
||||
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`queue-cancel-${p.id}-${i}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy}
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">On board</h3>
|
||||
{onboard.length === 0 ? (
|
||||
<p className="pane-muted">Nothing on board.</p>
|
||||
) : (
|
||||
onboard.map((plane) => {
|
||||
const cargo = state.packages.filter(
|
||||
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||
);
|
||||
return (
|
||||
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
|
||||
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
|
||||
// child, so this covers the whole block without flicker between sub-elements.
|
||||
<div
|
||||
key={plane.id}
|
||||
data-testid={`onboard-${plane.id}`}
|
||||
className="onboard-block"
|
||||
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div className="contract-main">
|
||||
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
|
||||
{plane.status === 'flying' ? (
|
||||
<>
|
||||
<span className="plane-status status-flying">in flight</span>
|
||||
{plane.destName && (
|
||||
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
|
||||
{plane.locationName} → {plane.destName}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="plane-status status-available">{plane.locationIcao}</span>
|
||||
)}
|
||||
</div>
|
||||
{cargo.map((pkg) => {
|
||||
const penalty = abandonPenalty(pkg, clockMs);
|
||||
return (
|
||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
||||
// mouseleave fires before the parent's).
|
||||
<div
|
||||
key={pkg.id}
|
||||
data-testid={`onboard-package-${pkg.id}`}
|
||||
className="contract-row"
|
||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
|
||||
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`fly-to-${pkg.id}`}
|
||||
className="pane-btn onboard-fly-btn"
|
||||
disabled={actionsBusy || plane.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
|
||||
>
|
||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
||||
Fly to {pkg.destName}
|
||||
</span>
|
||||
</button>
|
||||
<div className="onboard-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`refuel-for-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || plane.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
||||
>
|
||||
Refuel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`abandon-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || plane.status !== 'available'}
|
||||
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||
>
|
||||
Abandon{' '}
|
||||
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||
<span>{formatCash(pkg.reward)}</span>
|
||||
{pkg.status === 'expired' ? (
|
||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||
) : (
|
||||
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
|
||||
add control reusing this pane's already-fetched airport options (no second fetch). */}
|
||||
<div className="queue-subsection">
|
||||
<span className="queue-heading">Queue</span>
|
||||
{plane.queue.map((icao, i) => (
|
||||
<div key={`${icao}-${i}`} data-testid={`queue-leg-${i}`} className="queue-row">
|
||||
<span className="contract-route">
|
||||
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`queue-cancel-${i}`}
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy}
|
||||
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="market-row">
|
||||
<select
|
||||
data-testid="queue-add-dest"
|
||||
aria-label="Airport to add to the flight queue"
|
||||
value={queueAddDestIcao ?? ''}
|
||||
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
|
||||
>
|
||||
<option value="">Select an airport…</option>
|
||||
{airportOptions?.map((a) => (
|
||||
<option key={a.icao} value={a.icao}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="queue-add"
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || queueAddDestIcao === null}
|
||||
onClick={() => {
|
||||
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
|
||||
}}
|
||||
>
|
||||
Add to queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
||||
everything else about our planes, between On board and Time. */}
|
||||
everything else about our planes, between Fleet and Time. */}
|
||||
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
||||
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
||||
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
|
||||
With no planes or no loaded airport options there is nothing to preview → null. */}
|
||||
<section
|
||||
className="pane-section"
|
||||
data-testid="relocate-section"
|
||||
@@ -533,8 +486,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<h3 className="pane-heading">Relocate</h3>
|
||||
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
|
||||
repositioning, this row just exposes it for planes with nothing on board. */}
|
||||
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
|
||||
repositioning, this row just exposes it for planes with nothing on board.
|
||||
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
|
||||
<div className="market-row market-row-relocate">
|
||||
<select
|
||||
data-testid="relocate-plane"
|
||||
@@ -542,7 +496,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
value={relocatePlane?.id ?? ''}
|
||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
{relocatable.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
||||
</option>
|
||||
@@ -565,9 +519,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
data-testid="relocate-go"
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
||||
// relocation takes off when the current one lands.
|
||||
onClick={() => {
|
||||
if (relocatePlane && relocateDest) {
|
||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
||||
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -581,7 +537,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
<section className="pane-section" data-testid="refuel-section">
|
||||
<h3 className="pane-heading">Refuel</h3>
|
||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
||||
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
|
||||
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
|
||||
<div className="market-row">
|
||||
{refuelable.length === 0 ? (
|
||||
<span data-testid="market-refuel-none" className="pane-muted">
|
||||
|
||||
@@ -49,8 +49,9 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
|
||||
// Load controls for one open package: a single Load button when exactly one plane
|
||||
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||
// selection in local state and defaults to the "Select a plane…" placeholder —
|
||||
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
|
||||
// selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
|
||||
// first eligible plane so Load is one-click out of the box (matching the single-plane
|
||||
// UX) while the picker still allows choosing a different plane.
|
||||
function LoadControls({
|
||||
pkg,
|
||||
eligible,
|
||||
@@ -63,8 +64,9 @@ function LoadControls({
|
||||
onLoad: (planeId: string) => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// The user's pick while it stays eligible, else nothing (placeholder).
|
||||
const effective = eligible.find((p) => p.id === selectedId) ?? null;
|
||||
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
|
||||
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
|
||||
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
|
||||
|
||||
if (eligible.length === 1) {
|
||||
return (
|
||||
|
||||
+3
-3
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
|
||||
}).toBe(0);
|
||||
});
|
||||
|
||||
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
|
||||
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
||||
expect(fly.status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
|
||||
const block = page.getByTestId('onboard-plane-starter');
|
||||
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||
const block = page.getByTestId('plane-plane-starter');
|
||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||
|
||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||||
|
||||
+3
-39
@@ -70,10 +70,9 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
// Sim clock renders (UTC wall-clock of the game time).
|
||||
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
||||
|
||||
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
|
||||
// its heading renders here…
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
||||
// …and no longer in the right pane.
|
||||
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
|
||||
// per-plane cargo rows now render inside the fleet rows.
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||
|
||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
||||
@@ -85,41 +84,6 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const pauseBtn = page.getByTestId('pause-toggle');
|
||||
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Pause via the UI button — assert against the authoritative /api/state…
|
||||
await pauseBtn.click();
|
||||
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
|
||||
// …and in the pane itself (the refetch after POST flips the button to Resume).
|
||||
await expect(pauseBtn).toHaveText('Resume');
|
||||
|
||||
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
|
||||
// optimistically on input, and release flushes the debounced POST immediately.
|
||||
const slider = page.getByTestId('time-scale-slider');
|
||||
await slider.focus();
|
||||
await page.keyboard.press('End');
|
||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
||||
|
||||
// Home jumps to min (t=0 → 1×).
|
||||
await page.keyboard.press('Home');
|
||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
|
||||
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
|
||||
|
||||
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
|
||||
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(restore.status()).toBe(200);
|
||||
const after = await getState(page);
|
||||
expect(after.simState.timeScale).toBe(1);
|
||||
expect(after.simState.paused).toBe(false);
|
||||
});
|
||||
|
||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
|
||||
+17
-13
@@ -1,7 +1,8 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// 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 (add via airport select + button, cancel per leg), and
|
||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||
// while flying, ISS-053), and
|
||||
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
|
||||
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||
@@ -216,9 +217,9 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// LeftPane's own /api/airports fetch populates the queue-add select — wait for it.
|
||||
const addSelect = page.getByTestId('queue-add-dest');
|
||||
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
|
||||
const destSelect = page.getByTestId('relocate-dest');
|
||||
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||
|
||||
// START leg 1 via the UI (fly-to button in the on-board block).
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
@@ -232,20 +233,23 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
||||
|
||||
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected…
|
||||
const addButton = page.getByTestId('queue-add');
|
||||
await expect(addButton).toBeDisabled();
|
||||
await addSelect.selectOption(QUEUE);
|
||||
await expect(addButton).toBeEnabled();
|
||||
await addButton.click();
|
||||
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||
expect(dupQueue.status()).toBe(409);
|
||||
|
||||
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
||||
// posts to /queue and the leg starts when it lands.
|
||||
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||||
await destSelect.selectOption(QUEUE);
|
||||
await page.getByTestId('relocate-go').click();
|
||||
|
||||
// …and the new leg appears as queue-leg-0 with its destination.
|
||||
const legRow = page.getByTestId('queue-leg-0');
|
||||
const legRow = page.getByTestId('queue-leg-plane-starter-0');
|
||||
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
|
||||
|
||||
// CANCEL it via the UI and assert it's gone.
|
||||
await page.getByTestId('queue-cancel-0').click();
|
||||
await expect(page.getByTestId('queue-leg-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||
await page.getByTestId('queue-cancel-plane-starter-0').click();
|
||||
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||
await setScale(page, SCALE);
|
||||
|
||||
@@ -268,8 +268,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
await expect(flyBtn).toBeEnabled();
|
||||
await flyBtn.click();
|
||||
|
||||
// The on-board block now shows the plane in flight with its current leg…
|
||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
|
||||
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||
await expect(leg).toBeVisible();
|
||||
await expect(leg).toContainText('→');
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses
|
||||
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight
|
||||
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses →
|
||||
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded
|
||||
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
|
||||
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
|
||||
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
|
||||
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
|
||||
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
@@ -34,8 +33,8 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
|
||||
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
@@ -78,8 +77,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -96,13 +95,13 @@ test.afterEach(async ({ page }) => {
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => {
|
||||
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
|
||||
// Set 1000× via the API and verify it stuck.
|
||||
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
||||
@@ -120,18 +119,20 @@ test('last landing pauses the sim without changing speed; dispatch auto-resumes
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
||||
.toBe(true);
|
||||
|
||||
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it…
|
||||
expect(landedBody!.simState.paused).toBe(true);
|
||||
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch.
|
||||
expect(landedBody!.simState.timeScale).toBe(SCALE);
|
||||
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
|
||||
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
|
||||
expect(landedBody!.simState.paused).toBe(false);
|
||||
expect(landedBody!.simState.timeScale).toBe(1);
|
||||
|
||||
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full
|
||||
// post-action snapshot, so no poll race with the client's next tick.
|
||||
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
|
||||
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
|
||||
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
|
||||
// resumes it.
|
||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(fly2.status()).toBe(200);
|
||||
const resumedBody = (await fly2.json()) as StateBody;
|
||||
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect
|
||||
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
|
||||
|
||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
||||
@@ -0,0 +1,590 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
||||
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
|
||||
//
|
||||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
queue: string[];
|
||||
}
|
||||
|
||||
interface PackageState {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
destIcao: string;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||
planeId: string | null;
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
model: string;
|
||||
label: string;
|
||||
price: number;
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
emptyWeightKg: number;
|
||||
speedKmh: number;
|
||||
maxPayloadKg: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
catalog: CatalogEntry[];
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
}
|
||||
|
||||
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
|
||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
|
||||
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
|
||||
// proceeds at full speed.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
|
||||
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
|
||||
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
|
||||
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
|
||||
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||
}
|
||||
|
||||
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
|
||||
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
return fc.features
|
||||
.filter((f) => f.properties.icao !== HUB)
|
||||
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||
.sort((x, y) => x.d - y.d)
|
||||
.slice(0, 2)
|
||||
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
|
||||
}
|
||||
|
||||
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
|
||||
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409).
|
||||
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||||
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
||||
for (const f of fc.features) {
|
||||
if (f.properties.icao === HUB) continue;
|
||||
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
||||
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
||||
if (!farthest || d > farthest.d) {
|
||||
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
||||
}
|
||||
}
|
||||
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
|
||||
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
|
||||
async function createPackage(
|
||||
page: Page,
|
||||
fromIcao: string,
|
||||
toIcao: string,
|
||||
deadlineOffsetMs?: number,
|
||||
weightKg?: number,
|
||||
): Promise<PackageState> {
|
||||
const created = await page.request.post('/api/test/create-package', {
|
||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as PackageState;
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
|
||||
// post-grant snapshot (the route's response).
|
||||
async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
||||
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
||||
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||||
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||||
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
|
||||
|
||||
await page.goto('/');
|
||||
const right = page.getByTestId('right-pane');
|
||||
await expect(right).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('tab-planes-market').click();
|
||||
|
||||
const buyModel = page.getByTestId('market-buy-model');
|
||||
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
|
||||
await buyModel.selectOption(BUY_MODEL);
|
||||
await page.getByTestId('market-buy').click();
|
||||
|
||||
let secondId: string | null = null;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
|
||||
return secondId !== null;
|
||||
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
|
||||
.toBe(true);
|
||||
return secondId!;
|
||||
}
|
||||
|
||||
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||
if (q.length === 0) return;
|
||||
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
|
||||
// carrying cargo, and clean baselines keep exact-cash assertions honest.
|
||||
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
|
||||
const s = await getState(page);
|
||||
for (const pkg of s.packages) {
|
||||
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
|
||||
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
|
||||
// specs, generalized to an arbitrary plane id.
|
||||
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||||
await clearQueueViaApi(page, planeId);
|
||||
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||||
if (!p) return; // already sold — nothing to heal
|
||||
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
|
||||
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
|
||||
await setScale(page, SCALE);
|
||||
if (p.status === 'flying') {
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
const plane = s.planes.find((x) => x.id === planeId);
|
||||
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
|
||||
return plane?.status ?? '';
|
||||
}, {
|
||||
timeout: 240_000,
|
||||
message: `waiting for an in-flight ${planeId} to land`,
|
||||
})
|
||||
.toBe('available');
|
||||
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||
}
|
||||
if (p.locationIcao !== HUB) {
|
||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
if (flyHome.status() === 409) {
|
||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
|
||||
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
}
|
||||
expect(flyHome.status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: `waiting for ${planeId} to land back at the hub`,
|
||||
})
|
||||
.toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
|
||||
// in flight) — leaves the fleet at exactly one plane.
|
||||
async function sellNonStarters(page: Page): Promise<void> {
|
||||
const s = await getState(page);
|
||||
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
|
||||
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
|
||||
await abandonLeftoverCargo(page, p.id);
|
||||
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||
// no cargo on board — even after an interrupted previous run.
|
||||
async function resetFleet(page: Page): Promise<void> {
|
||||
await sellNonStarters(page);
|
||||
await healPlaneHome(page, STARTER);
|
||||
await abandonLeftoverCargo(page, STARTER);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetFleet(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
|
||||
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
|
||||
|
||||
const [nearest] = await twoNearestToHub(page);
|
||||
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
|
||||
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||
|
||||
// The multi-plane picker lists BOTH planes…
|
||||
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
|
||||
await expect(picker).toBeVisible({ timeout: 15_000 });
|
||||
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||
|
||||
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
|
||||
const s0 = await getState(page);
|
||||
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
|
||||
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
|
||||
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
|
||||
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
|
||||
|
||||
// One-click Load binds the package to the preselected (first eligible) plane.
|
||||
await loadBtn1.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
|
||||
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
|
||||
.toBe(firstEligible.id);
|
||||
|
||||
// Second package — pick the OTHER plane explicitly via the dropdown.
|
||||
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
|
||||
await expect(picker2).toBeVisible({ timeout: 15_000 });
|
||||
await picker2.selectOption(secondId);
|
||||
await page.getByTestId(`load-${pkg2.id}`).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
|
||||
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
||||
.toBe(secondId);
|
||||
|
||||
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
|
||||
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
||||
const secondId = await buySecondPlaneViaUi(page);
|
||||
|
||||
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||
|
||||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
|
||||
const [nearest] = await twoNearestToHub(page);
|
||||
const farthest = await farthestFlyableToHub(page);
|
||||
|
||||
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
|
||||
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
|
||||
|
||||
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||
expect(flyA.status()).toBe(200);
|
||||
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
|
||||
expect(flyB.status()).toBe(200);
|
||||
|
||||
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
|
||||
const s1 = (await flyB.json()) as StateBody;
|
||||
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
|
||||
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
|
||||
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||||
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||||
|
||||
// Fast-forward to the FIRST landing (the short hop)…
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for the first plane to land',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for both planes to land',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
|
||||
const landed = await getState(page);
|
||||
expect(landed.simState.paused).toBe(false);
|
||||
expect(landed.simState.timeScale).toBe(1);
|
||||
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||
|
||||
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
|
||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||
|
||||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyB2.status()).toBe(200);
|
||||
let body = (await flyB2.json()) as StateBody;
|
||||
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
|
||||
|
||||
// …but dispatching the LAST grounded plane restores the last manually selected speed.
|
||||
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyA2.status()).toBe(200);
|
||||
body = (await flyA2.json()) as StateBody;
|
||||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||||
});
|
||||
|
||||
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
|
||||
const secondId = await buySecondPlaneViaUi(page);
|
||||
|
||||
// Partial tanks: a short hop each, to different destinations.
|
||||
const [a, b] = await twoNearestToHub(page);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||
|
||||
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for the first plane to land',
|
||||
})
|
||||
.toBe(true);
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for both planes to land',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const pre = await getState(page);
|
||||
const starter = pre.planes.find((p) => p.id === STARTER)!;
|
||||
const second = pre.planes.find((p) => p.id === secondId)!;
|
||||
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
|
||||
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
|
||||
|
||||
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
|
||||
const refuelSelect = page.getByTestId('market-refuel-plane');
|
||||
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
|
||||
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||
|
||||
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
|
||||
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100;
|
||||
await refuelSelect.selectOption(secondId);
|
||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||||
|
||||
await page.getByTestId('market-refuel').click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
|
||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||||
.toBe(true);
|
||||
|
||||
const post = await getState(page);
|
||||
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
|
||||
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
|
||||
});
|
||||
|
||||
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||
|
||||
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
|
||||
|
||||
// The Relocate section lists BOTH grounded planes…
|
||||
const relocateSelect = page.getByTestId('relocate-plane');
|
||||
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
|
||||
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||
|
||||
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
|
||||
const destSelect = page.getByTestId('relocate-dest');
|
||||
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||
|
||||
// Relocate ONLY the second plane…
|
||||
await relocateSelect.selectOption(secondId);
|
||||
await destSelect.selectOption(a.icao);
|
||||
await page.getByTestId('relocate-go').click();
|
||||
|
||||
// …it is flying to the new airport while the starter stays put at EDDF.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
|
||||
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
|
||||
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
|
||||
.toBe(true);
|
||||
|
||||
// Let it land — locationIcao flips to the destination on arrival.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: `waiting for the relocated plane to land at ${a.icao}`,
|
||||
})
|
||||
.toBe(a.icao);
|
||||
|
||||
const s = await getState(page);
|
||||
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
|
||||
});
|
||||
|
||||
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||
const secondId = await buySecondPlaneViaUi(page);
|
||||
const [a, b] = await twoNearestToHub(page);
|
||||
|
||||
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
|
||||
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||
|
||||
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
|
||||
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
|
||||
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
|
||||
|
||||
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
|
||||
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
|
||||
|
||||
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
|
||||
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
|
||||
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…
|
||||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
// …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);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||
.toBe(true);
|
||||
|
||||
// …then restore speed so the chained leg fast-forwards too.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||
return !!p && p.status === 'available' && p.locationIcao === b.icao;
|
||||
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
|
||||
.toBe(true);
|
||||
|
||||
// The second plane never moved — still grounded at EDDF with an empty queue.
|
||||
const s = await getState(page);
|
||||
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
|
||||
});
|
||||
|
||||
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||
|
||||
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||||
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
|
||||
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
|
||||
|
||||
// The Sell row lists BOTH grounded planes…
|
||||
const sellSelect = page.getByTestId('market-sell-plane');
|
||||
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||
|
||||
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
|
||||
await sellSelect.selectOption(secondId);
|
||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||
await page.getByTestId('market-sell').click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const s = await getState(page);
|
||||
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
|
||||
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
|
||||
.toBe(true);
|
||||
|
||||
// The last remaining plane can never be sold — even via the API.
|
||||
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
|
||||
expect(res.status()).toBe(409);
|
||||
});
|
||||
@@ -151,7 +151,8 @@ describe('openGameDb', () => {
|
||||
raw.exec(LEGACY_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)');
|
||||
// time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale.
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||
const file = path.join(dir, 'legacy.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
@@ -167,6 +168,9 @@ describe('openGameDb', () => {
|
||||
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
|
||||
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
||||
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
||||
// v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default.
|
||||
expect(sim?.timeScale).toBe(50);
|
||||
expect(sim?.lastSelectedTimeScale).toBe(50);
|
||||
});
|
||||
|
||||
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
||||
|
||||
+8
-1
@@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
||||
clock_ms INTEGER NOT NULL,
|
||||
time_scale REAL NOT NULL,
|
||||
paused INTEGER NOT NULL,
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -82,6 +83,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
if (!cols('packages').has('weight_kg')) {
|
||||
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
||||
}
|
||||
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
||||
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||
|
||||
@@ -61,6 +61,10 @@ export const simState = sqliteTable('sim_state', {
|
||||
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
|
||||
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
|
||||
lastRealTickMs: integer('last_real_tick_ms').notNull(),
|
||||
// Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without
|
||||
// touching this; flyPlane restores it when the last grounded plane takes off. Written by
|
||||
// POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale.
|
||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
|
||||
@@ -170,16 +170,32 @@ describe('flyPlane', () => {
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
});
|
||||
|
||||
it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => {
|
||||
const inst = await freshDb('fly-unpause.db');
|
||||
// The last landing pauses the sim at whatever scale was in use.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 });
|
||||
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-resume.db');
|
||||
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
|
||||
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
|
||||
});
|
||||
|
||||
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-stayslow.db');
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
// A second grounded plane — the starter taking off leaves it on the ground.
|
||||
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 flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch auto-resumes…
|
||||
expect(sim.timeScale).toBe(250); // …with the stored speed preserved
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
@@ -245,6 +261,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
const inst = await freshDb('qleg-unknownicao.db');
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||
const inst = await freshDb('qleg-dup.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||
});
|
||||
|
||||
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
|
||||
const inst = await freshDb('qleg-dupflying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||
});
|
||||
|
||||
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||
const inst = await freshDb('qleg-dupother.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||
|
||||
+17
-3
@@ -125,9 +125,14 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
lon: origin.lon,
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside
|
||||
// runAction(), so tick + unpause + dispatch are atomic under the sim lock.
|
||||
await inst.db.update(schema.simState).set({ paused: false });
|
||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||
// + dispatch are atomic under the sim lock.
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
|
||||
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||||
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
|
||||
await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
@@ -148,7 +153,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
|
||||
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||
}
|
||||
|
||||
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
|
||||
// leg already in the queue would only produce pointless repeated hops on landing.
|
||||
if (plane.status === 'flying' && plane.destIcao === destIcao) {
|
||||
throw new GameActionError(409, `already flying to ${destIcao}`);
|
||||
}
|
||||
|
||||
const queue = parseIcaoQueue(plane.queue);
|
||||
if (queue.includes(destIcao)) {
|
||||
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
|
||||
}
|
||||
queue.push(destIcao);
|
||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
|
||||
@@ -105,8 +105,9 @@ describe('buildSeed', () => {
|
||||
it('seeds the starting account and sim state', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
|
||||
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
|
||||
});
|
||||
|
||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||
|
||||
+4
-2
@@ -62,7 +62,7 @@ export interface SeedData {
|
||||
plane: SeedPlane;
|
||||
packages: SeedPackage[];
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number };
|
||||
}
|
||||
|
||||
// --- Tunables (documented so future revenue/cost work can build on them) ---
|
||||
@@ -225,6 +225,8 @@ export function buildSeed(input: {
|
||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs },
|
||||
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
|
||||
// selected manually yet, so resume would restore 1× anyway.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ 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')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
|
||||
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']);
|
||||
|
||||
@@ -260,6 +260,11 @@ export const openApiSpec = {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+27
-23
@@ -301,12 +301,13 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
|
||||
// any advancing tick past T0 finalizes it. timeScale is the value under test.
|
||||
// any advancing tick past T0 finalizes it. timeScale is the value under test;
|
||||
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
|
||||
async function setupLanding(name: string, timeScale: number) {
|
||||
const inst = await freshDb(name);
|
||||
const fc = await loadAirportsGeoJson();
|
||||
@@ -315,7 +316,7 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
await inst.db.update(schema.simState).set({ timeScale });
|
||||
await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
@@ -323,20 +324,21 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
return { inst, from, km, type };
|
||||
}
|
||||
|
||||
it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => {
|
||||
const { inst } = await setupLanding('pause-last.db', 1000);
|
||||
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
|
||||
const { inst } = await setupLanding('slow-last.db', 1000);
|
||||
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(true); // persisted on the sim_state row…
|
||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(true); // …and visible to the next snapshot
|
||||
expect(sim.timeScale).toBe(1000); // speed preserved — no auto-slow anymore (ISS-041)
|
||||
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
|
||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
|
||||
expect(sim.timeScale).toBe(1); // …slowed to the floor…
|
||||
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
|
||||
});
|
||||
|
||||
it('a landing while another plane is still flying → no pause', async () => {
|
||||
const { inst, from, type } = await setupLanding('pause-other.db', 1000);
|
||||
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
|
||||
const { inst, from, type } = await setupLanding('slow-other.db', 1000);
|
||||
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
|
||||
@@ -346,26 +348,26 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
vi.setSystemTime(T0 + 2_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it)
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
|
||||
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
|
||||
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
|
||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle
|
||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
|
||||
});
|
||||
|
||||
it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => {
|
||||
const { inst } = await setupLanding('pause-one.db', 1);
|
||||
it('already at 1× → stays 1 (no-op slow)', async () => {
|
||||
const { inst } = await setupLanding('slow-one.db', 1);
|
||||
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true);
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1);
|
||||
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
|
||||
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
|
||||
const { inst } = await setupLanding('slow-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']) })
|
||||
@@ -374,23 +376,25 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
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…
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
|
||||
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');
|
||||
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
|
||||
const inst = await freshDb('slow-paused.db');
|
||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
||||
// controls even though "no plane in flight" holds: the trigger requires a landing this tick.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1000, paused: true });
|
||||
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
|
||||
|
||||
vi.setSystemTime(T0 + 5_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
expect(sim.lastSelectedTimeScale).toBe(1000);
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
+24
-15
@@ -75,8 +75,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
let landedThisTick = 0;
|
||||
if (advanceMs > 0) {
|
||||
await expirePackages(inst, clockMs);
|
||||
// ISS-041: remember the landings so the tick can pause when this was the last
|
||||
// plane in flight.
|
||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||
landedThisTick = await deliverPackages(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
@@ -84,18 +83,21 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await spawnCoveragePackages(inst, clockMs);
|
||||
}
|
||||
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale' | 'paused'>> = {
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
|
||||
clockMs,
|
||||
lastRealTickMs: nowMs,
|
||||
};
|
||||
// Pause on landing (ISS-041): when ≥1 plane landed this tick and NO plane remains in flight,
|
||||
// pause the sim WITHOUT touching timeScale — speed is preserved for the next dispatch.
|
||||
// Arrival-triggered by design: a grounded fleet at high scale is left alone (there is no
|
||||
// auto-resume except starting a flight; see flyPlane). A paused tick never processes landings
|
||||
// (advanceMs === 0), so the trigger can only fire on an advancing tick.
|
||||
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
|
||||
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — 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 is manual-only now;
|
||||
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
|
||||
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
|
||||
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
|
||||
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
|
||||
if (landedThisTick > 0) {
|
||||
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying'));
|
||||
if (flying.length === 0) sets.paused = true;
|
||||
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||||
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
@@ -132,8 +134,8 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
||||
// 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.
|
||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||||
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
|
||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, packageRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
@@ -320,13 +322,20 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||||
|
||||
// Tick first so the returned state reflects current time, then apply the new
|
||||
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
|
||||
// the route handler (400 on non-number / non-boolean).
|
||||
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
|
||||
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
|
||||
// the last grounded plane takes off. A {paused}-only post never touches it, so the
|
||||
// last manual intent wins without an extra flag.
|
||||
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
|
||||
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
|
||||
if (input.timeScale !== undefined) {
|
||||
const scale = clampTimeScale(input.timeScale);
|
||||
sets.timeScale = scale;
|
||||
sets.lastSelectedTimeScale = scale;
|
||||
}
|
||||
if (input.paused !== undefined) sets.paused = input.paused;
|
||||
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.29.0",
|
||||
"version": "0.34.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user