Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2133fd25c | ||
|
|
179fad450d | ||
|
|
85b7b07f53 | ||
|
|
5ef661a06f | ||
|
|
408cd933f4 | ||
|
|
3395fb4f90 | ||
|
|
537c494daa | ||
|
|
69b708dad3 |
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.27.0 — 2026-09-20 12:27
|
||||||
|
- **Packages now have real weight (ISS-045).** New `weight_kg REAL NOT NULL DEFAULT 2000` column on `packages` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`; pre-existing rows backfill to the mid-range default). `generatePackage` draws it AFTER cargoCount: `UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng())`, rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table `UNIT_WEIGHT_KG` in `lib/game/seed.ts` (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
|
||||||
|
- **Plane capacity is now max payload kg, not 6 slots (ISS-045).** `capacity` REMOVED from `PlaneType`/`CatalogEntry`/snapshot catalog; replaced by `maxPayloadKg` — ATR 72-600 = 4800 kg (`lib/game/catalog.ts`). Server load validation (`loadPackage`, `lib/game/actions.ts`) now sums `weightKg` over the plane's loaded+expired packages and 409s with `no payload room: the ATR 72-600 carries X/Y kg` when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: `slotsUsedFor` → `cargoWeightFor(state, planeId)` (summed kg) in `components/RightPane.tsx`, and `loadEligiblePlanes` compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
|
||||||
|
- **Payout now includes a per-kg part (ISS-045).** New `rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg)` with `REWARD_PER_KG = 0.5` (`lib/game/seed.ts`) — replaces the distance-only `rewardForDistanceKm`, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
|
||||||
|
- **Loadout display shows tonnage (ISS-045).** Left-pane fleet rows now render `X.Xt / Y.Yt` (used/max payload, one decimal) instead of `n/6 pkgs`; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains `PackageState.weightKg` and swaps `CatalogEntry.capacity` → `maxPayloadKg`, so the e2e path list stays at twelve
|
||||||
|
- **Test hook gains a forced weight (ISS-045).** `generatePackage` accepts an optional `weightKg` that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and `POST /api/test/create-package` passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
|
||||||
|
- Tests: unit — `lib/game/seed.test.ts` (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), `lib/game/actions.test.ts` (loadPackage fill loop now inserts weights summing past maxPayloadKg → `/no payload room/`; expired-still-occupies reworked weight-based), `components/right-pane.test.ts` (`cargoWeightFor` sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), `lib/openapi.test.ts` (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in `deriveEvents`/`mapData` tests and the e2e interfaces. E2E — `e2e/right-pane.spec.ts` asserts `maxPayloadKg === 4800`; `e2e/panes.spec.ts` loadout regex → `/^\d+\.\dt \/ \d+\.\dt$/`; snapshot key lists in `app/api/state/route.test.ts` + `e2e/state.spec.ts` gain `weightKg`; the two multi-load tests in `e2e/actions.spec.ts` pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (311, was 308); `npx playwright test --list` = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-09-20 11:28
|
||||||
|
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
|
||||||
|
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
|
||||||
|
- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport `<select>` (`queue-add-dest`) + "Add to queue" button (`queue-add` → `POST …/queue {destIcao}`) reusing the pane's already-fetched `airportOptions` (no second `/api/airports` fetch); buttons disable on `actionsBusy`, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`) unchanged
|
||||||
|
- Tests: unit — `lib/mapData.test.ts` gains a `queuedRoutesGeoJson` block mirroring the `routesGeoJson` tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length `n_legs × SEGMENTS + 1`; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new `e2e/queue.spec.ts` (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via `queue-add-dest`/`queue-add`, assert `queue-leg-0` appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered `queue-routes` segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries `planeId: plane-starter`; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (`status 'flying'` to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (308, was 303); `npx playwright test --list` = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.25.0 — 2026-09-20 10:36
|
||||||
|
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
|
||||||
|
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
|
||||||
|
- **New endpoints (ISS-043).** `POST /api/planes/{planeId}/queue` body `{destIcao}` appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); `POST /api/planes/{planeId}/queue/cancel` body `{index}` removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through `performAction()` like every other action and return the full state snapshot. New actions `queueLeg`/`cancelQueuedLeg` in `lib/game/actions.ts`; routes under `app/api/planes/[planeId]/queue/`
|
||||||
|
- **Snapshot + spec (ISS-043).** `PlaneState.queue: string[]` added to the `/api/state` snapshot (`lib/game/state.ts`, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — `PlaneState.queue`, `QueueLegRequest`, `CancelQueuedLegRequest`, both new paths (200→snapshot, 400, 404)
|
||||||
|
- Tests: unit — `lib/sim/engine.test.ts`: auto-chain block in `deliverPackages` (lands with queue + enough fuel → `'flying'` to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. `lib/game/actions.test.ts`: `queueLeg` (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and `cancelQueuedLeg` (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). `lib/openapi.test.ts`: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: `queue: []` added to the `PlaneState`/seed fixtures in `deriveEvents`, `right-pane`, `mapData`, `seed` tests
|
||||||
|
- Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: `tsc --noEmit` clean; unit suite green locally; `npx playwright test --list` unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
## v0.24.0 — 2026-09-20 09:48
|
## v0.24.0 — 2026-09-20 09:48
|
||||||
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
|
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
|
||||||
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
|
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { loadPackage, performAction } from '@/lib/game/actions';
|
import { loadPackage, performAction } from '@/lib/game/actions';
|
||||||
|
|
||||||
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
|
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
|
||||||
// package onto a GROUNDED plane at the package's origin with a free slot. 400
|
// package onto a GROUNDED plane at the package's origin with enough payload room
|
||||||
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409
|
// for its weight (v0.27.0). 400 missing/non-string planeId or unknown fields; 404
|
||||||
// wrong package status, plane flying, plane not at the origin, no free slots. On
|
// unknown package/plane id; 409 wrong package status, plane flying, plane not at
|
||||||
// success returns the full state snapshot (package now "loaded" on that plane).
|
// the origin, no payload room. On success returns the full state snapshot
|
||||||
|
// (package now "loaded" on that plane).
|
||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cancelQueuedLeg, performAction } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/queue/cancel (v0.25.0, ISS-043) — body { index: number }.
|
||||||
|
// Removes a NOT-yet-started leg from the plane's queue by index; only queued legs
|
||||||
|
// are cancellable, the active in-flight leg is never affected. 400 missing/
|
||||||
|
// non-integer index, unknown fields, or an index outside [0, queue.length); 404
|
||||||
|
// unknown plane id. On success returns the full state snapshot (plane.queue updated).
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
|
const { planeId } = await ctx.params;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'index') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(input.index)) {
|
||||||
|
return Response.json({ error: 'index must be an integer' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = input.index as number;
|
||||||
|
return performAction((inst) => cancelQueuedLeg(inst, planeId, index));
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { performAction, queueLeg } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/queue (v0.25.0, ISS-043) — body { destIcao: string }.
|
||||||
|
// Appends a NOT-yet-started leg to the plane's flight queue; works whether the
|
||||||
|
// plane is grounded or flying (pre-building a route). Nothing auto-starts here —
|
||||||
|
// queued legs chain on landing when fuel covers them. 400 missing/non-string
|
||||||
|
// destIcao, unknown fields, or an ICAO not in the airport dataset; 404 unknown
|
||||||
|
// plane id. On success returns the full state snapshot (plane.queue updated).
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
|
const { planeId } = await ctx.params;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'destIcao') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof input.destIcao !== 'string' || input.destIcao.length === 0) {
|
||||||
|
return Response.json({ error: 'destIcao must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const destIcao = input.destIcao;
|
||||||
|
return performAction((inst) => queueLeg(inst, planeId, destIcao));
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const packages = body.packages as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(packages.length).toBeGreaterThan(0);
|
expect(packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
||||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -86,7 +86,7 @@ describe('GET /api/state', () => {
|
|||||||
expect(entry.maxFuel).toBe(type.maxFuel);
|
expect(entry.maxFuel).toBe(type.maxFuel);
|
||||||
expect(entry.burnPerKm).toBe(type.burnPerKm);
|
expect(entry.burnPerKm).toBe(type.burnPerKm);
|
||||||
expect(entry.speedKmh).toBe(type.speedKmh);
|
expect(entry.speedKmh).toBe(type.speedKmh);
|
||||||
expect(entry.capacity).toBe(type.capacity); // v0.12.0 slot capacity (ATR 72-600: 6)
|
expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import { tickNow } from '@/lib/sim/engine';
|
|||||||
// same gate, same conventions.
|
// same gate, same conventions.
|
||||||
//
|
//
|
||||||
// POST /api/test/create-package
|
// POST /api/test/create-package
|
||||||
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number }
|
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
|
||||||
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
|
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
|
||||||
// omitted endpoints are picked at random by generatePackage.
|
// omitted endpoints are picked at random by generatePackage.
|
||||||
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
|
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
|
||||||
// hook deadline of expire-package) — pass an explicit value to override.
|
// hook deadline of expire-package) — pass an explicit value to override.
|
||||||
|
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
|
||||||
|
// flows that load several packages can stay under the plane's payload deterministically.
|
||||||
// Returns 201 + the created package row.
|
// Returns 201 + the created package row.
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -38,7 +40,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const input = body as Record<string, unknown>;
|
const input = body as Record<string, unknown>;
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') {
|
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,9 @@ export async function POST(req: Request) {
|
|||||||
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
|
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
|
||||||
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
|
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
|
||||||
|
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
@@ -79,6 +84,7 @@ export async function POST(req: Request) {
|
|||||||
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
|
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
|
||||||
originIcao: input.originIcao as string | undefined,
|
originIcao: input.originIcao as string | undefined,
|
||||||
destIcao: input.destIcao as string | undefined,
|
destIcao: input.destIcao as string | undefined,
|
||||||
|
weightKg: input.weightKg as number | undefined,
|
||||||
});
|
});
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -416,6 +416,30 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ISS-043 (v0.26.0) — flight-queue sub-section inside on-board blocks */
|
||||||
|
.queue-subsection {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
|
/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */
|
||||||
.toasts {
|
.toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+62
-8
@@ -9,7 +9,7 @@ import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '
|
|||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
import { slotsUsedFor } from './RightPane';
|
import { cargoWeightFor } from './RightPane';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
interface LeftPaneProps {
|
interface LeftPaneProps {
|
||||||
@@ -39,13 +39,12 @@ function planeLocation(p: PlaneState): string {
|
|||||||
return p.locationName;
|
return p.locationName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot.
|
// Cargo loadout "X.Xt / Y.Yt" (v0.27.0) — used/max payload in tonnes, one decimal;
|
||||||
|
// expired packages still occupy their payload.
|
||||||
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
||||||
const used = state.packages.filter(
|
const used = cargoWeightFor(state, p.id);
|
||||||
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
|
||||||
).length;
|
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
||||||
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
|
|
||||||
return `${used}/${capacity} pkgs`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
||||||
@@ -107,6 +106,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// a sensible default (first grounded plane with fuel below max).
|
// a sensible default (first grounded plane with fuel below max).
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
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
|
// 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;
|
// 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
|
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||||
@@ -200,7 +203,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
|
// 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.
|
// abandon controls, and flying planes show their current leg.
|
||||||
const clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 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
|
// 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
|
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
|
||||||
@@ -455,6 +462,53 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
+31
-1
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
|
|||||||
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
||||||
import type { StateSnapshot } from '../lib/game/state';
|
import type { StateSnapshot } from '../lib/game/state';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
|
|||||||
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
||||||
const ROUTES_SOURCE_ID = 'routes';
|
const ROUTES_SOURCE_ID = 'routes';
|
||||||
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — a flying plane's NOT-yet-started legs
|
||||||
|
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
||||||
|
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
||||||
|
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
||||||
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
||||||
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
||||||
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
||||||
@@ -95,6 +99,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
if (routesSource && airportsIndexRef.current) {
|
if (routesSource && airportsIndexRef.current) {
|
||||||
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
void routesSource.setData(routesGeoJson(st, airportsIndexRef.current));
|
||||||
}
|
}
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — same airport-index guard as the solid legs above.
|
||||||
|
const queueRoutesSource = map.getSource(QUEUE_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (queueRoutesSource && airportsIndexRef.current) {
|
||||||
|
void queueRoutesSource.setData(queuedRoutesGeoJson(st.planes, airportsIndexRef.current));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -357,6 +366,27 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
||||||
|
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
|
||||||
|
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
|
||||||
|
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
||||||
|
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: queuedRoutesGeoJson(stateRef.current?.planes ?? [], airportsIndexRef.current ?? new Map()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer(QUEUE_ROUTES_LAYER_ID)) {
|
||||||
|
map.addLayer({
|
||||||
|
id: QUEUE_ROUTES_LAYER_ID,
|
||||||
|
type: 'line',
|
||||||
|
source: QUEUE_ROUTES_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
'line-color': '#64748b', // muted slate — planned legs, not actively flying
|
||||||
|
'line-width': 3,
|
||||||
|
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!map.getSource(PLANES_SOURCE_ID)) {
|
if (!map.getSource(PLANES_SOURCE_ID)) {
|
||||||
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) });
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -23,23 +23,25 @@ interface RightPaneProps {
|
|||||||
|
|
||||||
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
|
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
|
||||||
|
|
||||||
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which
|
// Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
|
||||||
// keeps its slot until it is abandoned or the plane lands at its destination.
|
// cargo, which keeps its space until it is abandoned or the plane lands at its
|
||||||
export function slotsUsedFor(state: StateSnapshot, planeId: string): number {
|
// destination.
|
||||||
return state.packages.filter(
|
export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
|
||||||
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'),
|
return state.packages
|
||||||
).length;
|
.filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'))
|
||||||
|
.reduce((sum, p) => sum + p.weightKg, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a
|
// Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
|
||||||
// free slot (capacity from the snapshot catalog). The Load button is enabled iff
|
// payload room for it (maxPayloadKg from the snapshot catalog). The Load button is
|
||||||
// this list is non-empty; exactly one entry loads directly, several show a picker.
|
// enabled iff this list is non-empty; exactly one entry loads directly, several show a picker.
|
||||||
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
|
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
|
||||||
return state.planes.filter((p) => {
|
return state.planes.filter((p) => {
|
||||||
if (p.status !== 'available') return false;
|
if (p.status !== 'available') return false;
|
||||||
if (p.locationIcao !== pkg.originIcao) return false;
|
if (p.locationIcao !== pkg.originIcao) return false;
|
||||||
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0;
|
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
|
||||||
return slotsUsedFor(state, p.id) < capacity;
|
// Mirrors the server check in lib/game/actions.ts loadPackage.
|
||||||
|
return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
|
import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
|
||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
|
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
|
||||||
// it is GROUNDED at the package's origin with a free slot (capacity from the
|
// package only while it is GROUNDED at the package's origin with enough payload room for
|
||||||
// snapshot catalog; expired packages still occupy theirs). The Load button acts
|
// it (maxPayloadKg from the snapshot catalog; expired packages still occupy theirs). The
|
||||||
// directly on the single eligible plane, or shows a picker when several qualify.
|
// Load button acts directly on the single eligible plane, or shows a picker when several qualify.
|
||||||
|
|
||||||
const CATALOG = [
|
const CATALOG = [
|
||||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||||
@@ -24,6 +24,7 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50,
|
lat: 50,
|
||||||
lon: 8.5,
|
lon: 8.5,
|
||||||
|
queue: [],
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
reward: 1000,
|
reward: 1000,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
|
weightKg: 1_000,
|
||||||
deadlineSimTime: 1_000_000,
|
deadlineSimTime: 1_000_000,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
@@ -57,27 +59,27 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('slotsUsedFor', () => {
|
describe('cargoWeightFor', () => {
|
||||||
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
|
it('sums the weight of loaded AND expired packages bound to the plane (both occupy payload)', () => {
|
||||||
const s = snap(
|
const s = snap(
|
||||||
[plane({ id: 'p1' })],
|
[plane({ id: 'p1' })],
|
||||||
[
|
[
|
||||||
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
|
pkg({ id: 'a', status: 'loaded', planeId: 'p1', weightKg: 800 }),
|
||||||
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
|
pkg({ id: 'b', status: 'expired', planeId: 'p1', weightKg: 500 }),
|
||||||
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
|
pkg({ id: 'c', status: 'open', planeId: null, weightKg: 9_000 }), // not on any plane
|
||||||
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
|
pkg({ id: 'd', status: 'delivered', planeId: 'p1', weightKg: 700 }), // terminal — payload long freed
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
expect(slotsUsedFor(s, 'p1')).toBe(2);
|
expect(cargoWeightFor(s, 'p1')).toBe(1_300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is 0 for a plane with no bound cargo', () => {
|
it('is 0 for a plane with no bound cargo', () => {
|
||||||
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
expect(cargoWeightFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
||||||
it('includes only grounded planes at the package origin with a free slot', () => {
|
it('includes only grounded planes at the package origin with enough payload room', () => {
|
||||||
const s = snap([
|
const s = snap([
|
||||||
plane({ id: 'at-origin' }), // eligible
|
plane({ id: 'at-origin' }), // eligible
|
||||||
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
||||||
@@ -86,18 +88,23 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
|
|||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
|
it('a plane with partial payload can still load a package that fits', () => {
|
||||||
const packages = Array.from({ length: 6 }, (_, i) =>
|
const s = snap([plane({ id: 'partial' })], [pkg({ id: 'onboard', status: 'loaded', planeId: 'partial', weightKg: 3_000 })]);
|
||||||
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['partial']); // 3000 + 1000 ≤ 4800
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes a plane whose payload is full (maxPayloadKg from the catalog)', () => {
|
||||||
|
const packages = Array.from({ length: 4 }, (_, i) =>
|
||||||
|
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 }), // 4 × 1200 = 4800
|
||||||
);
|
);
|
||||||
const s = snap([plane({ id: 'full' })], packages);
|
const s = snap([plane({ id: 'full' })], packages);
|
||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a full plane with one slot freed by an expired package still counts as full', () => {
|
it('a plane at its limit with an expired package still counts as full', () => {
|
||||||
const packages = [
|
const packages = [
|
||||||
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
|
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 960 })),
|
||||||
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
|
pkg({ id: 'exp', status: 'expired', planeId: 'full', weightKg: 960 }), // occupies its payload — 5 × 960 = 4800
|
||||||
];
|
];
|
||||||
const s = snap([plane({ id: 'full' })], packages);
|
const s = snap([plane({ id: 'full' })], packages);
|
||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
@@ -113,7 +120,7 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
|
|||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a plane with an unknown model has no capacity → never eligible', () => {
|
it('a plane with an unknown model has no maxPayloadKg in the catalog → never eligible', () => {
|
||||||
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
||||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-7
@@ -53,8 +53,16 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
||||||
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
|
// weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
|
||||||
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
|
// several packages onto one plane stay under its payload deterministically.
|
||||||
|
async function createPackage(
|
||||||
|
page: Page,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
deadlineOffsetMs?: number,
|
||||||
|
weightKg?: number,
|
||||||
|
): Promise<StateBody['packages'][number]> {
|
||||||
|
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } });
|
||||||
expect(res.status()).toBe(201);
|
expect(res.status()).toBe(201);
|
||||||
return (await res.json()) as StateBody['packages'][number];
|
return (await res.json()) as StateBody['packages'][number];
|
||||||
}
|
}
|
||||||
@@ -240,9 +248,10 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
|
|||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
|
|
||||||
// Two packages at the hub — load both, abandon one through the UI.
|
// Two packages at the hub — load both, abandon one through the UI. Forced weights keep
|
||||||
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
// the pair under the starter's payload (v0.27.0) so both loads are deterministic.
|
||||||
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
|
||||||
|
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
|
||||||
for (const p of [pkgA, pkgB]) {
|
for (const p of [pkgA, pkgB]) {
|
||||||
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
@@ -282,8 +291,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
|
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
|
||||||
// still be abandoned (an expired package whose dest matches the arrival airport is
|
// still be abandoned (an expired package whose dest matches the arrival airport is
|
||||||
// unbound by the tick instead).
|
// unbound by the tick instead).
|
||||||
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
|
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0)
|
||||||
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
|
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000);
|
||||||
for (const p of [pkgA, pkgB]) {
|
for (const p of [pkgA, pkgB]) {
|
||||||
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
|||||||
expect(body.version).toBe(pkg.version);
|
expect(body.version).toBe(pkg.version);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /api/openapi serves the spec with all ten endpoints and the package.json version', async ({ page }) => {
|
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/openapi');
|
const res = await page.request.get('/api/openapi');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
expect(res.headers()['content-type']).toContain('application/json');
|
expect(res.headers()['content-type']).toContain('application/json');
|
||||||
@@ -28,6 +28,8 @@ test('GET /api/openapi serves the spec with all ten endpoints and the package.js
|
|||||||
'/api/packages/{id}/abandon',
|
'/api/packages/{id}/abandon',
|
||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
|
'/api/planes/{planeId}/queue',
|
||||||
|
'/api/planes/{planeId}/queue/cancel',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
|
|||||||
+2
-2
@@ -62,10 +62,10 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(row).toContainText(truth.locationName);
|
await expect(row).toContainText(truth.locationName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
|
// v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
|
||||||
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
||||||
await expect(loadout).toBeVisible();
|
await expect(loadout).toBeVisible();
|
||||||
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/);
|
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
|
||||||
|
|
||||||
// Sim clock renders (UTC wall-clock of the game time).
|
// Sim clock renders (UTC wall-clock of the game time).
|
||||||
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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 DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||||
|
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
status: 'available' | 'flying';
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
queue: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageState {
|
||||||
|
id: string;
|
||||||
|
originIcao: string;
|
||||||
|
destIcao: string;
|
||||||
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
|
planeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
packages: PackageState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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. ISS-041 also clears pause: a
|
||||||
|
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
|
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||||||
|
});
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
return (await created.json()) as PackageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||||
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (plane.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
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.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||||
|
const s1 = await getState(page);
|
||||||
|
for (const pkg of s1.packages) {
|
||||||
|
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAirportCount(page: Page): Promise<void> {
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered features of the given layers in the current viewport, [] until the map exists.
|
||||||
|
function renderedFeatures(
|
||||||
|
page: Page,
|
||||||
|
layers: string[],
|
||||||
|
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
|
||||||
|
return page.evaluate((ls: string[]) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) return [];
|
||||||
|
try {
|
||||||
|
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
|
||||||
|
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
|
||||||
|
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
|
||||||
|
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
properties?: unknown;
|
||||||
|
geometry?: unknown;
|
||||||
|
}>).map((f) => ({
|
||||||
|
type: f.type,
|
||||||
|
id: f.id,
|
||||||
|
properties: f.properties,
|
||||||
|
geometry: f.geometry,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, layers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
|
||||||
|
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
|
||||||
|
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const points = icaos.map((icao) => {
|
||||||
|
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||||
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
|
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||||
|
});
|
||||||
|
await page.evaluate((pts: [number, number][]) => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m) throw new Error('map not ready');
|
||||||
|
const lons = pts.map((p) => p[0]);
|
||||||
|
const lats = pts.map((p) => p[1]);
|
||||||
|
// duration 0 → instant camera jump, no animation to wait out.
|
||||||
|
m.fitBounds(
|
||||||
|
[
|
||||||
|
[Math.min(...lons), Math.min(...lats)],
|
||||||
|
[Math.max(...lons), Math.max(...lats)],
|
||||||
|
],
|
||||||
|
{ padding: 40, duration: 0 },
|
||||||
|
);
|
||||||
|
}, points);
|
||||||
|
// Give the renderer a beat after the camera jump before asserting on rendered features.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
|
||||||
|
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||||
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
|
return [f.properties.lon, f.properties.lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
|
||||||
|
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
|
||||||
|
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
|
||||||
|
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
|
||||||
|
// any two EU airports.
|
||||||
|
const QUEUE_ROUTE_EPS_DEG = 0.05;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
|
||||||
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
|
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
|
||||||
|
await loadBtn.click();
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// START leg 1 via the UI (fly-to button in the on-board block).
|
||||||
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
await expect(flyBtn).toBeEnabled();
|
||||||
|
await flyBtn.click();
|
||||||
|
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
|
||||||
|
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||||||
|
}, { 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();
|
||||||
|
|
||||||
|
// …and the new leg appears as queue-leg-0 with its destination.
|
||||||
|
const legRow = page.getByTestId('queue-leg-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 });
|
||||||
|
|
||||||
|
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(home.status()).toBe(200);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
}).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
||||||
|
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(load.status()).toBe(200);
|
||||||
|
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(queueRes.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||||||
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
|
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
||||||
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||||||
|
await fitToAirports(page, [DEST, QUEUE]);
|
||||||
|
|
||||||
|
const destLL = await airportLonLat(page, DEST);
|
||||||
|
const queueLL = await airportLonLat(page, QUEUE);
|
||||||
|
const hubLL = await airportLonLat(page, HUB);
|
||||||
|
const near = (a: [number, number], b: [number, number]) =>
|
||||||
|
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
geometry?: { type: string; coordinates: [number, number][] };
|
||||||
|
}>;
|
||||||
|
// Filter to real LineString segments — keeps this poll callback total (a throw inside
|
||||||
|
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
|
||||||
|
const segs = feats.filter(
|
||||||
|
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||||
|
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||||
|
);
|
||||||
|
if (!segs.length) return false;
|
||||||
|
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
|
||||||
|
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
|
||||||
|
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
|
||||||
|
// …and some ENDS within EPS of the queued airport.
|
||||||
|
const endsAtQueue = segs.some((f) => {
|
||||||
|
const c = f.geometry.coordinates;
|
||||||
|
return near(c[c.length - 1], queueLL);
|
||||||
|
});
|
||||||
|
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
|
||||||
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||||||
|
// Every rendered feature belongs to plane-starter (the only queued plane).
|
||||||
|
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||||||
|
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
|
||||||
|
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
|
||||||
|
// to arrival, then fly home.
|
||||||
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancelRes.status()).toBe(200);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(home.status()).toBe(200);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
}).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(load.status()).toBe(200);
|
||||||
|
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
|
||||||
|
expect(queueRes.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||||||
|
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
||||||
|
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
|
||||||
|
|
||||||
|
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
|
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ interface CatalogEntry {
|
|||||||
burnPerKm: number;
|
burnPerKm: number;
|
||||||
emptyWeightKg: number; // v0.16.0 weighted burn input
|
emptyWeightKg: number; // v0.16.0 weighted burn input
|
||||||
speedKmh: number;
|
speedKmh: number;
|
||||||
capacity: number;
|
maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
@@ -309,7 +309,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||||||
expect(body.catalog.length).toBeGreaterThan(0);
|
expect(body.catalog.length).toBeGreaterThan(0);
|
||||||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||||||
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
|
expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts)
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
|
|
||||||
expect(Array.isArray(body.packages)).toBe(true);
|
expect(Array.isArray(body.packages)).toBe(true);
|
||||||
expect(body.packages.length).toBeGreaterThan(0);
|
expect(body.packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
||||||
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
|
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-2
@@ -30,7 +30,8 @@ CREATE TABLE IF NOT EXISTS planes (
|
|||||||
dest_icao TEXT,
|
dest_icao TEXT,
|
||||||
lat REAL NOT NULL,
|
lat REAL NOT NULL,
|
||||||
lon REAL NOT NULL,
|
lon REAL NOT NULL,
|
||||||
departure_sim_time INTEGER
|
departure_sim_time INTEGER,
|
||||||
|
queue TEXT NOT NULL DEFAULT '[]'
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS packages (
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -39,6 +40,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
reward REAL NOT NULL,
|
reward REAL NOT NULL,
|
||||||
cargo_type TEXT NOT NULL,
|
cargo_type TEXT NOT NULL,
|
||||||
cargo_count INTEGER NOT NULL,
|
cargo_count INTEGER NOT NULL,
|
||||||
|
weight_kg REAL NOT NULL DEFAULT 2000,
|
||||||
deadline_sim_time INTEGER NOT NULL,
|
deadline_sim_time INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
plane_id TEXT,
|
plane_id TEXT,
|
||||||
@@ -72,6 +74,14 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('planes').has('dest_icao')) {
|
if (!cols('planes').has('dest_icao')) {
|
||||||
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
|
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
|
||||||
}
|
}
|
||||||
|
// v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs.
|
||||||
|
if (!cols('planes').has('queue')) {
|
||||||
|
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
|
||||||
|
}
|
||||||
|
// v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default.
|
||||||
|
if (!cols('packages').has('weight_kg')) {
|
||||||
|
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||||
@@ -159,7 +169,9 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
|||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
const data = buildSeed({ airports, rng, nowMs: Date.now() });
|
const data = buildSeed({ airports, rng, nowMs: Date.now() });
|
||||||
|
|
||||||
await instance.db.insert(schema.planes).values(data.plane);
|
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||||
|
const { queue, ...planeCols } = data.plane;
|
||||||
|
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
|
||||||
await instance.db.insert(schema.packages).values(data.packages);
|
await instance.db.insert(schema.packages).values(data.packages);
|
||||||
await instance.db.insert(schema.account).values(data.account);
|
await instance.db.insert(schema.account).values(data.account);
|
||||||
await instance.db.insert(schema.simState).values(data.simState);
|
await instance.db.insert(schema.simState).values(data.simState);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export const planes = sqliteTable('planes', {
|
|||||||
// flying, `fuel` is frozen at its departure value — live position/fuel are pure
|
// flying, `fuel` is frozen at its departure value — live position/fuel are pure
|
||||||
// functions of the clock (lib/sim/movement.ts), recomputed on read.
|
// functions of the clock (lib/sim/movement.ts), recomputed on read.
|
||||||
departureSimTime: integer('departure_sim_time'),
|
departureSimTime: integer('departure_sim_time'),
|
||||||
|
// Queue of upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered JSON array
|
||||||
|
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||||
|
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||||
|
queue: text('queue').notNull().default('[]'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||||
@@ -33,6 +37,9 @@ export const packages = sqliteTable('packages', {
|
|||||||
reward: real('reward').notNull(),
|
reward: real('reward').notNull(),
|
||||||
cargoType: text('cargo_type').notNull(),
|
cargoType: text('cargo_type').notNull(),
|
||||||
cargoCount: integer('cargo_count').notNull(),
|
cargoCount: integer('cargo_count').notNull(),
|
||||||
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
|
||||||
|
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
|
||||||
|
weightKg: real('weight_kg').notNull().default(2000),
|
||||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { diffSnapshots } from './deriveEvents';
|
|||||||
// feature, unit-tested in node like advanceSimClock/fetchState.
|
// feature, unit-tested in node like advanceSimClock/fetchState.
|
||||||
|
|
||||||
const CATALOG = [
|
const CATALOG = [
|
||||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, capacity: 6 },
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, maxPayloadKg: 4800 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const basePlane = (id: string): PlaneState => ({
|
const basePlane = (id: string): PlaneState => ({
|
||||||
@@ -22,6 +22,7 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50.03,
|
lat: 50.03,
|
||||||
lon: 8.56,
|
lon: 8.56,
|
||||||
|
queue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePackage = (id: string): PackageState => ({
|
const basePackage = (id: string): PackageState => ({
|
||||||
@@ -33,6 +34,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
reward: 1234.5,
|
reward: 1234.5,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
|
weightKg: 600,
|
||||||
deadlineSimTime: 0,
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
+85
-14
@@ -13,9 +13,11 @@ import { HUB_ICAO } from './seed';
|
|||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
|
cancelQueuedLeg,
|
||||||
flyPlane,
|
flyPlane,
|
||||||
GameActionError,
|
GameActionError,
|
||||||
loadPackage,
|
loadPackage,
|
||||||
|
queueLeg,
|
||||||
refuelPlane,
|
refuelPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
@@ -125,27 +127,27 @@ describe('loadPackage', () => {
|
|||||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects loading when the plane has no free slot (capacity from catalog)', async () => {
|
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
||||||
const inst = await freshDb('load-full.db');
|
const inst = await freshDb('load-full.db');
|
||||||
await insertOpenPackage(inst, 'p-new');
|
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
|
||||||
// Fill all atr72 slots with loaded cargo…
|
// Fill past maxPayloadKg with loaded cargo…
|
||||||
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) {
|
const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
|
||||||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
||||||
}
|
}
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts expired packages as occupying their slot too', async () => {
|
it('counts expired packages as occupying their payload too', async () => {
|
||||||
const inst = await freshDb('load-expiredslot.db');
|
const inst = await freshDb('load-expiredslot.db');
|
||||||
await insertOpenPackage(inst, 'p-new');
|
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
|
||||||
// capacity − 1 loaded + 1 expired = full.
|
// Half + half (expired) leaves no room — each alone would fit.
|
||||||
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) {
|
const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
|
||||||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
|
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||||||
}
|
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||||||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
|
|
||||||
|
|
||||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +208,75 @@ describe('flyPlane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||||
|
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||||
|
const inst = await freshDb('qleg-ok.db');
|
||||||
|
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||||
|
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => {
|
||||||
|
const inst = await freshDb('qleg-flying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying'); // still on its active leg…
|
||||||
|
expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
|
const inst = await freshDb('qleg-ghost.db');
|
||||||
|
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
|
const inst = await freshDb('qleg-unknownicao.db');
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
|
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||||
|
const inst = await freshDb('qcancel-ok.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EDDV');
|
||||||
|
|
||||||
|
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an out-of-range index with 400', async () => {
|
||||||
|
const inst = await freshDb('qcancel-oob.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cancelling from an empty queue with 400', async () => {
|
||||||
|
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
|
const inst = await freshDb('qcancel-ghost.db');
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('abandonPackage', () => {
|
describe('abandonPackage', () => {
|
||||||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
||||||
const REWARD = 1_000;
|
const REWARD = 1_000;
|
||||||
|
|||||||
+53
-11
@@ -6,7 +6,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { requiredFuelFor } from '@/lib/sim/movement';
|
||||||
import { runAction } from '@/lib/sim/engine';
|
import { runAction } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from './state';
|
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||||
import { abandonPenalty } from './penalty';
|
import { abandonPenalty } from './penalty';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
@@ -20,7 +20,7 @@ import { HUB_ICAO } from './seed';
|
|||||||
// GameActionError carrying the HTTP status:
|
// GameActionError carrying the HTTP status:
|
||||||
// 404 — unknown entity id
|
// 404 — unknown entity id
|
||||||
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
||||||
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
|
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
|
||||||
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
||||||
|
|
||||||
export class GameActionError extends Error {
|
export class GameActionError extends Error {
|
||||||
@@ -43,8 +43,9 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
|
|||||||
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
||||||
|
|
||||||
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
||||||
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
|
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
|
||||||
// cargo keeps occupying its space until delivered/abandoned).
|
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
||||||
|
// delivered/abandoned).
|
||||||
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
||||||
const [pkgRows, planeRows] = await Promise.all([
|
const [pkgRows, planeRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||||
@@ -69,13 +70,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
|
|
||||||
const type = PLANE_TYPES[plane.model];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
const occupied = (
|
const occupied = await inst.db.select({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
|
||||||
await inst.db.select().from(schema.packages).where(
|
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
||||||
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
);
|
||||||
)
|
const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
|
||||||
).length;
|
if (usedKg + pkg.weightKg > type.maxPayloadKg) {
|
||||||
if (occupied >= type.capacity) {
|
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
|
||||||
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await inst.db.update(schema.packages)
|
await inst.db.update(schema.packages)
|
||||||
@@ -131,6 +131,48 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Queue (POST /api/planes/:id/queue {destIcao}) — ISS-043 part 1 ---
|
||||||
|
|
||||||
|
// Append a NOT-yet-started leg to the plane's queue. Works whether the plane is
|
||||||
|
// grounded or flying (you can pre-build a route); nothing auto-starts here — the
|
||||||
|
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
||||||
|
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||||||
|
// in the airport dataset.
|
||||||
|
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
||||||
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
const plane = planeRows[0];
|
||||||
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||||||
|
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
queue.push(destIcao);
|
||||||
|
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||||
|
await persist(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue cancel (POST /api/planes/:id/queue/cancel {index}) — ISS-043 part 1 ---
|
||||||
|
|
||||||
|
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
||||||
|
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||||
|
// known plane, integer index within [0, queue.length).
|
||||||
|
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
|
||||||
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
const plane = planeRows[0];
|
||||||
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
|
||||||
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (index < 0 || index >= queue.length) {
|
||||||
|
throw new GameActionError(400, `queue index ${index} out of range — the queue holds ${queue.length} legs`);
|
||||||
|
}
|
||||||
|
queue.splice(index, 1);
|
||||||
|
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||||
|
await persist(inst);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Abandon (POST /api/packages/:id/abandon) ---
|
// --- Abandon (POST /api/packages/:id/abandon) ---
|
||||||
|
|
||||||
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
|
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
|
||||||
|
|||||||
+10
-5
@@ -16,8 +16,10 @@ export interface PlaneType {
|
|||||||
// Market price in € (v0.8.0): buying costs `price`, selling pays out
|
// Market price in € (v0.8.0): buying costs `price`, selling pays out
|
||||||
// SELL_FRACTION × price.
|
// SELL_FRACTION × price.
|
||||||
price: number;
|
price: number;
|
||||||
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane.
|
// Max payload in kg (v0.27.0, ISS-045 — replaces the v0.12.0 slot count): total
|
||||||
capacity: number;
|
// package weight that can be loaded on one plane. ATR 72-600-class figure, tuned
|
||||||
|
// so every cargo type at max count × 1.3 jitter still fits alone (see UNIT_WEIGHT_KG).
|
||||||
|
maxPayloadKg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLANE_TYPES: Record<string, PlaneType> = {
|
export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||||
@@ -31,7 +33,9 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
|||||||
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
|
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
|
||||||
// a second ATR takes sustained play to afford, as intended for the MVP sink.
|
// a second ATR takes sustained play to afford, as intended for the MVP sink.
|
||||||
price: 200_000,
|
price: 200_000,
|
||||||
capacity: 6,
|
// ponytail: balance-tunable payload (kg) — every cargo type at max count × 1.3 jitter
|
||||||
|
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
|
||||||
|
maxPayloadKg: 4_800,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ export interface CatalogEntry {
|
|||||||
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
|
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
|
||||||
emptyWeightKg: number;
|
emptyWeightKg: number;
|
||||||
speedKmh: number;
|
speedKmh: number;
|
||||||
capacity: number;
|
// Max payload in kg (v0.27.0) — total package weight that can be loaded on one plane.
|
||||||
|
maxPayloadKg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The catalog as a flat array, one entry per plane type — the single source of
|
// The catalog as a flat array, one entry per plane type — the single source of
|
||||||
@@ -71,6 +76,6 @@ export function catalogEntries(): CatalogEntry[] {
|
|||||||
burnPerKm: t.burnPerKm,
|
burnPerKm: t.burnPerKm,
|
||||||
emptyWeightKg: t.emptyWeightKg,
|
emptyWeightKg: t.emptyWeightKg,
|
||||||
speedKmh: t.speedKmh,
|
speedKmh: t.speedKmh,
|
||||||
capacity: t.capacity,
|
maxPayloadKg: t.maxPayloadKg,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-9
@@ -9,8 +9,9 @@ import {
|
|||||||
generatePackage,
|
generatePackage,
|
||||||
HUB_ICAO,
|
HUB_ICAO,
|
||||||
mulberry32,
|
mulberry32,
|
||||||
|
REWARD_PER_KG,
|
||||||
REWARD_PER_KM,
|
REWARD_PER_KM,
|
||||||
rewardForDistanceKm,
|
rewardFor,
|
||||||
STARTING_CASH,
|
STARTING_CASH,
|
||||||
STARTING_REPUTATION,
|
STARTING_REPUTATION,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
@@ -44,6 +45,7 @@ describe('buildSeed', () => {
|
|||||||
destIcao: null,
|
destIcao: null,
|
||||||
lat: 50.0333,
|
lat: 50.0333,
|
||||||
lon: 8.5706,
|
lon: 8.5706,
|
||||||
|
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,12 +71,26 @@ describe('buildSeed', () => {
|
|||||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
|
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
expect(c.reward).toBe(
|
||||||
|
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
|
||||||
|
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
|
||||||
|
for (const seed of [seedWith(42), seedWith(7)]) {
|
||||||
|
for (const c of seed.packages) {
|
||||||
|
expect(Number.isInteger(c.weightKg)).toBe(true);
|
||||||
|
expect(c.weightKg).toBeGreaterThan(0);
|
||||||
|
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
|
||||||
|
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,9 +121,9 @@ describe('buildSeed', () => {
|
|||||||
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rewardForDistanceKm applies the documented formula', () => {
|
it('rewardFor applies the documented formula', () => {
|
||||||
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
|
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
||||||
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
|
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +142,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
|
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
expect(c.reward).toBe(
|
||||||
|
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
||||||
|
);
|
||||||
|
|
||||||
// Same 6–72h game-time deadline window as the seed.
|
// Same 6–72h game-time deadline window as the seed.
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||||||
@@ -172,7 +190,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
|
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
expect(c.reward).toBe(
|
||||||
|
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destIcao forces the destination; origin stays random but distinct', () => {
|
it('destIcao forces the destination; origin stays random but distinct', () => {
|
||||||
@@ -188,7 +208,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
|
|
||||||
const o = AIRPORTS[0]; // EDDF
|
const o = AIRPORTS[0]; // EDDF
|
||||||
const d = AIRPORTS[1]; // EHAM
|
const d = AIRPORTS[1]; // EHAM
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
expect(c.reward).toBe(
|
||||||
|
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||||||
@@ -211,4 +233,22 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('forced weight (v0.27.0 e2e hook)', () => {
|
||||||
|
it('weightKg forces the package weight and feeds the payout', () => {
|
||||||
|
const c = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
||||||
|
expect(c.weightKg).toBe(1_234);
|
||||||
|
|
||||||
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
|
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * 1_234));
|
||||||
|
|
||||||
|
// Deterministic for a fixed RNG…
|
||||||
|
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
||||||
|
expect(again).toEqual(c);
|
||||||
|
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
|
||||||
|
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
|
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-9
@@ -38,6 +38,9 @@ export interface SeedPlane {
|
|||||||
destIcao: null;
|
destIcao: null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
|
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||||||
|
// column as JSON text on insert; defaults to an empty queue.
|
||||||
|
queue?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedPackage {
|
export interface SeedPackage {
|
||||||
@@ -47,6 +50,8 @@ export interface SeedPackage {
|
|||||||
reward: number;
|
reward: number;
|
||||||
cargoType: string;
|
cargoType: string;
|
||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
|
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
||||||
|
weightKg: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open';
|
status: 'open';
|
||||||
planeId: null;
|
planeId: null;
|
||||||
@@ -77,10 +82,12 @@ export const PACKAGE_COUNT_MAX = 12; // inclusive
|
|||||||
// count after expiries, so the job board never runs dry.
|
// count after expiries, so the job board never runs dry.
|
||||||
export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
||||||
|
|
||||||
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
|
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
||||||
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
|
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
||||||
|
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
||||||
export const BASE_REWARD = 500;
|
export const BASE_REWARD = 500;
|
||||||
export const REWARD_PER_KM = 2.5;
|
export const REWARD_PER_KM = 2.5;
|
||||||
|
export const REWARD_PER_KG = 0.5;
|
||||||
|
|
||||||
// Package deadlines: now + a plausible game-time window of 6–72 hours.
|
// Package deadlines: now + a plausible game-time window of 6–72 hours.
|
||||||
const DEADLINE_MIN_HOURS = 6;
|
const DEADLINE_MIN_HOURS = 6;
|
||||||
@@ -88,19 +95,33 @@ const DEADLINE_MAX_HOURS = 72;
|
|||||||
|
|
||||||
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
||||||
|
|
||||||
export function rewardForDistanceKm(km: number): number {
|
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
|
||||||
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
|
// type at MAX count × max jitter (1.3×) still fits the ATR 72-600's payload alone, and
|
||||||
|
// typical packages land ~400–2500 kg — a normal flight carries roughly 3–6 by weight.
|
||||||
|
export const UNIT_WEIGHT_KG: Record<string, number> = {
|
||||||
|
Passengers: 50, // max 50 × 68 × 1.3 = 4420 < 4800
|
||||||
|
Mail: 60, // max 60 × 50 × 1.3 = 3900
|
||||||
|
Perishables: 70, // max 70 × 50 × 1.3 = 4550
|
||||||
|
Electronics: 40, // max 40 × 50 × 1.3 = 2600
|
||||||
|
'Auto parts': 72, // max 72 × 50 × 1.3 = 4680
|
||||||
|
};
|
||||||
|
|
||||||
|
export function rewardFor(km: number, weightKg: number): number {
|
||||||
|
return Math.round(BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared package generator, used by BOTH the first-run seed and the runtime
|
// Shared package generator, used by BOTH the first-run seed and the runtime
|
||||||
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
|
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
|
||||||
// distinct airports from the dataset, distance-based reward, cargo flavor,
|
// distinct airports from the dataset, distance+weight-based reward, cargo flavor,
|
||||||
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
|
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
|
||||||
// the e2e test hook to force a near-term expiry). The RNG call order per package
|
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
|
||||||
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
|
// force a near-term expiry). The RNG call order per package is fixed: origin,
|
||||||
|
// dest, cargoType, cargoCount, weight jitter, [deadline window].
|
||||||
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
||||||
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
||||||
// callers that omit both get byte-identical behavior to before.
|
// callers that omit both get byte-identical behavior to before.
|
||||||
|
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
||||||
|
// jitter draw, same pattern as forced endpoints.
|
||||||
export function generatePackage(input: {
|
export function generatePackage(input: {
|
||||||
id: string;
|
id: string;
|
||||||
airports: SeedAirport[];
|
airports: SeedAirport[];
|
||||||
@@ -109,6 +130,7 @@ export function generatePackage(input: {
|
|||||||
deadlineOffsetMs?: number;
|
deadlineOffsetMs?: number;
|
||||||
originIcao?: string;
|
originIcao?: string;
|
||||||
destIcao?: string;
|
destIcao?: string;
|
||||||
|
weightKg?: number;
|
||||||
}): SeedPackage {
|
}): SeedPackage {
|
||||||
const { airports, rng, nowMs } = input;
|
const { airports, rng, nowMs } = input;
|
||||||
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
||||||
@@ -140,6 +162,9 @@ export function generatePackage(input: {
|
|||||||
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
|
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
|
||||||
const cargoCount =
|
const cargoCount =
|
||||||
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
|
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
|
||||||
|
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
||||||
|
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
|
||||||
|
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
||||||
const deadlineMs =
|
const deadlineMs =
|
||||||
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
||||||
|
|
||||||
@@ -147,9 +172,10 @@ export function generatePackage(input: {
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
originIcao: origin.icao,
|
originIcao: origin.icao,
|
||||||
destIcao: dest.icao,
|
destIcao: dest.icao,
|
||||||
reward: rewardForDistanceKm(km),
|
reward: rewardFor(km, weightKg),
|
||||||
cargoType,
|
cargoType,
|
||||||
cargoCount,
|
cargoCount,
|
||||||
|
weightKg,
|
||||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
@@ -182,6 +208,7 @@ export function buildSeed(input: {
|
|||||||
destIcao: null,
|
destIcao: null,
|
||||||
lat: hub.lat,
|
lat: hub.lat,
|
||||||
lon: hub.lon,
|
lon: hub.lon,
|
||||||
|
queue: [], // no queued legs on first run (v0.25.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ export interface PlaneState {
|
|||||||
departureSimTime: number | null;
|
departureSimTime: number | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
|
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
||||||
|
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||||
|
queue: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||||
|
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
|
||||||
|
export function parseIcaoQueue(raw: string | null): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(raw);
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageState {
|
export interface PackageState {
|
||||||
@@ -35,6 +50,8 @@ export interface PackageState {
|
|||||||
reward: number;
|
reward: number;
|
||||||
cargoType: string;
|
cargoType: string;
|
||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
||||||
|
weightKg: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
@@ -108,6 +125,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
departureSimTime: p.departureSimTime,
|
departureSimTime: p.departureSimTime,
|
||||||
lat: live?.lat ?? p.lat,
|
lat: live?.lat ?? p.lat,
|
||||||
lon: live?.lon ?? p.lon,
|
lon: live?.lon ?? p.lon,
|
||||||
|
queue: parseIcaoQueue(p.queue),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
packages: packageRows.map((p) => ({
|
packages: packageRows.map((p) => ({
|
||||||
@@ -119,6 +137,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
reward: p.reward,
|
reward: p.reward,
|
||||||
cargoType: p.cargoType,
|
cargoType: p.cargoType,
|
||||||
cargoCount: p.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
|
weightKg: p.weightKg,
|
||||||
deadlineSimTime: p.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
|
|||||||
+74
-11
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm } from './geo';
|
||||||
|
|
||||||
const AIRPORTS = new Map<string, AirportCoord>([
|
const AIRPORTS = new Map<string, AirportCoord>([
|
||||||
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
planes: [],
|
planes: [],
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: [
|
catalog: [
|
||||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
||||||
],
|
],
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
sellFraction: 0.5,
|
sellFraction: 0.5,
|
||||||
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
|
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
snapshot({
|
||||||
catalog: [],
|
catalog: [],
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 },
|
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -140,6 +140,69 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only)', () => {
|
||||||
|
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(1);
|
||||||
|
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||||||
|
const coords = fc.features[0].geometry.coordinates;
|
||||||
|
// Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points.
|
||||||
|
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1);
|
||||||
|
expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin
|
||||||
|
expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD)
|
||||||
|
expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
|
||||||
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
||||||
|
const fc = queuedRoutesGeoJson(
|
||||||
|
[
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
|
||||||
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
|
||||||
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
|
||||||
|
],
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
|
describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => {
|
||||||
it('returns an empty collection for the no-hover "" sentinel', () => {
|
it('returns an empty collection for the no-hover "" sentinel', () => {
|
||||||
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { StateSnapshot } from './game/state';
|
import type { PlaneState, StateSnapshot } from './game/state';
|
||||||
import { formatFuel } from './format';
|
import { formatFuel } from './format';
|
||||||
import { positionAt } from './sim/movement';
|
import { positionAt } from './sim/movement';
|
||||||
|
|
||||||
@@ -164,3 +164,31 @@ export function routesGeoJson(
|
|||||||
}
|
}
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
|
||||||
|
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
|
||||||
|
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
|
||||||
|
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
|
||||||
|
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
|
||||||
|
export function queuedRoutesGeoJson(
|
||||||
|
planes: PlaneState[],
|
||||||
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
|
): FeatureCollection<MapLineFeature> {
|
||||||
|
const features: MapLineFeature[] = [];
|
||||||
|
for (const p of planes) {
|
||||||
|
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
|
||||||
|
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
|
||||||
|
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
|
||||||
|
const coords: [number, number][] = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
|
||||||
|
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
|
||||||
|
}
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'LineString', coordinates: coords },
|
||||||
|
properties: { planeId: p.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|||||||
+27
-5
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => {
|
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
'/api/airports',
|
'/api/airports',
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
@@ -11,6 +11,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/packages/{id}/abandon',
|
'/api/packages/{id}/abandon',
|
||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
@@ -92,18 +94,17 @@ describe('OpenAPI spec', () => {
|
|||||||
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
|
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
|
||||||
expect(Object.keys(catalog).sort()).toEqual([
|
expect(Object.keys(catalog).sort()).toEqual([
|
||||||
'burnPerKm',
|
'burnPerKm',
|
||||||
'capacity',
|
|
||||||
'emptyWeightKg', // v0.16.0 weighted burn model input
|
'emptyWeightKg', // v0.16.0 weighted burn model input
|
||||||
'label',
|
'label',
|
||||||
'maxFuel',
|
'maxFuel',
|
||||||
|
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
|
||||||
'model',
|
'model',
|
||||||
'price',
|
'price',
|
||||||
'speedKmh',
|
'speedKmh',
|
||||||
]);
|
]);
|
||||||
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
|
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'maxPayloadKg', 'price', 'speedKmh'] as const) {
|
||||||
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
|
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
|
||||||
}
|
}
|
||||||
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
|
|
||||||
expect(catalog.model.type).toBe('string');
|
expect(catalog.model.type).toBe('string');
|
||||||
expect(catalog.label.type).toBe('string');
|
expect(catalog.label.type).toBe('string');
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'lon',
|
'lon',
|
||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -137,6 +139,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'planeId',
|
'planeId',
|
||||||
'reward',
|
'reward',
|
||||||
'status',
|
'status',
|
||||||
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sim = openApiSpec.components.schemas.SimState.properties;
|
const sim = openApiSpec.components.schemas.SimState.properties;
|
||||||
@@ -169,7 +172,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(error)).toEqual(['error']);
|
expect(Object.keys(error)).toEqual(['error']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||||
// The spec object is a literal type; these helpers view it as plain JSON.
|
// The spec object is a literal type; these helpers view it as plain JSON.
|
||||||
const post = (p: string) =>
|
const post = (p: string) =>
|
||||||
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||||
@@ -183,6 +186,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/packages/{id}/load',
|
'/api/packages/{id}/load',
|
||||||
'/api/packages/{id}/abandon',
|
'/api/packages/{id}/abandon',
|
||||||
'/api/planes/{planeId}/fly',
|
'/api/planes/{planeId}/fly',
|
||||||
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
@@ -196,6 +201,8 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['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}/fly')).toEqual(['400', '404', '409']);
|
||||||
|
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying
|
||||||
|
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
||||||
@@ -208,6 +215,12 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
'#/components/schemas/FlyPlaneRequest',
|
'#/components/schemas/FlyPlaneRequest',
|
||||||
);
|
);
|
||||||
|
expect(post('/api/planes/{planeId}/queue').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
|
'#/components/schemas/QueueLegRequest', // v0.25.0 (ISS-043)
|
||||||
|
);
|
||||||
|
expect(post('/api/planes/{planeId}/queue/cancel').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
|
'#/components/schemas/CancelQueuedLegRequest', // v0.25.0 (ISS-043)
|
||||||
|
);
|
||||||
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
'#/components/schemas/BuyPlaneRequest',
|
'#/components/schemas/BuyPlaneRequest',
|
||||||
);
|
);
|
||||||
@@ -224,6 +237,8 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
|
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
|
||||||
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
|
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
|
||||||
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
||||||
|
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||||
|
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
||||||
|
|
||||||
// Request schema shapes.
|
// Request schema shapes.
|
||||||
@@ -233,6 +248,13 @@ describe('OpenAPI spec', () => {
|
|||||||
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
|
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
|
||||||
expect(fly.required).toEqual(['destIcao']);
|
expect(fly.required).toEqual(['destIcao']);
|
||||||
expect(fly.properties.destIcao.type).toBe('string');
|
expect(fly.properties.destIcao.type).toBe('string');
|
||||||
|
// v0.25.0 (ISS-043): the queue request schemas mirror fly's destIcao shape.
|
||||||
|
const queueLeg = openApiSpec.components.schemas.QueueLegRequest;
|
||||||
|
expect(queueLeg.required).toEqual(['destIcao']);
|
||||||
|
expect(queueLeg.properties.destIcao.type).toBe('string');
|
||||||
|
const cancelQueuedLeg = openApiSpec.components.schemas.CancelQueuedLegRequest;
|
||||||
|
expect(cancelQueuedLeg.required).toEqual(['index']);
|
||||||
|
expect(cancelQueuedLeg.properties.index.type).toBe('integer');
|
||||||
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
|
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
|
||||||
expect(buy.required).toEqual(['model']);
|
expect(buy.required).toEqual(['model']);
|
||||||
expect(buy.properties.model.type).toBe('string');
|
expect(buy.properties.model.type).toBe('string');
|
||||||
|
|||||||
+95
-4
@@ -106,7 +106,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Load a package onto a plane',
|
summary: 'Load a package onto a plane',
|
||||||
description:
|
description:
|
||||||
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.',
|
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs enough payload room for the package weight — maxPayloadKg per catalog model; expired packages still occupy theirs (v0.27.0). The sim clock is ticked first, so the returned snapshot reflects current game time.',
|
||||||
operationId: 'loadPackage',
|
operationId: 'loadPackage',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -204,6 +204,74 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/planes/{planeId}/queue': {
|
||||||
|
post: {
|
||||||
|
summary: 'Queue a flight leg for a plane',
|
||||||
|
description:
|
||||||
|
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).',
|
||||||
|
operationId: 'queueLeg',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/QueueLegRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Queued — full state snapshot (plane.queue now includes the new leg)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description:
|
||||||
|
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown plane id',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/planes/{planeId}/queue/cancel': {
|
||||||
|
post: {
|
||||||
|
summary: 'Cancel a queued flight leg',
|
||||||
|
description:
|
||||||
|
'Remove a NOT-yet-started leg from the plane\'s queue by index (v0.25.0, ISS-043). Only queued legs are cancellable — the active in-flight leg (destIcao) is never affected.',
|
||||||
|
operationId: 'cancelQueuedLeg',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/CancelQueuedLegRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Cancelled — full state snapshot (plane.queue without the removed leg)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description:
|
||||||
|
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown plane id',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api/market/buy-plane': {
|
'/api/market/buy-plane': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Buy a plane from the market',
|
summary: 'Buy a plane from the market',
|
||||||
@@ -393,9 +461,9 @@ export const openApiSpec = {
|
|||||||
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
|
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
|
||||||
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
|
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
|
||||||
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
|
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
|
||||||
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' },
|
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
|
||||||
},
|
},
|
||||||
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'],
|
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'maxPayloadKg'],
|
||||||
},
|
},
|
||||||
AccountState: {
|
AccountState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -430,7 +498,7 @@ export const openApiSpec = {
|
|||||||
LoadPackageRequest: {
|
LoadPackageRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' },
|
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with enough payload room for it (v0.27.0).' },
|
||||||
},
|
},
|
||||||
required: ['planeId'],
|
required: ['planeId'],
|
||||||
},
|
},
|
||||||
@@ -441,6 +509,20 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['destIcao'],
|
required: ['destIcao'],
|
||||||
},
|
},
|
||||||
|
QueueLegRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — appended to the queue as a not-yet-started leg (v0.25.0).' },
|
||||||
|
},
|
||||||
|
required: ['destIcao'],
|
||||||
|
},
|
||||||
|
CancelQueuedLegRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
index: { type: 'integer', description: 'Zero-based position of the queued (not-yet-started) leg to remove; must be within [0, queue.length) (v0.25.0).' },
|
||||||
|
},
|
||||||
|
required: ['index'],
|
||||||
|
},
|
||||||
BuyPlaneRequest: {
|
BuyPlaneRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -502,6 +584,12 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
lat: { type: 'number' },
|
lat: { type: 'number' },
|
||||||
lon: { type: 'number' },
|
lon: { type: 'number' },
|
||||||
|
queue: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description:
|
||||||
|
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -516,6 +604,7 @@ export const openApiSpec = {
|
|||||||
'departureSimTime',
|
'departureSimTime',
|
||||||
'lat',
|
'lat',
|
||||||
'lon',
|
'lon',
|
||||||
|
'queue',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PackageState: {
|
PackageState: {
|
||||||
@@ -529,6 +618,7 @@ export const openApiSpec = {
|
|||||||
reward: { type: 'number' },
|
reward: { type: 'number' },
|
||||||
cargoType: { type: 'string' },
|
cargoType: { type: 'string' },
|
||||||
cargoCount: { type: 'integer' },
|
cargoCount: { type: 'integer' },
|
||||||
|
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
@@ -545,6 +635,7 @@ export const openApiSpec = {
|
|||||||
'reward',
|
'reward',
|
||||||
'cargoType',
|
'cargoType',
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
'deadlineSimTime',
|
'deadlineSimTime',
|
||||||
'status',
|
'status',
|
||||||
'planeId',
|
'planeId',
|
||||||
|
|||||||
+79
-1
@@ -9,7 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
|
||||||
import {
|
import {
|
||||||
clampTimeScale,
|
clampTimeScale,
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
@@ -235,6 +235,68 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0 });
|
.set({ status: 'flying', departureSimTime: T0 });
|
||||||
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
|
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
|
||||||
|
|
||||||
|
it('auto-chains to the next queued leg when fuel covers it', async () => {
|
||||||
|
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
|
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
|
||||||
|
|
||||||
|
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
|
||||||
|
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying'); // chained — still airborne, no ground stop
|
||||||
|
expect(plane.destIcao).toBe('EHAM'); // first queued leg is now the active one
|
||||||
|
expect(plane.departureSimTime).toBe(clock); // departed at the arrival instant…
|
||||||
|
expect(plane.locationIcao).toBe(TO); // …from the airport just landed at
|
||||||
|
expect(plane.lat).toBe(to.lat);
|
||||||
|
expect(plane.lon).toBe(to.lon);
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EDDV']); // first leg consumed, rest kept in order
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
|
||||||
|
const { inst, km, type } = await setupFlight('chain-nofuel.db');
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
|
|
||||||
|
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({
|
||||||
|
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
|
||||||
|
queue: JSON.stringify(['EHAM']),
|
||||||
|
})
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // grounded safely…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(plane.departureSimTime).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for refuel + re-dispatch
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grounds as before when the queue is empty', async () => {
|
||||||
|
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||||
@@ -300,6 +362,22 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
|||||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a chained plane does NOT trigger the last-plane pause (ISS-043)', async () => {
|
||||||
|
const { inst } = await setupLanding('pause-chain.db', 1000); // full tank, arrival exactly T0
|
||||||
|
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['EDDV']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
||||||
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
|
expect(sim.paused).toBe(false); // still flying after the chain — no premature pause…
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
|
||||||
|
expect(plane.destIcao).toBe('EDDV');
|
||||||
|
});
|
||||||
|
|
||||||
it('paused sim with all planes landed → no change', async () => {
|
it('paused sim with all planes landed → no change', async () => {
|
||||||
const inst = await freshDb('pause-paused.db');
|
const inst = await freshDb('pause-paused.db');
|
||||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
||||||
|
|||||||
+57
-19
@@ -5,6 +5,7 @@ import * as schema from '@/lib/db/schema';
|
|||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
|
import { parseIcaoQueue } from '@/lib/game/state';
|
||||||
import {
|
import {
|
||||||
generatePackage,
|
generatePackage,
|
||||||
MARKET_TARGET_OPEN_PACKAGES,
|
MARKET_TARGET_OPEN_PACKAGES,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
type Rng,
|
type Rng,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
} from '@/lib/game/seed';
|
} from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
|
||||||
|
|
||||||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
||||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||||||
@@ -122,12 +123,17 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||||
// ground the plane at its dest_icao with its remaining fuel, then for every
|
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||||
// package bound to it and destined for that airport — 'loaded' ones still inside
|
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||||
// their deadline are delivered (cash += reward + reputation gain per package),
|
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||||
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
|
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||||
// on the plane for a later leg. Idempotent — a finalized plane leaves the
|
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
||||||
// 'flying' state, so it can never be awarded twice. Returns planes landed.
|
// immediately from the arrival airport; otherwise ground the plane there with
|
||||||
|
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
||||||
|
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||||||
|
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||||
|
// Returns planes landed. NOTE: runs BEFORE tickOnce's pause check on purpose —
|
||||||
|
// a chained plane counts as still flying there, so chaining never pauses early.
|
||||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
const [planeRows, packageRows] = await Promise.all([
|
const [planeRows, packageRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.planes),
|
inst.db.select().from(schema.planes),
|
||||||
@@ -149,19 +155,10 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||||
|
|
||||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
|
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
|
||||||
await inst.db.update(schema.planes)
|
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
|
||||||
.set({
|
|
||||||
status: 'available',
|
|
||||||
locationIcao: to.icao,
|
|
||||||
lat: to.lat,
|
|
||||||
lon: to.lon,
|
|
||||||
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
|
|
||||||
departureSimTime: null,
|
|
||||||
destIcao: null,
|
|
||||||
})
|
|
||||||
.where(eq(schema.planes.id, p.id));
|
|
||||||
|
|
||||||
// Deliver every bound package whose destination is the airport just reached.
|
// Deliver every bound package whose destination is the airport just reached —
|
||||||
|
// runs whether or not a queued leg chains next (ISS-043).
|
||||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||||
for (const pk of onBoard) {
|
for (const pk of onBoard) {
|
||||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||||
@@ -178,6 +175,47 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
|
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
||||||
|
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
||||||
|
// (requiredFuelFor is independent of current load). Otherwise ground here; an
|
||||||
|
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
|
||||||
|
// intact either way so a grounded plane can refuel and re-dispatch.
|
||||||
|
const queue = parseIcaoQueue(p.queue);
|
||||||
|
let chainedTo: string | null = null;
|
||||||
|
if (queue.length > 0) {
|
||||||
|
const nextDest = queue[0];
|
||||||
|
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||||
|
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
|
||||||
|
queue.shift();
|
||||||
|
chainedTo = nextDest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set(
|
||||||
|
chainedTo
|
||||||
|
? {
|
||||||
|
status: 'flying',
|
||||||
|
locationIcao: to.icao,
|
||||||
|
lat: to.lat,
|
||||||
|
lon: to.lon,
|
||||||
|
fuel: landedFuel,
|
||||||
|
destIcao: chainedTo,
|
||||||
|
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||||
|
queue: JSON.stringify(queue),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'available',
|
||||||
|
locationIcao: to.icao,
|
||||||
|
lat: to.lat,
|
||||||
|
lon: to.lon,
|
||||||
|
fuel: landedFuel,
|
||||||
|
departureSimTime: null,
|
||||||
|
destIcao: null,
|
||||||
|
queue: JSON.stringify(queue),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.where(eq(schema.planes.id, p.id));
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.24.0",
|
"version": "0.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user