feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.12.0 — 2026-09-18 09:17
|
||||||
|
- **Gameplay pivot (user-driven): contracts → packages** — the job model is rebuilt end-to-end around cargo *packages* and manual flight, replacing the old accept→dispatch flow in one coherent increment. A **package** is a cargo unit origin→dest with a reward € and a game-time deadline; planes carry up to `capacity` packages (ATR 72-600 = **6 slots**, new `capacity` field on catalog entries exposed via `/api/state`). The loop: **Load** an open package onto a grounded plane at its origin → **Fly** the plane to any known airport (the destination of a loaded package, or pure repositioning) → on arrival every in-deadline package bound for that airport is **delivered** (cash += reward + rep per package); packages destined elsewhere stay loaded for a later leg
|
||||||
|
- **New player actions** (`lib/game/actions.ts`, all under the sim lock via `engine.runAction()` — tick first, then validate+mutate): `loadPackage` (open → loaded; plane must be GROUNDED at the origin with a free slot — expired packages still occupy theirs), `flyPlane` (grounded plane → any dataset ICAO; 400 unknown airport, 409 flying / insufficient fuel where burnPerKm × great-circle km > fuel), `abandonPackage` (loaded or already-expired package while its plane is GROUNDED — 409 if in flight). Removed: `acceptContract`, `dispatchPlane`
|
||||||
|
- **Abandon penalty** (`lib/game/penalty.ts`, pure + shared by server and client preview): fraction of the payout scaling with time pressure — exactly **15%** at creation, linear to **60%** at the deadline; an already-expired package abandons for free; cash floors at €0. `abandonPenaltyPct` / `elapsedFractionFor` / `abandonPenalty(pkg, clockMs)`
|
||||||
|
- **Sim engine** (`lib/sim/engine.ts`): `expirePackages` flips both OPEN and LOADED packages past their deadline to 'expired' (open ones leave the market so spawn tops it back up; loaded ones keep their slot); `deliverPackages` (was finalizeArrivals) grounds a flying plane at its `dest_icao`, then for every bound package destined there — in-deadline 'loaded' → delivered + cash/rep, 'expired' → unbind only (no reward); spawn rules (`spawnOpenPackages` / `spawnCoveragePackages`) adapted to packages via the renamed shared builder
|
||||||
|
- **Schema** (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): new `packages` table (status open|loaded|delivered|expired|abandoned, planeId nullable); planes gain nullable `dest_icao` and lose `contract_id`; seed builder renamed `generateContract` → `generatePackage` (same reward/deadline formulas), `MARKET_TARGET_OPEN_PACKAGES = 10`
|
||||||
|
- **One-time migration for live DBs** (`lib/db/client.ts`, detected by the presence of the old `contracts` table so it runs exactly once per file): PRESERVES account + sim_state rows and every plane row; resets all planes to GROUNDED at their locationIcao with departure_sim_time/dest_icao cleared (an in-flight plane from the old state simply lands back where it took off); wipes old contract data (`DROP TABLE contracts`), drops the orphaned `contract_id` column, creates the empty packages table — the engine's spawn rules reseed the market on the next tick. **Wallet, fleet and sim clock survive; old contracts do not.**
|
||||||
|
- **API** (OpenAPI updated in code): NEW `POST /api/packages/:id/load {planeId}`, `POST /api/planes/:planeId/fly {destIcao}`, `POST /api/packages/:id/abandon` — all tick-first, full state snapshot on 200, `{error}` on 4xx (404 unknown id; 409 wrong status / not at origin / no free slot / in flight / insufficient fuel; 400 bad body or unknown ICAO). REMOVED `POST /api/contracts/:id/accept` and `POST /api/planes/:planeId/dispatch`. Test hooks renamed: `/api/test/create-package` (origin/dest/deadlineOffsetMs) and `/api/test/expire-package` ({packageId?}), same FLIGHT_TEST_HOOKS=1 gate
|
||||||
|
- **Snapshot** (`lib/game/state.ts`): `contracts` → `packages` field; planes gain `destIcao` + `destName` (null when grounded); catalog entries carry `capacity`. `/api/state` shape otherwise unchanged
|
||||||
|
- **UI — right pane** (`components/RightPane.tsx`): "Packages" section lists open packages with a Load button that acts directly on the single eligible plane or shows a small picker when several qualify (pure rule extracted as `loadEligiblePlanes` / `slotsUsedFor`, unit-tested); new "On board" section per plane — loaded/expired packages listed with a **fly-to-destination** button (the destination's name; enabled only while grounded) and an **Abandon** button showing the exact penalty preview computed client-side from the same formula; flying planes show their current leg "originName → destName". Left pane fleet rows gain a loadout "n/6 pkgs"
|
||||||
|
- **UI — map** (`components/Map.tsx` + `lib/mapData.ts`): route lines are now each FLYING plane's current leg (origin→dest_icao, one amber line per flying plane keyed by planeId) — the old pending/inflight contract-based layers are gone; grounded planes draw nothing. Plane popup route text comes from the plane's own dest fields
|
||||||
|
- Tests: unit 192 → **228** green — new `lib/game/penalty.test.ts` (15% start / 60% deadline / monotonic / clamped / expired→0 / cent rounding), rewritten `actions.test.ts` (load validation matrix incl. slot capacity + expired-slot occupancy; fly unknown-ICAO/flying/no-fuel; abandon penalty at exact clock points, cash floor, in-flight 409), `engine.test.ts` (expire open+loaded, terminal never flips; arrival delivery mixed loaded/expired/other-dest with exact cash+rep deltas and idempotency; spawn top-up + coverage on packages), `seed.test.ts` / `client.test.ts` (fresh seed of packages; pre-v0.12 migration wipes contracts + grounds planes + preserves account/clock, one-shot on reopen), route tests for load/fly/abandon, openapi/mapData/right-pane updated to the package model. e2e 27 → **29** — rewritten `actions.spec.ts` (full loop via UI: hook package at EDDF → Load click → fly-to-destination click → exact reward+rep on arrival; abandon deducts exactly the formula penalty; expired packages deliver/abandon with no reward and no penalty; load/fly validation 404/409/400 incl. an unfuelable return leg), `right-pane.spec.ts` (open package rows + Load enabled/disabled by origin, on-board fly+abandon with penalty preview, flying-leg display, market previews + exact refuel cost), `map-interactions.spec.ts` (no line while grounded → leg line appears keyed by planeId after fly, marker moves across polls, popup shows the leg); sim/state/api/panes updated to packages (api now asserts ten endpoints; panes asserts the "n/6 pkgs" loadout)
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite 228 green; e2e 29 green against the rebuilt compose instance with the live DB migrated in place (wallet € + sim clock preserved, old contracts wiped); scale restored to 1×/unpaused after every spec
|
||||||
|
|
||||||
## v0.11.0 — 2026-09-18 07:25
|
## v0.11.0 — 2026-09-18 07:25
|
||||||
- **Map interactions per PLAN.md "Middle — Map"** (`components/Map.tsx` now receives the shell's polled `/api/state` snapshot from `app/page.tsx`, same single poll loop as the panes): (a) airport popup expanded — name + ICAO kept, plus **approximate local time** and a **generated weather** line; (b) **animated plane markers** — every plane rendered on the map at its polled lat/lon (server computes flying positions analytically each tick), grounded planes as small sky-blue circles at their airport, flying planes bigger + green so they stand out; movement between 1 s polls is acceptable for this increment (no client-side interpolation); (c) **plane status popup** on click — catalog model label ("ATR 72-600"), status, fuel "x / y L", and while flying the contract route "originName → destName"; (d) **active contract routes highlighted** — one origin→dest line per accepted contract: dashed muted slate when pending (accepted, not yet dispatched), solid bright amber once in-flight
|
- **Map interactions per PLAN.md "Middle — Map"** (`components/Map.tsx` now receives the shell's polled `/api/state` snapshot from `app/page.tsx`, same single poll loop as the panes): (a) airport popup expanded — name + ICAO kept, plus **approximate local time** and a **generated weather** line; (b) **animated plane markers** — every plane rendered on the map at its polled lat/lon (server computes flying positions analytically each tick), grounded planes as small sky-blue circles at their airport, flying planes bigger + green so they stand out; movement between 1 s polls is acceptable for this increment (no client-side interpolation); (c) **plane status popup** on click — catalog model label ("ATR 72-600"), status, fuel "x / y L", and while flying the contract route "originName → destName"; (d) **active contract routes highlighted** — one origin→dest line per accepted contract: dashed muted slate when pending (accepted, not yet dispatched), solid bright amber once in-flight
|
||||||
- **Pure deterministic weather generator** `lib/weather.ts`: `weatherFor(icao: string, dayKey?: number)` → `{condition: 'Clear'|'Cloudy'|'Rain'|'Fog', windDirDeg, windKt, visibilityM}` — seeded by FNV-1a hash of the ICAO through mulberry32 (same PRNG as game seeding), so the same airport always shows the same weather across polls/sessions; optional `dayKey` varies it per sim-day. Display-only per PLAN.md — no gameplay effects
|
- **Pure deterministic weather generator** `lib/weather.ts`: `weatherFor(icao: string, dayKey?: number)` → `{condition: 'Clear'|'Cloudy'|'Rain'|'Fog', windDirDeg, windKt, visibilityM}` — seeded by FNV-1a hash of the ICAO through mulberry32 (same PRNG as game seeding), so the same airport always shows the same weather across polls/sessions; optional `dayKey` varies it per sim-day. Display-only per PLAN.md — no gameplay effects
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import * as schema from '@/lib/db/schema';
|
|
||||||
import { getGameDb } from '@/lib/db/client';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-accept-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'accept.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (id: string) => POST(new Request(`http://localhost/api/contracts/${id}/accept`), { params: Promise.resolve({ id }) });
|
|
||||||
|
|
||||||
describe('POST /api/contracts/:id/accept', () => {
|
|
||||||
it('returns the full state snapshot with the contract accepted', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
const open = (await db.select().from(schema.contracts)).find((c) => c.status === 'open')!;
|
|
||||||
|
|
||||||
const res = await post(open.id);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get('content-type')).toContain('application/json');
|
|
||||||
|
|
||||||
// Response convention: the FULL snapshot, same shape as GET /api/state.
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
account: { cash: number };
|
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
|
||||||
planes: Array<Record<string, unknown>>;
|
|
||||||
contracts: Array<{ id: string; status: string }>;
|
|
||||||
};
|
|
||||||
// v0.10.0: the snapshot also carries the static market reference data.
|
|
||||||
expect(Object.keys(body).sort()).toEqual([
|
|
||||||
'account',
|
|
||||||
'catalog',
|
|
||||||
'contracts',
|
|
||||||
'fuelPricePerL',
|
|
||||||
'planes',
|
|
||||||
'sellFraction',
|
|
||||||
'simState',
|
|
||||||
]);
|
|
||||||
expect(body.contracts.find((c) => c.id === open.id)?.status).toBe('accepted');
|
|
||||||
|
|
||||||
// Persisted, not just in the response.
|
|
||||||
const row = (await db.select().from(schema.contracts).where(eq(schema.contracts.id, open.id)))[0];
|
|
||||||
expect(row.status).toBe('accepted');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a non-open contract with 409', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
// Deterministic regardless of which contract the first test accepted.
|
|
||||||
await db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, 'contract-2'));
|
|
||||||
|
|
||||||
const res = await post('contract-2');
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/completed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
|
||||||
const res = await post('nope');
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { acceptContract, performAction } from '@/lib/game/actions';
|
|
||||||
|
|
||||||
// POST /api/contracts/:id/accept (v0.8.0) — claim an open contract: status flips
|
|
||||||
// open → accepted. 404 unknown id; 409 if the contract is not 'open' (accepted /
|
|
||||||
// completed / expired). No body. On success returns the full state snapshot.
|
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await ctx.params;
|
|
||||||
return performAction((inst) => acceptContract(inst, id));
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,7 @@ describe('POST /api/market/sell-plane', () => {
|
|||||||
// A second, grounded plane so "flying" is the reason for rejection.
|
// A second, grounded plane so "flying" is the reason for rejection.
|
||||||
await db.insert(schema.planes).values({
|
await db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
|
||||||
|
params: Promise.resolve({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
||||||
|
// the exact created/deadline times so tests can compute the penalty from the
|
||||||
|
// snapshot's clock (robust against however much real time the test itself takes).
|
||||||
|
async function insertLoadedPackage(id: string, reward = 1_000) {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
const sim = (await db.select().from(schema.simState))[0];
|
||||||
|
const createdSimTime = sim.clockMs;
|
||||||
|
const deadlineSimTime = createdSimTime + 24 * 3_600_000;
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id, originIcao: 'EDDF', destIcao: 'LEMD', reward, cargoType: 'Mail', cargoCount: 4,
|
||||||
|
deadlineSimTime, status: 'loaded', planeId: 'plane-starter', createdSimTime,
|
||||||
|
});
|
||||||
|
return { createdSimTime, deadlineSimTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors lib/game/penalty.ts (same formula → identical floats).
|
||||||
|
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||||
|
const frac = Math.min(1, Math.max(0, (clockMs - createdSimTime) / (deadlineSimTime - createdSimTime)));
|
||||||
|
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/packages/:id/abandon', () => {
|
||||||
|
it('rejects an unknown id with 404', async () => {
|
||||||
|
const res = await post('nope');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an open package with 409 (nothing loaded to abandon)', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post('p-open');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||||||
|
await insertLoadedPackage('p-flying');
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
await db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const res = await post('p-flying');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/in flight/i);
|
||||||
|
|
||||||
|
// Ground it again for later tests.
|
||||||
|
await db.update(schema.planes)
|
||||||
|
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
|
||||||
|
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const res = await post('p-ab');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
|
||||||
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
|
'account',
|
||||||
|
'catalog',
|
||||||
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
|
'planes',
|
||||||
|
'sellFraction',
|
||||||
|
'simState',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cash reduced by exactly the penalty at the snapshot's clock (15% floor + a
|
||||||
|
// sliver of elapsed time — computed with the same formula as the server).
|
||||||
|
const expectedPenalty = penaltyFor(1_000, times.createdSimTime, times.deadlineSimTime, body.simState.clockMs);
|
||||||
|
expect(expectedPenalty).toBeGreaterThanOrEqual(150); // at/above the floor…
|
||||||
|
expect(body.account.cash).toBeCloseTo(cashBefore - expectedPenalty, 6); // …and exact
|
||||||
|
|
||||||
|
const abandoned = body.packages.find((p) => p.id === 'p-ab')!;
|
||||||
|
expect(abandoned.status).toBe('abandoned');
|
||||||
|
expect(abandoned.planeId).toBeNull(); // slot freed in the response too
|
||||||
|
|
||||||
|
// Persisted, not just in the response.
|
||||||
|
const row = (await db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
|
expect(row.status).toBe('abandoned');
|
||||||
|
expect(row.planeId).toBeNull(); // slot freed
|
||||||
|
|
||||||
|
// Abandoning it again is rejected — the package is no longer loaded/expired.
|
||||||
|
const again = await post('p-ab');
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { abandonPackage, performAction } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/packages/:id/abandon (v0.12.0) — no body. Drops a LOADED (or already
|
||||||
|
// EXPIRED) package while its plane is GROUNDED: cash -= penalty (15% of the payout
|
||||||
|
// at creation scaling to 60% at the deadline; expired packages cost nothing, cash
|
||||||
|
// floors at €0), the package becomes "abandoned" and frees its slot. 404 unknown
|
||||||
|
// id; 409 wrong status or the plane is in flight. On success returns the full
|
||||||
|
// state snapshot.
|
||||||
|
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
return performAction((inst) => abandonPackage(inst, id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (id: string, body: unknown) =>
|
||||||
|
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
|
||||||
|
params: Promise.resolve({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/packages/:id/load', () => {
|
||||||
|
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
|
||||||
|
for (const body of [{}, { planeId: 42 }, { bogus: 1 }]) {
|
||||||
|
const res = await post('p-x', body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown package id with 404', async () => {
|
||||||
|
const res = await post('nope', { planeId: 'plane-starter' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post('p-ghost-plane', { planeId: 'ghost' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects loading a package whose origin the plane is not at with 409', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
|
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects loading a non-open package with 409', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id: 'p-busy', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
|
deadlineSimTime: Date.now() + 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post('p-busy', { planeId: 'plane-starter' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/loaded/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads an open package at the plane\'s airport and returns the full snapshot', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
// A known open package originating where the starter actually sits (EDDF).
|
||||||
|
await db.insert(schema.packages).values({
|
||||||
|
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
|
||||||
|
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post('p-home', { planeId: 'plane-starter' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
|
||||||
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: Array<Record<string, unknown>>;
|
||||||
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
|
'account',
|
||||||
|
'catalog',
|
||||||
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
|
'planes',
|
||||||
|
'sellFraction',
|
||||||
|
'simState',
|
||||||
|
]);
|
||||||
|
const loaded = body.packages.find((p) => p.id === 'p-home')!;
|
||||||
|
expect(loaded.status).toBe('loaded');
|
||||||
|
expect(loaded.planeId).toBe('plane-starter');
|
||||||
|
|
||||||
|
// Persisted, not just in the response.
|
||||||
|
const row = (await db.select().from(schema.packages).where(eq(schema.packages.id, 'p-home')))[0];
|
||||||
|
expect(row.status).toBe('loaded');
|
||||||
|
expect(row.planeId).toBe('plane-starter');
|
||||||
|
|
||||||
|
// A second load of the same package is rejected — it is no longer open.
|
||||||
|
const again = await post('p-home', { planeId: 'plane-starter' });
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { loadPackage, performAction } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409
|
||||||
|
// wrong package status, plane flying, plane not at the origin, no free slots. On
|
||||||
|
// success returns the full state snapshot (package now "loaded" on that plane).
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = 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 !== 'planeId') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof input.planeId !== 'string' || input.planeId.length === 0) {
|
||||||
|
return Response.json({ error: 'planeId must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const planeId = input.planeId;
|
||||||
|
return performAction((inst) => loadPackage(inst, id, planeId));
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import * as schema from '@/lib/db/schema';
|
|
||||||
import { getGameDb } from '@/lib/db/client';
|
|
||||||
import { POST as acceptPost } from '../../../contracts/[id]/accept/route';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-dispatch-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'dispatch.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (planeId: string, body: unknown) =>
|
|
||||||
POST(new Request(`http://localhost/api/planes/${planeId}/dispatch`, { method: 'POST', body: JSON.stringify(body) }), {
|
|
||||||
params: Promise.resolve({ planeId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /api/planes/:planeId/dispatch', () => {
|
|
||||||
it('rejects a missing/non-string contractId and unknown fields with 400', async () => {
|
|
||||||
for (const body of [{}, { contractId: 42 }, { bogus: 1 }]) {
|
|
||||||
const res = await post('plane-starter', body);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects dispatching an OPEN contract (no accept) with 409 — the e2e "dispatch without accept" path', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
// A known open contract at the starter plane's home base.
|
|
||||||
await db.insert(schema.contracts).values({
|
|
||||||
id: 'c-open-at-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
|
||||||
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { contractId: 'c-open-at-home' });
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toMatch(/open/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown plane and contract ids with 404', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
// Contract checks run before plane checks, so the ghost-plane case needs an
|
|
||||||
// ACCEPTED contract to reach the plane lookup.
|
|
||||||
await db.insert(schema.contracts).values({
|
|
||||||
id: 'c-acc-ghost', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
|
||||||
deadlineSimTime: Date.now() + 3_600_000, status: 'accepted', planeId: null, createdSimTime: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ghostPlane = await post('ghost-plane', { contractId: 'c-acc-ghost' });
|
|
||||||
expect(ghostPlane.status).toBe(404);
|
|
||||||
|
|
||||||
const ghostContract = await post('plane-starter', { contractId: 'nope' });
|
|
||||||
expect(ghostContract.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts then dispatches: snapshot shows the plane flying with departure set and the contract bound', async () => {
|
|
||||||
const { db } = await getGameDb();
|
|
||||||
// Accept the home-base contract through its own route, then dispatch.
|
|
||||||
const acceptRes = await acceptPost(
|
|
||||||
new Request('http://localhost/api/contracts/c-open-at-home/accept'),
|
|
||||||
{ params: Promise.resolve({ id: 'c-open-at-home' }) },
|
|
||||||
);
|
|
||||||
expect(acceptRes.status).toBe(200);
|
|
||||||
|
|
||||||
const res = await post('plane-starter', { contractId: 'c-open-at-home' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
planes: Array<{ id: string; status: string; departureSimTime: number | null; locationIcao: string }>;
|
|
||||||
contracts: Array<{ id: string; status: string; planeId: string | null }>;
|
|
||||||
};
|
|
||||||
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(plane.status).toBe('flying');
|
|
||||||
expect(typeof plane.departureSimTime).toBe('number'); // departure set at dispatch time
|
|
||||||
expect(plane.locationIcao).toBe('EDDF');
|
|
||||||
|
|
||||||
const contract = body.contracts.find((c) => c.id === 'c-open-at-home')!;
|
|
||||||
expect(contract.status).toBe('accepted');
|
|
||||||
expect(contract.planeId).toBe('plane-starter');
|
|
||||||
|
|
||||||
// A second dispatch of the same (now bound, still accepted) contract by the
|
|
||||||
// flying plane is rejected — the plane is no longer available.
|
|
||||||
const again = await post('plane-starter', { contractId: 'c-open-at-home' });
|
|
||||||
expect(again.status).toBe(409);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { dispatchPlane, performAction } from '@/lib/game/actions';
|
|
||||||
|
|
||||||
// POST /api/planes/:planeId/dispatch (v0.8.0) — body { contractId: string }.
|
|
||||||
// Binds an ACCEPTED contract to a grounded plane at its origin; the v0.7.0
|
|
||||||
// finalize path then flies it and pays out on arrival. 400 missing/non-string
|
|
||||||
// contractId or unknown fields; 404 unknown plane/contract id; 409 wrong contract
|
|
||||||
// status, plane not available, plane not at origin, insufficient fuel. On success
|
|
||||||
// returns the full state snapshot (plane already 'flying' with departure set).
|
|
||||||
|
|
||||||
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 !== 'contractId') {
|
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof input.contractId !== 'string' || input.contractId.length === 0) {
|
|
||||||
return Response.json({ error: 'contractId must be a non-empty string' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractId = input.contractId;
|
|
||||||
return performAction((inst) => dispatchPlane(inst, planeId, contractId));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
||||||
|
// throwaway file (fresh module registry per test file → fresh singleton).
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (planeId: string, body: unknown) =>
|
||||||
|
POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
|
||||||
|
params: Promise.resolve({ planeId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/planes/:planeId/fly', () => {
|
||||||
|
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
|
||||||
|
for (const body of [{}, { destIcao: 42 }, { bogus: 1 }]) {
|
||||||
|
const res = await post('plane-starter', body);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
|
const res = await post('plane-starter', { destIcao: 'ZZZZ' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
|
const res = await post('ghost-plane', { destIcao: 'LEMD' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a flying plane with 409', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
await db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const res = await post('plane-starter', { destIcao: 'EDDM' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
|
||||||
|
|
||||||
|
// Ground it again for later tests.
|
||||||
|
await db.update(schema.planes)
|
||||||
|
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
||||||
|
const { db } = await getGameDb();
|
||||||
|
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
|
||||||
|
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const res = await post('plane-starter', { destIcao: 'LEMD' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||||
|
|
||||||
|
// Refill for later tests.
|
||||||
|
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
|
||||||
|
const res = await post('plane-starter', { destIcao: 'LEMD' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
|
||||||
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
|
'account',
|
||||||
|
'catalog',
|
||||||
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
|
'planes',
|
||||||
|
'sellFraction',
|
||||||
|
'simState',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('LEMD'); // destination set at fly time (v0.12.0)
|
||||||
|
expect(typeof plane.departureSimTime).toBe('number'); // departure set at fly time
|
||||||
|
expect(plane.locationIcao).toBe('EDDF'); // origin unchanged until arrival
|
||||||
|
|
||||||
|
// A second flight of the same (now flying) plane is rejected — it is no longer available.
|
||||||
|
const again = await post('plane-starter', { destIcao: 'EDDM' });
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { flyPlane, performAction } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/fly (v0.12.0) — body { destIcao: string }. Flies a
|
||||||
|
// GROUNDED plane to any known airport (its loaded packages' destinations or pure
|
||||||
|
// repositioning); the engine delivers bound cargo on arrival. 400 missing/
|
||||||
|
// non-string destIcao, unknown fields, or an ICAO not in the airport dataset;
|
||||||
|
// 404 unknown plane id; 409 plane flying or insufficient fuel for the leg. On
|
||||||
|
// success returns the full state snapshot (plane "flying" with departure set).
|
||||||
|
|
||||||
|
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) => flyPlane(inst, planeId, destIcao));
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
|
|||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
await db.update(schema.planes)
|
await db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: Date.now(), contractId: 'c-x' })
|
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
const res = await post('plane-starter');
|
const res = await post('plane-starter');
|
||||||
@@ -61,7 +61,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
|
|||||||
|
|
||||||
// Ground it again for later tests.
|
// Ground it again for later tests.
|
||||||
await db.update(schema.planes)
|
await db.update(schema.planes)
|
||||||
.set({ status: 'available', departureSimTime: null, contractId: null })
|
.set({ status: 'available', departureSimTime: null, destIcao: null })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ describe('GET /api/state', () => {
|
|||||||
expect(Object.keys(body).sort()).toEqual([
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
'account',
|
'account',
|
||||||
'catalog',
|
'catalog',
|
||||||
'contracts',
|
|
||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
@@ -48,15 +48,18 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const planes = body.planes as Array<Record<string, unknown>>;
|
const planes = body.planes as Array<Record<string, unknown>>;
|
||||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
||||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
expect(typeof planes[0].locationName).toBe('string');
|
expect(typeof planes[0].locationName).toBe('string');
|
||||||
|
// The seeded starter is grounded — no flight destination yet.
|
||||||
|
expect(planes[0].destIcao).toBeNull();
|
||||||
|
expect(planes[0].destName).toBeNull();
|
||||||
|
|
||||||
const contracts = body.contracts as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(contracts.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', 'deadlineSimTime', 'status']) {
|
||||||
expect(contracts[0], `contract.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
||||||
|
|||||||
@@ -2,23 +2,24 @@ import { randomInt, randomUUID } from 'node:crypto';
|
|||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { generateContract, mulberry32 } from '@/lib/game/seed';
|
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
// TEST-ONLY hook (v0.8.0) — lets the e2e suite create an open contract with a
|
// TEST-ONLY hook (v0.8.0, renamed create-contract → create-package in v0.12.0) —
|
||||||
// specified origin and/or destination (and optional deadline offset), so the full
|
// lets the e2e suite create an open package with a specified origin and/or
|
||||||
// accept → dispatch → fly loop can be exercised on a known, short route without
|
// destination (and optional deadline offset), so the full load → fly loop can be
|
||||||
// waiting for random spawns. Disabled unless FLIGHT_TEST_HOOKS=1 (set in
|
// exercised on a known, short route without waiting for random spawns. Disabled
|
||||||
// docker-compose.yml for the e2e instance only); every other deployment 404s here.
|
// unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance
|
||||||
// Sibling of /api/test/expire-contract — same gate, same conventions.
|
// only); every other deployment 404s here. Sibling of /api/test/expire-package —
|
||||||
|
// same gate, same conventions.
|
||||||
//
|
//
|
||||||
// POST /api/test/create-contract
|
// POST /api/test/create-package
|
||||||
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number }
|
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: 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 generateContract.
|
// 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-contract) — pass an explicit value to override.
|
// hook deadline of expire-package) — pass an explicit value to override.
|
||||||
// Returns 201 + the created contract row.
|
// Returns 201 + the created package row.
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
@@ -52,7 +53,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
const sim = await tickNow(); // clock is current before "now" for the new contract
|
const sim = await tickNow(); // clock is current before "now" for the new package
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const airports = fc.features.map((f) => ({
|
const airports = fc.features.map((f) => ({
|
||||||
@@ -70,7 +71,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
const created = generateContract({
|
const created = generatePackage({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
airports,
|
airports,
|
||||||
rng,
|
rng,
|
||||||
@@ -79,7 +80,7 @@ export async function POST(req: Request) {
|
|||||||
originIcao: input.originIcao as string | undefined,
|
originIcao: input.originIcao as string | undefined,
|
||||||
destIcao: input.destIcao as string | undefined,
|
destIcao: input.destIcao as string | undefined,
|
||||||
});
|
});
|
||||||
await inst.db.insert(schema.contracts).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(created, { status: 201 });
|
return Response.json(created, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3,18 +3,19 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { generateContract, mulberry32 } from '@/lib/game/seed';
|
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
// TEST-ONLY hook (v0.7.0) — lets the e2e suite observe contract expiry without
|
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite
|
||||||
// waiting hours of game time. Disabled unless FLIGHT_TEST_HOOKS=1, which is set in
|
// observe package expiry without waiting hours of game time. Disabled unless
|
||||||
// docker-compose.yml for the e2e instance only; every other deployment 404s here.
|
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance
|
||||||
|
// only; every other deployment 404s here.
|
||||||
//
|
//
|
||||||
// POST /api/test/expire-contract
|
// POST /api/test/expire-package
|
||||||
// body { contractId?: string }
|
// body { packageId?: string }
|
||||||
// - with contractId: moves that OPEN contract's deadline to now + HOOK_DEADLINE_MS
|
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS
|
||||||
// (game time) so the next fast tick expires it; 404 if unknown/not open.
|
// (game time) so the next fast tick expires it; 404 if unknown/not open.
|
||||||
// - without: creates a fresh open contract with the same short deadline.
|
// - without: creates a fresh open package with the same short deadline.
|
||||||
|
|
||||||
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (~0.2 s real at 60×)
|
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (~0.2 s real at 60×)
|
||||||
|
|
||||||
@@ -33,9 +34,9 @@ export async function POST(req: Request) {
|
|||||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const contractId = (body as Record<string, unknown>).contractId;
|
const packageId = (body as Record<string, unknown>).packageId;
|
||||||
if (contractId !== undefined && typeof contractId !== 'string') {
|
if (packageId !== undefined && typeof packageId !== 'string') {
|
||||||
return Response.json({ error: 'contractId must be a string' }, { status: 400 });
|
return Response.json({ error: 'packageId must be a string' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -43,15 +44,15 @@ export async function POST(req: Request) {
|
|||||||
const sim = await tickNow(); // clock is current before we set the deadline
|
const sim = await tickNow(); // clock is current before we set the deadline
|
||||||
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
|
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
|
||||||
|
|
||||||
if (typeof contractId === 'string') {
|
if (typeof packageId === 'string') {
|
||||||
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
|
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row || row.status !== 'open') {
|
if (!row || row.status !== 'open') {
|
||||||
return Response.json({ error: 'contract not found or not open' }, { status: 404 });
|
return Response.json({ error: 'package not found or not open' }, { status: 404 });
|
||||||
}
|
}
|
||||||
await inst.db.update(schema.contracts).set({ deadlineSimTime }).where(eq(schema.contracts.id, contractId));
|
await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
const updated = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)))[0];
|
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
|
||||||
return Response.json(updated ?? row);
|
return Response.json(updated ?? row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +64,8 @@ export async function POST(req: Request) {
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
const created = generateContract({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
|
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
|
||||||
await inst.db.insert(schema.contracts).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(created, { status: 201 });
|
return Response.json(created, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -330,3 +330,17 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.12.0 — on-board blocks (per plane) in the right pane */
|
||||||
|
.onboard-block {
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboard-block .contract-row {
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 0;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
+15
-7
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
|
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
@@ -19,14 +19,21 @@ interface LeftPaneProps {
|
|||||||
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one doubling per keyboard-arrow step
|
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one doubling per keyboard-arrow step
|
||||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
function planeLocation(p: PlaneState, contracts: ContractState[]): string {
|
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
||||||
if (p.status === 'flying' && p.contractId) {
|
function planeLocation(p: PlaneState): string {
|
||||||
const c = contracts.find((c) => c.id === p.contractId);
|
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
||||||
if (c) return `${p.locationName} → ${c.destName}`;
|
|
||||||
}
|
|
||||||
return p.locationName;
|
return p.locationName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot.
|
||||||
|
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
||||||
|
const used = state.packages.filter(
|
||||||
|
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||||
|
).length;
|
||||||
|
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
|
||||||
|
return `${used}/${capacity} pkgs`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -156,7 +163,8 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
|||||||
</div>
|
</div>
|
||||||
<div className="plane-sub">
|
<div className="plane-sub">
|
||||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||||
<span className="plane-loc">{planeLocation(p, state.contracts)}</span>
|
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+5
-19
@@ -20,13 +20,13 @@ export const MAX_ZOOM = 11;
|
|||||||
|
|
||||||
const AIRPORTS_SOURCE_ID = 'airports';
|
const AIRPORTS_SOURCE_ID = 'airports';
|
||||||
const AIRPORTS_LAYER_ID = 'airports-circles';
|
const AIRPORTS_LAYER_ID = 'airports-circles';
|
||||||
// Dynamic game-state layers (v0.11.0): planes as circles, active contract routes as lines.
|
// Dynamic game-state layers (v0.11.0, package model v0.12.0): planes as circles,
|
||||||
|
// each FLYING plane's current leg (origin → dest_icao) as a line.
|
||||||
const PLANES_SOURCE_ID = 'planes';
|
const PLANES_SOURCE_ID = 'planes';
|
||||||
const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
|
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_PENDING_LAYER_ID = 'routes-pending';
|
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
||||||
const ROUTES_INFLIGHT_LAYER_ID = 'routes-inflight';
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -198,25 +198,11 @@ export default function FlightMap({ state }: MapProps) {
|
|||||||
data: routesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()),
|
data: routesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!map.getLayer(ROUTES_PENDING_LAYER_ID)) {
|
if (!map.getLayer(ROUTES_LEGS_LAYER_ID)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: ROUTES_PENDING_LAYER_ID,
|
id: ROUTES_LEGS_LAYER_ID,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: ROUTES_SOURCE_ID,
|
source: ROUTES_SOURCE_ID,
|
||||||
filter: ['==', ['get', 'kind'], 'pending'],
|
|
||||||
paint: {
|
|
||||||
'line-color': '#94a3b8', // muted slate — accepted but not yet dispatched
|
|
||||||
'line-width': 2,
|
|
||||||
'line-dasharray': [2, 2],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!map.getLayer(ROUTES_INFLIGHT_LAYER_ID)) {
|
|
||||||
map.addLayer({
|
|
||||||
id: ROUTES_INFLIGHT_LAYER_ID,
|
|
||||||
type: 'line',
|
|
||||||
source: ROUTES_SOURCE_ID,
|
|
||||||
filter: ['==', ['get', 'kind'], 'inflight'],
|
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#f59e0b', // bright amber — actively flying the leg
|
'line-color': '#f59e0b', // bright amber — actively flying the leg
|
||||||
'line-width': 3,
|
'line-width': 3,
|
||||||
|
|||||||
+159
-102
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
||||||
|
|
||||||
interface RightPaneProps {
|
interface RightPaneProps {
|
||||||
@@ -15,75 +16,85 @@ 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;
|
||||||
|
|
||||||
// Why a plane cannot be dispatched on `contract` — null when it can (grounded at the origin).
|
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which
|
||||||
function dispatchReason(p: PlaneState, contract: ContractState): string | null {
|
// keeps its slot until it is abandoned or the plane lands at its destination.
|
||||||
if (p.status !== 'available') return 'in flight';
|
export function slotsUsedFor(state: StateSnapshot, planeId: string): number {
|
||||||
if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`;
|
return state.packages.filter(
|
||||||
return null;
|
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'),
|
||||||
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plane a dispatch picker should act on for one accepted contract: the user's explicit pick
|
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a
|
||||||
// while it stays eligible, else NOTHING. v0.10.1 removed the "auto-track first eligible" fallback —
|
// free slot (capacity from the snapshot catalog). The Load button is enabled iff
|
||||||
// it made every row display the same plane by default (selections looked shared across contracts)
|
// this list is non-empty; exactly one entry loads directly, several show a picker.
|
||||||
// and left no way to show "nothing chosen". null = placeholder shown + Dispatch disabled; a poll
|
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
|
||||||
// that makes the pick ineligible (plane flew / moved away) drops back to the placeholder too.
|
return state.planes.filter((p) => {
|
||||||
export function dispatchSelection(
|
if (p.status !== 'available') return false;
|
||||||
planes: PlaneState[],
|
if (p.locationIcao !== pkg.originIcao) return false;
|
||||||
contract: ContractState,
|
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0;
|
||||||
selectedId: string | null,
|
return slotsUsedFor(state, p.id) < capacity;
|
||||||
): PlaneState | null {
|
});
|
||||||
if (selectedId === null) return null;
|
|
||||||
return planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
|
// Load controls for one open package: a single Load button when exactly one plane
|
||||||
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
|
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||||
// button bound to the selection. Each row owns its selection in local state and defaults to the
|
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||||
// "Select a plane…" placeholder — never preselecting — so rows stay visibly independent and a
|
// selection in local state and defaults to the "Select a plane…" placeholder —
|
||||||
// dispatch can't fire on the wrong contract by accident.
|
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
|
||||||
function DispatchControls({
|
function LoadControls({
|
||||||
contract,
|
pkg,
|
||||||
planes,
|
eligible,
|
||||||
busy,
|
busy,
|
||||||
onDispatch,
|
onLoad,
|
||||||
}: {
|
}: {
|
||||||
contract: ContractState;
|
pkg: PackageState;
|
||||||
planes: PlaneState[];
|
eligible: PlaneState[];
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onDispatch: (planeId: string) => void;
|
onLoad: (planeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const effective = dispatchSelection(planes, contract, selectedId);
|
// The user's pick while it stays eligible, else nothing (placeholder).
|
||||||
|
const effective = eligible.find((p) => p.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
if (eligible.length === 1) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`load-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onLoad(eligible[0].id)}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dispatch-controls">
|
<div className="dispatch-controls">
|
||||||
<select
|
<select
|
||||||
data-testid={`dispatch-plane-${contract.id}`}
|
data-testid={`load-plane-${pkg.id}`}
|
||||||
aria-label={`Dispatch plane for ${contract.originName} to ${contract.destName}`}
|
aria-label={`Load plane for ${pkg.originName} to ${pkg.destName}`}
|
||||||
value={effective?.id ?? ''}
|
value={effective?.id ?? ''}
|
||||||
onChange={(e) => setSelectedId(e.target.value || null)}
|
onChange={(e) => setSelectedId(e.target.value || null)}
|
||||||
>
|
>
|
||||||
<option value="">Select a plane…</option>
|
<option value="">Select a plane…</option>
|
||||||
{planes.map((p) => {
|
{eligible.map((p) => (
|
||||||
const reason = dispatchReason(p, contract);
|
<option key={p.id} value={p.id}>
|
||||||
return (
|
{planeName(p)} — {p.locationIcao}
|
||||||
<option key={p.id} value={p.id} disabled={reason !== null}>
|
</option>
|
||||||
{planeName(p)}
|
))}
|
||||||
{reason ? ` — ${reason}` : ''}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`dispatch-${contract.id}`}
|
data-testid={`load-${pkg.id}`}
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={busy || effective === null}
|
disabled={busy || effective === null}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (effective) onDispatch(effective.id);
|
if (effective) onLoad(effective.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Dispatch
|
Load
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -98,7 +109,7 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
|
|||||||
if (state !== null) setActionError(null);
|
if (state !== null) setActionError(null);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
// Market picker selections — same "effective selection" pattern as DispatchControls: the
|
// Market picker selections — same "effective selection" pattern as LoadControls: the
|
||||||
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
|
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
|
||||||
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
||||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||||
@@ -143,8 +154,14 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
|
|||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|
||||||
const clockMs = state.simState.clockMs;
|
const clockMs = state.simState.clockMs;
|
||||||
const open = state.contracts.filter((c) => c.status === 'open');
|
const open = state.packages.filter((p) => p.status === 'open');
|
||||||
const accepted = state.contracts.filter((c) => c.status === 'accepted');
|
|
||||||
|
// On board (v0.12.0): every plane that carries cargo or is mid-flight gets a block —
|
||||||
|
// its loaded/expired packages with fly-to-destination + abandon controls, and flying
|
||||||
|
// planes show their current leg.
|
||||||
|
const onboard = state.planes.filter(
|
||||||
|
(p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const catalog = state.catalog;
|
const catalog = state.catalog;
|
||||||
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
|
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
|
||||||
@@ -167,70 +184,110 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
|
|||||||
<h2 className="pane-title">Jobs & Market</h2>
|
<h2 className="pane-title">Jobs & Market</h2>
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Open contracts</h3>
|
<h3 className="pane-heading">Packages</h3>
|
||||||
{open.length === 0 ? (
|
{open.length === 0 ? (
|
||||||
<p className="pane-muted">No open contracts.</p>
|
<p className="pane-muted">No open packages.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="open-contracts" className="contract-list">
|
<ul data-testid="open-packages" className="contract-list">
|
||||||
{open.map((c) => (
|
{open.map((pkg) => {
|
||||||
<li key={c.id} data-testid={`open-contract-${c.id}`} className="contract-row">
|
const eligible = loadEligiblePlanes(state, pkg);
|
||||||
<div className="contract-main">
|
return (
|
||||||
<span className="contract-route">{c.originName} → {c.destName}</span>
|
<li key={pkg.id} data-testid={`open-package-${pkg.id}`} className="contract-row">
|
||||||
<button
|
<div className="contract-main">
|
||||||
type="button"
|
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||||
data-testid={`accept-${c.id}`}
|
{eligible.length > 0 ? (
|
||||||
className="pane-btn"
|
<LoadControls
|
||||||
disabled={busy}
|
pkg={pkg}
|
||||||
onClick={() => void runAction(`/api/contracts/${c.id}/accept`)}
|
eligible={eligible}
|
||||||
>
|
busy={busy}
|
||||||
Accept
|
onLoad={(planeId) => void runAction(`/api/packages/${pkg.id}/load`, { planeId })}
|
||||||
</button>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<div className="contract-sub">
|
<button type="button" data-testid={`load-${pkg.id}`} className="pane-btn" disabled>
|
||||||
<span data-testid={`open-contract-reward-${c.id}`}>{formatCash(c.reward)}</span>
|
Load
|
||||||
<span>{c.cargoType} × {c.cargoCount}</span>
|
</button>
|
||||||
<span data-testid={`open-contract-deadline-${c.id}`}>
|
)}
|
||||||
{formatDeadlineRelative(c.deadlineSimTime, clockMs)}
|
</div>
|
||||||
</span>
|
<div className="contract-sub">
|
||||||
</div>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
</li>
|
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
|
||||||
))}
|
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||||
|
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Accepted</h3>
|
<h3 className="pane-heading">On board</h3>
|
||||||
{accepted.length === 0 ? (
|
{onboard.length === 0 ? (
|
||||||
<p className="pane-muted">Nothing accepted yet.</p>
|
<p className="pane-muted">Nothing on board.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="accepted-contracts" className="contract-list">
|
onboard.map((plane) => {
|
||||||
{accepted.map((c) => (
|
const cargo = state.packages.filter(
|
||||||
<li key={c.id} data-testid={`accepted-contract-${c.id}`} className="contract-row">
|
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={plane.id} data-testid={`onboard-${plane.id}`} className="onboard-block">
|
||||||
<div className="contract-main">
|
<div className="contract-main">
|
||||||
<span className="contract-route">{c.originName} → {c.destName}</span>
|
<span className="plane-model">{planeName(plane)}</span>
|
||||||
{c.planeId ? (
|
{plane.status === 'flying' ? (
|
||||||
<span data-testid={`contract-inflight-${c.id}`} className="plane-status status-flying">
|
<>
|
||||||
in flight
|
<span className="plane-status status-flying">in flight</span>
|
||||||
</span>
|
{plane.destName && (
|
||||||
) : null}
|
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
|
||||||
|
{plane.locationName} → {plane.destName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="plane-status status-available">{plane.locationIcao}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
{cargo.map((pkg) => {
|
||||||
<span>{formatCash(c.reward)}</span>
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
<span>{c.cargoType} × {c.cargoCount}</span>
|
return (
|
||||||
<span>{formatDeadlineRelative(c.deadlineSimTime, clockMs)}</span>
|
<div key={pkg.id} data-testid={`onboard-package-${pkg.id}`} className="contract-row">
|
||||||
</div>
|
<div className="contract-main">
|
||||||
{!c.planeId && (
|
<button
|
||||||
<DispatchControls
|
type="button"
|
||||||
contract={c}
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
planes={state.planes}
|
className="pane-btn"
|
||||||
busy={busy}
|
disabled={busy || plane.status !== 'available'}
|
||||||
onDispatch={(planeId) => void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })}
|
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
|
||||||
/>
|
>
|
||||||
)}
|
Fly to {pkg.destName}
|
||||||
</li>
|
</button>
|
||||||
))}
|
<button
|
||||||
</ul>
|
type="button"
|
||||||
|
data-testid={`abandon-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={busy || plane.status !== 'available'}
|
||||||
|
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||||
|
>
|
||||||
|
Abandon{' '}
|
||||||
|
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="contract-sub">
|
||||||
|
<span>{pkg.originName} → {pkg.destName}</span>
|
||||||
|
<span>{formatCash(pkg.reward)}</span>
|
||||||
|
{pkg.status === 'expired' ? (
|
||||||
|
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||||
|
) : (
|
||||||
|
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { dispatchSelection } from './RightPane';
|
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
|
||||||
import type { ContractState, PlaneState } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
// v0.10.1 — the per-contract dispatch picker rule: a row's effective selection is the user's
|
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
|
||||||
// explicit pick while it stays eligible (available AND at the contract origin), else NOTHING
|
// it is GROUNDED at the package's origin with a free slot (capacity from the
|
||||||
// (placeholder). The old "auto-track first eligible" fallback made every accepted-contract row
|
// snapshot catalog; expired packages still occupy theirs). The Load button acts
|
||||||
// display the same plane, so selections looked shared across contracts.
|
// directly on the single eligible plane, or shows a picker when several qualify.
|
||||||
|
|
||||||
const contract: ContractState = {
|
const CATALOG = [
|
||||||
id: 'contract-a',
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
|
||||||
originIcao: 'EDDF',
|
];
|
||||||
originName: 'Frankfurt Main',
|
|
||||||
destIcao: 'EDDM',
|
|
||||||
destName: 'Munich',
|
|
||||||
reward: 1000,
|
|
||||||
cargoType: 'mail',
|
|
||||||
cargoCount: 4,
|
|
||||||
deadlineSimTime: 1_000_000,
|
|
||||||
status: 'accepted',
|
|
||||||
planeId: null,
|
|
||||||
createdSimTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||||
return {
|
return {
|
||||||
@@ -30,7 +19,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
maxFuel: 100,
|
maxFuel: 100,
|
||||||
locationIcao: 'EDDF',
|
locationIcao: 'EDDF',
|
||||||
locationName: 'Frankfurt Main',
|
locationName: 'Frankfurt Main',
|
||||||
contractId: null,
|
destIcao: null,
|
||||||
|
destName: null,
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50,
|
lat: 50,
|
||||||
lon: 8.5,
|
lon: 8.5,
|
||||||
@@ -38,29 +28,93 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('dispatchSelection (per-contract dispatch picker rule)', () => {
|
function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageState {
|
||||||
it('defaults to the placeholder — no auto-selection of the first eligible plane', () => {
|
return {
|
||||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2' })]; // both eligible at EDDF
|
originIcao: 'EDDF',
|
||||||
expect(dispatchSelection(planes, contract, null)).toBeNull();
|
originName: 'Frankfurt Main',
|
||||||
|
destIcao: 'LEMD',
|
||||||
|
destName: 'Madrid Barajas',
|
||||||
|
reward: 1000,
|
||||||
|
cargoType: 'mail',
|
||||||
|
cargoCount: 4,
|
||||||
|
deadlineSimTime: 1_000_000,
|
||||||
|
status: 'open',
|
||||||
|
planeId: null,
|
||||||
|
createdSimTime: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
||||||
|
return {
|
||||||
|
account: { cash: 0, reputation: 0 },
|
||||||
|
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||||
|
planes,
|
||||||
|
packages,
|
||||||
|
catalog: CATALOG,
|
||||||
|
fuelPricePerL: 0.8,
|
||||||
|
sellFraction: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('slotsUsedFor', () => {
|
||||||
|
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
|
||||||
|
const s = snap(
|
||||||
|
[plane({ id: 'p1' })],
|
||||||
|
[
|
||||||
|
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
|
||||||
|
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
|
||||||
|
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
|
||||||
|
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(slotsUsedFor(s, 'p1')).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the user pick while it stays eligible (available AND at the origin)', () => {
|
it('is 0 for a plane with no bound cargo', () => {
|
||||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2', locationIcao: 'EDDM' })];
|
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
||||||
expect(dispatchSelection(planes, contract, 'p1')?.id).toBe('p1');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('drops to the placeholder when a poll makes the pick ineligible (flew away)', () => {
|
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
||||||
const planes = [plane({ id: 'p1', status: 'flying' })];
|
it('includes only grounded planes at the package origin with a free slot', () => {
|
||||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
const s = snap([
|
||||||
|
plane({ id: 'at-origin' }), // eligible
|
||||||
|
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
||||||
|
plane({ id: 'in-flight', status: 'flying', destIcao: 'LEMD', destName: 'Madrid Barajas' }), // flying
|
||||||
|
]);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops to the placeholder when a poll moves the pick off the origin', () => {
|
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
|
||||||
const planes = [plane({ id: 'p1', locationIcao: 'EDDM' })];
|
const packages = Array.from({ length: 6 }, (_, i) =>
|
||||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
|
||||||
|
);
|
||||||
|
const s = snap([plane({ id: 'full' })], packages);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unknown selection id resolves to the placeholder', () => {
|
it('a full plane with one slot freed by an expired package still counts as full', () => {
|
||||||
const planes = [plane({ id: 'p1' })];
|
const packages = [
|
||||||
expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull();
|
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
|
||||||
|
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
|
||||||
|
];
|
||||||
|
const s = snap([plane({ id: 'full' })], packages);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns several planes when several qualify (picker case)', () => {
|
||||||
|
const s = snap([plane({ id: 'a' }), plane({ id: 'b' })]);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when no plane is at the origin (Load stays disabled)', () => {
|
||||||
|
const s = snap([plane({ id: 'far', locationIcao: 'LEMD' })]);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plane with an unknown model has no capacity → never eligible', () => {
|
||||||
|
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
||||||
|
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -9,8 +9,9 @@ services:
|
|||||||
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
|
# Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv).
|
||||||
- "./db:/app/db"
|
- "./db:/app/db"
|
||||||
environment:
|
environment:
|
||||||
# e2e-only affordance (v0.7.0): enables POST /api/test/expire-contract so the
|
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
||||||
# sim suite can force a near-term contract expiry; 404 without this var.
|
# /api/test/expire-package so the suites can force known routes/expiries;
|
||||||
|
# 404 without this var.
|
||||||
FLIGHT_TEST_HOOKS: "1"
|
FLIGHT_TEST_HOOKS: "1"
|
||||||
networks:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
|
|||||||
+314
-198
@@ -1,239 +1,355 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import type { Page, Request } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// v0.8.0 player actions against the live origin: THE full core loop — accept a
|
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
||||||
// contract → dispatch plane-starter → watch it fly at scale 60 → arrival pays the
|
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
||||||
// exact reward + reputation and grounds the plane with reduced fuel — plus the
|
// via the right pane → click its destination in the on-board list → fly at 60× →
|
||||||
// refuel cost check (folded into the loop, where the tank is genuinely partial)
|
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
|
||||||
// and the "dispatch without accept" 4xx path. Contracts are created on a known
|
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
||||||
// short route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1 in
|
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||||
// docker-compose.yml), with the destination picked as the closest airport to EDDF
|
|
||||||
// from /api/airports so arrival is fast at scale 60.
|
|
||||||
|
|
||||||
const SCALE = 60;
|
const SCALE = 60; // fastest sim speed — keeps flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
// Mirrors of server constants — kept in sync manually (same pattern as sim.spec.ts).
|
const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable
|
||||||
const BURN_PER_KM = 1.75; // atr72, lib/game/catalog.ts
|
|
||||||
const FUEL_PRICE_PER_L = 0.8; // lib/game/catalog.ts
|
// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72).
|
||||||
|
const BURN_PER_KM = 1.75;
|
||||||
|
const FUEL_PRICE_PER_L = 0.8;
|
||||||
const REP_BASE = 1; // lib/sim/engine.ts
|
const REP_BASE = 1; // lib/sim/engine.ts
|
||||||
const REP_PER_KM = 0.0005;
|
const REP_PER_KM = 0.0005;
|
||||||
|
|
||||||
interface PlaneState {
|
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
|
||||||
id: string;
|
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||||
status: 'available' | 'flying';
|
const span = deadlineSimTime - createdSimTime;
|
||||||
fuel: number;
|
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
|
||||||
maxFuel: number;
|
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||||
locationIcao: string;
|
|
||||||
departureSimTime: number | null;
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContractState {
|
|
||||||
id: string;
|
|
||||||
originIcao: string;
|
|
||||||
destIcao: string;
|
|
||||||
reward: number;
|
|
||||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
|
||||||
planeId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
planes: PlaneState[];
|
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||||
contracts: ContractState[];
|
packages: Array<{
|
||||||
}
|
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
||||||
|
deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number;
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
}>;
|
||||||
|
|
||||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
|
||||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
||||||
const dLat = toRad(lat2 - lat1);
|
|
||||||
const dLon = toRad(lon2 - lon1);
|
|
||||||
const a =
|
|
||||||
Math.sin(dLat / 2) ** 2 +
|
|
||||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
||||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
const res = await page.request.get('/api/state');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.ok()).toBeTruthy();
|
||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
||||||
return page.request.post('/api/sim', { data: body });
|
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
|
||||||
|
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()) as StateBody['packages'][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||||
test.afterEach(async ({ page }) => {
|
const res = await page.request.post('/api/sim', { data: body });
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||||
|
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
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);
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
|
|
||||||
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
|
||||||
const created = await page.request.post('/api/test/create-contract', {
|
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
|
||||||
});
|
|
||||||
expect(created.status()).toBe(201);
|
|
||||||
return (await created.json()) as ContractState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
||||||
// scale 60 until the plane lands at `to`. Used for the main flight and for
|
// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts.
|
||||||
// restoring the starter plane to EDDF between runs.
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
const res = await page.request.get('/api/airports');
|
||||||
const contract = await createContract(page, fromIcao, toIcao);
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
||||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
|
||||||
expect(acc.status()).toBe(200);
|
|
||||||
|
|
||||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
|
||||||
expect(disp.status()).toBe(200);
|
|
||||||
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const body = await getState(page);
|
|
||||||
const p = body.planes.find((x) => x.id === 'plane-starter');
|
|
||||||
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
|
||||||
}, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
return contract;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('full core loop: accept → dispatch → fly at 60x → exact reward + rep on arrival', async ({ page }) => {
|
|
||||||
test.setTimeout(240_000); // generous cap — typical run is well under two minutes
|
|
||||||
|
|
||||||
// Self-heal first: a previous interrupted run may have left the starter plane
|
|
||||||
// mid-flight or at another airport. Let any flight finish, then fly it home so
|
|
||||||
// the loop below starts from the canonical onboarding state (plane at EDDF).
|
|
||||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
|
||||||
expect(foundStarter).toBeDefined();
|
|
||||||
let starter: PlaneState = foundStarter!;
|
|
||||||
if (starter.status === 'flying') {
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
|
||||||
timeout: 90_000,
|
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
starter = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
}
|
|
||||||
if (starter.locationIcao !== HUB) {
|
|
||||||
await flyContract(page, starter.locationIcao, HUB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baseline AFTER any healing flight — the exact-cash assertion below must not
|
|
||||||
// include heal rewards.
|
|
||||||
const before = await getState(page);
|
|
||||||
|
|
||||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time.
|
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
|
||||||
};
|
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||||
for (const f of fc.features) {
|
for (const f of fc.features) {
|
||||||
if (f.properties.icao === HUB) continue;
|
if (f.properties.icao === HUB) continue;
|
||||||
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
||||||
nearest = f.properties;
|
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
||||||
|
if (d < dn) nearest = f.properties;
|
||||||
|
}
|
||||||
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
|
||||||
|
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await postSim(page, { timeScale: 1, paused: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
// Self-heal helper: let any in-flight leg finish, reposition plane-starter home via the API
|
||||||
|
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||||
|
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||||
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const plane = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (plane.status === 'flying') {
|
||||||
|
// Let the in-flight leg finish before repositioning.
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||||||
|
}
|
||||||
|
if (plane.locationIcao !== HUB) {
|
||||||
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
|
}
|
||||||
|
// Abandon leftover loaded/expired packages (expired ones cost nothing; loaded ones take a
|
||||||
|
// penalty — irrelevant, the baseline snapshot 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const km = haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon);
|
}
|
||||||
|
|
||||||
// 1) Accept the hook-created contract — response is the full snapshot.
|
|
||||||
const contract = await createContract(page, HUB, nearest.icao);
|
|
||||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
|
||||||
expect(acc.status()).toBe(200);
|
|
||||||
let snap = (await acc.json()) as StateBody;
|
|
||||||
expect(snap.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
|
|
||||||
|
|
||||||
// 2) Dispatch plane-starter — snapshot shows it flying with departure set.
|
|
||||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
|
||||||
expect(disp.status()).toBe(200);
|
|
||||||
snap = (await disp.json()) as StateBody;
|
|
||||||
let plane = snap.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(plane.status).toBe('flying');
|
|
||||||
expect(typeof plane.departureSimTime).toBe('number'); // departure set at dispatch time
|
|
||||||
const departureFuel = plane.fuel; // frozen while flying (live fuel is analytic)
|
|
||||||
|
|
||||||
// Position moves away from the origin across polls at scale 60.
|
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
|
||||||
await postSim(page, { timeScale: SCALE });
|
test.setTimeout(300_000); // self-heal (worst case ~3 min at 60×) + two short flights + refuel
|
||||||
let prevDist = -1;
|
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
||||||
for (let i = 0; i < 3; i++) {
|
await flyHomeViaApi(page);
|
||||||
await sleep(700);
|
const before = await getState(page);
|
||||||
snap = await getState(page);
|
expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB);
|
||||||
plane = snap.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
const distFromOrigin = haversineKm(hub.lat, hub.lon, plane.lat, plane.lon);
|
// Nearest airport to the hub → shortest possible flight (fastest e2e loop).
|
||||||
expect(distFromOrigin).toBeGreaterThan(prevDist); // strictly along the arc
|
const nearest = await nearestToHub(page);
|
||||||
prevDist = distFromOrigin;
|
|
||||||
|
// A known short package at the hub with a generous deadline.
|
||||||
|
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||||
|
expect(pkg.originIcao).toBe(HUB);
|
||||||
|
expect(pkg.destIcao).toBe(nearest.icao);
|
||||||
|
|
||||||
|
// LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button.
|
||||||
|
await page.goto('/');
|
||||||
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
|
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled();
|
||||||
|
await loadBtn.click();
|
||||||
|
|
||||||
|
// The action endpoint's full snapshot is applied immediately (applySnapshot) — the
|
||||||
|
// package shows as loaded on the starter in the on-board section.
|
||||||
|
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||||
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||||
|
|
||||||
|
// FLY via the UI: click the destination name button in the on-board list.
|
||||||
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
await expect(flyBtn).toBeEnabled();
|
||||||
|
await flyBtn.click();
|
||||||
|
|
||||||
|
let s = await getState(page);
|
||||||
|
const planeId = 'plane-starter';
|
||||||
|
expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying');
|
||||||
|
expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao);
|
||||||
|
|
||||||
|
// Fast-forward to arrival (≤ ~10 min game time at 60× → seconds of real time).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.status), { timeout: 120_000 }).toBe('available');
|
||||||
|
|
||||||
|
s = await getState(page);
|
||||||
|
const landed = s.planes.find((p) => p.id === planeId)!;
|
||||||
|
expect(landed.locationIcao).toBe(nearest.icao); // arrived at the package's destination
|
||||||
|
expect(landed.destIcao).toBeNull(); // flight context cleared on landing
|
||||||
|
|
||||||
|
// EXACT reward: cash increased by exactly the package's payout…
|
||||||
|
const delivered = s.packages.find((p) => p.id === pkg.id)!;
|
||||||
|
expect(delivered.status).toBe('delivered');
|
||||||
|
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward, 6);
|
||||||
|
|
||||||
|
// …and rep gained per the engine formula (great-circle km of the leg just flown),
|
||||||
|
// mirroring lib/sim/engine.ts's double rounding (gain rounded to cents, then total).
|
||||||
|
const exactKm = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||||
|
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
||||||
|
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
||||||
|
|
||||||
|
// Fuel burned exactly burnPerKm × km (analytic movement — no rounding drift).
|
||||||
|
expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6);
|
||||||
|
|
||||||
|
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
|
||||||
|
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
|
||||||
|
expect(refuelRes.ok()).toBeTruthy();
|
||||||
|
s = await getState(page);
|
||||||
|
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
|
||||||
|
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
|
||||||
|
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||||
|
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
|
||||||
|
|
||||||
|
// Restore: fly home (repositioning — no package needed in the v0.12.0 model).
|
||||||
|
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abandon while grounded deducts exactly the formula penalty', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
const before = await getState(page);
|
||||||
|
|
||||||
|
// Two packages at the hub — load both, abandon one through the UI.
|
||||||
|
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
||||||
|
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
||||||
|
for (const p of [pkgA, pkgB]) {
|
||||||
|
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Poll until arrival (plane grounded at the destination).
|
// Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked
|
||||||
let arrived: StateBody | undefined;
|
// against the exact game clock the server used (a later GET would tick the clock further).
|
||||||
await expect
|
await page.goto('/');
|
||||||
.poll(async () => {
|
const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`);
|
||||||
snap = await getState(page);
|
await expect(abandonBtn).toBeEnabled();
|
||||||
const p = snap.planes.find((x) => x.id === 'plane-starter');
|
const [res] = await Promise.all([
|
||||||
if (p && p.status === 'available' && p.locationIcao === nearest.icao) arrived = snap;
|
page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }),
|
||||||
return !!arrived;
|
abandonBtn.click(),
|
||||||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
]);
|
||||||
.toBe(true);
|
expect(res.status()).toBe(200);
|
||||||
|
const s = (await res.json()) as StateBody; // full post-action snapshot at the penalty's clock
|
||||||
|
|
||||||
// 4) Exact reward + reputation gain; plane grounded with the analytic remaining fuel.
|
const expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs);
|
||||||
const after = arrived!;
|
expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor
|
||||||
expect(after.account.cash).toBeCloseTo(before.account.cash + contract.reward, 6);
|
const abandoned = s.packages.find((p) => p.id === pkgA.id)!;
|
||||||
|
expect(abandoned.status).toBe('abandoned');
|
||||||
|
expect(abandoned.planeId).toBeNull(); // slot freed
|
||||||
|
expect(s.account.cash).toBeCloseTo(before.account.cash - expectedPenalty, 6);
|
||||||
|
|
||||||
const repGain = Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; // mirrors reputationGainFor
|
// B is still on board — abandon it via the API to leave a clean state.
|
||||||
expect(after.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 6);
|
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
|
||||||
|
expect(resB.ok()).toBeTruthy();
|
||||||
plane = after.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(plane.status).toBe('available');
|
|
||||||
expect(plane.locationIcao).toBe(nearest.icao);
|
|
||||||
expect(plane.departureSimTime).toBeNull(); // flight context cleared on landing
|
|
||||||
expect(plane.fuel).toBeCloseTo(Math.max(0, departureFuel - BURN_PER_KM * km), 3);
|
|
||||||
|
|
||||||
const done = after.contracts.find((c) => c.id === contract.id)!;
|
|
||||||
expect(done.status).toBe('completed');
|
|
||||||
expect(done.planeId).toBe('plane-starter');
|
|
||||||
|
|
||||||
// 5) Refuel at the destination: cost is exactly (maxFuel − fuel) × FUEL_PRICE_PER_L.
|
|
||||||
const cashBeforeRefuel = after.account.cash;
|
|
||||||
const expectedCost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
expect(expectedCost).toBeGreaterThan(0); // the flight genuinely burned fuel
|
|
||||||
|
|
||||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
|
||||||
expect(refuel.status()).toBe(200);
|
|
||||||
const afterRefuel = (await refuel.json()) as StateBody;
|
|
||||||
expect(afterRefuel.account.cash).toBeCloseTo(cashBeforeRefuel - expectedCost, 6);
|
|
||||||
expect(afterRefuel.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(plane.maxFuel);
|
|
||||||
|
|
||||||
// 6) Fly home so the live instance stays in its onboarding state for the next run.
|
|
||||||
await flyContract(page, nearest.icao, HUB);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatch without accept is rejected with 409; unknown contract id with 404', async ({ page }) => {
|
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||||
const body = await getState(page);
|
test.setTimeout(480_000); // two ~3 min real-time legs at 60× (HUB↔LEMD) plus expiry wait
|
||||||
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
await flyHomeViaApi(page);
|
||||||
|
const before = await getState(page);
|
||||||
|
|
||||||
// A contract originating where the plane actually sits — so ONLY the missing
|
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||||
// accept is what blocks dispatch.
|
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
||||||
const created = await page.request.post('/api/test/create-contract', {
|
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
|
||||||
data: { originIcao: starter.locationIcao, destIcao: HUB === starter.locationIcao ? 'LEMD' : HUB },
|
// still be abandoned (an expired package whose dest matches the arrival airport is
|
||||||
});
|
// unbound by the tick instead).
|
||||||
expect(created.status()).toBe(201);
|
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
|
||||||
const contract = (await created.json()) as ContractState;
|
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
|
||||||
|
for (const p of [pkgA, pkgB]) {
|
||||||
|
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
const noAccept = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||||
expect(noAccept.status()).toBe(409);
|
await setScale(page, SCALE);
|
||||||
expect(((await noAccept.json()) as { error: string }).error).toMatch(/open/);
|
let s = await getState(page);
|
||||||
|
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||||
|
s = await getState(page);
|
||||||
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
||||||
|
|
||||||
const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } });
|
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
||||||
expect(unknown.status()).toBe(404);
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
||||||
|
expect(flyRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||||||
|
|
||||||
|
s = await getState(page);
|
||||||
|
const landedA = s.packages.find((p) => p.id === pkgA.id)!;
|
||||||
|
expect(landedA.status).toBe('expired'); // still expired — never delivered…
|
||||||
|
expect(landedA.planeId).toBeNull(); // …but the slot was freed on arrival
|
||||||
|
expect(s.account.cash).toBe(before.account.cash); // NO reward for expired cargo
|
||||||
|
expect(s.account.reputation).toBe(before.account.reputation); // no rep either
|
||||||
|
|
||||||
|
// B: abandon an expired package while grounded — penalty is exactly €0.
|
||||||
|
const cashBeforeAbandon = s.account.cash;
|
||||||
|
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
|
||||||
|
expect(resB.ok()).toBeTruthy();
|
||||||
|
s = await getState(page);
|
||||||
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
|
// Restore: refuel + fly home (scale is still SCALE from the expiry wait above).
|
||||||
|
await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||||
|
test.setTimeout(600_000); // the FAR round trip alone is ~7.5 min real time at 60×
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Load a package whose origin the plane is not at → 409.
|
||||||
|
const pkg = await createPackage(page, 'LEMD', HUB, 24 * 3_600_000); // originates in Madrid, plane sits in Frankfurt
|
||||||
|
let res = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(res.status()).toBe(409);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
|
||||||
|
|
||||||
|
// Load an unknown package → 404.
|
||||||
|
res = await page.request.post('/api/packages/no-such-package/load', { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
|
||||||
|
// Fly to an ICAO not in the dataset → 400.
|
||||||
|
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'ZZZZ' } });
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
|
||||||
|
|
||||||
|
// Fly while already flying → 409 (short hop leg on the nearest route, then let it finish).
|
||||||
|
const hopDest = await nearestToHub(page);
|
||||||
|
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: hopDest.icao } });
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const again = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(again.status()).toBe(409);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(hopDest.icao);
|
||||||
|
|
||||||
|
// Abandon an open package → 409 (nothing loaded to abandon).
|
||||||
|
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(res.status()).toBe(409);
|
||||||
|
|
||||||
|
// Fly home from the hop so the fuel section starts at the hub.
|
||||||
|
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
||||||
|
|
||||||
|
// Insufficient fuel: a full-tank one-way burn of > half the tank makes the return leg unfuelable.
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
|
||||||
|
if (starter.fuel < starter.maxFuel) {
|
||||||
|
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long leg
|
||||||
|
}
|
||||||
|
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
|
||||||
|
expect(farRes.ok()).toBeTruthy();
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||||||
|
|
||||||
|
const sFar = await getState(page);
|
||||||
|
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
|
expect(atFar.locationIcao).toBe(FAR);
|
||||||
|
// The return leg needs the same burn as the outbound — more than what's left.
|
||||||
|
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(backRes.status()).toBe(409);
|
||||||
|
expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||||
|
|
||||||
|
// Restore: refuel + fly home.
|
||||||
|
await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|||||||
+5
-4
@@ -13,20 +13,21 @@ 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 nine endpoints and the package.json version', async ({ page }) => {
|
test('GET /api/openapi serves the spec with all ten 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');
|
||||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
||||||
expect(spec.info.title).toBe('Flight Ops API');
|
expect(spec.info.title).toBe('Flight Ops API');
|
||||||
expect(spec.info.version).toBe(pkg.version);
|
expect(spec.info.version).toBe(pkg.version);
|
||||||
// v0.8.0 adds the five player-action endpoints to the four read-only ones.
|
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
|
||||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||||
'/api/airports',
|
'/api/airports',
|
||||||
'/api/contracts/{id}/accept',
|
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
'/api/planes/{planeId}/dispatch',
|
'/api/packages/{id}/abandon',
|
||||||
|
'/api/packages/{id}/load',
|
||||||
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// v0.11.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||||
// deterministic generated weather), plane markers whose positions come from the
|
// deterministic generated weather), plane markers whose positions come from the
|
||||||
// /api/state polls, active contract route lines (pending dashed vs in-flight
|
// /api/state polls, each FLYING plane's current leg as a route line (origin →
|
||||||
// solid), and the plane status popup. Contracts are created on a known EDDF→EDDV
|
// dest_icao; grounded planes draw nothing), and the plane status popup. Packages
|
||||||
// route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1).
|
// are created on a known EDDF→EDDV route via POST /api/test/create-package
|
||||||
|
// (FLIGHT_TEST_HOOKS=1).
|
||||||
|
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable
|
// Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable
|
||||||
@@ -20,19 +21,18 @@ interface PlaneState {
|
|||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
contractId: string | null;
|
locationName: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
destName: string | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContractState {
|
interface PackageState {
|
||||||
id: string;
|
id: string;
|
||||||
originIcao: string;
|
|
||||||
originName: string;
|
originName: string;
|
||||||
destIcao: string;
|
|
||||||
destName: string;
|
destName: string;
|
||||||
reward: number;
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ interface StateBody {
|
|||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
contracts: ContractState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -66,38 +66,47 @@ async function postSim(page: Page, body: unknown) {
|
|||||||
return page.request.post('/api/sim', { data: body });
|
return page.request.post('/api/sim', { data: body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||||
|
// otherwise surface as an unexplained landing-wait timeout.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||||
|
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).
|
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
const created = await page.request.post('/api/test/create-contract', {
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||||||
});
|
});
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as ContractState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
||||||
// scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
// scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||||
async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||||
const contract = await createContract(page, fromIcao, toIcao);
|
const pkg = await createPackage(page, fromIcao, toIcao);
|
||||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(acc.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
||||||
expect(disp.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
const p = body.planes.find((x) => x.id === 'plane-starter');
|
const p = body.planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
}, { timeout: 240_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
return contract;
|
return pkg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
||||||
@@ -161,8 +170,8 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
|||||||
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
|
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plane markers move while flying, the active route line is highlighted, and the plane popup shows status + route', async ({ page }) => {
|
test('plane markers move while flying, the current leg draws a route line, and the plane popup shows status + route', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // generous cap — two short flights at scale 60 plus map waits
|
test.setTimeout(600_000); // self-heal from a far airport (~4 min worst case at 60×) + main flight + cleanup
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -173,35 +182,45 @@ test('plane markers move while flying, the active route line is highlighted, and
|
|||||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||||
expect(foundStarter).toBeDefined();
|
expect(foundStarter).toBeDefined();
|
||||||
if (foundStarter!.status === 'flying') {
|
if (foundStarter!.status === 'flying') {
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||||
timeout: 120_000,
|
timeout: 240_000,
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||||
if (starterLoc !== HUB) {
|
if (starterLoc !== HUB) {
|
||||||
await flyContract(page, starterLoc, HUB);
|
await loadAndFly(page, starterLoc, HUB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Accept the hook-created contract — before dispatch it must draw a PENDING route line.
|
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
|
||||||
const contract = await createContract(page, HUB, DEST);
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
expect(acc.status()).toBe(200);
|
expect(load.status()).toBe(200);
|
||||||
|
|
||||||
// The map updates from the shell's /api/state polls — poll until the pending line lands.
|
// The map updates from the shell's /api/state polls — poll until the snapshot lands.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
|
||||||
|
return feats.some((f) => f.properties.id === 'plane-starter');
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the plane feature in the planes source' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// A GROUNDED plane draws no route line. Poll (not one-shot): if self-heal just flew the
|
||||||
|
// plane home, the map's last poll may still show that flight's stale line until its next
|
||||||
|
// ~1 s tick — wait for it to clear rather than racing the poll loop.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending');
|
return !feats.some((f) => f.properties.planeId === 'plane-starter');
|
||||||
}, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' })
|
}, { timeout: 15_000, message: 'waiting for the grounded plane to draw no route line' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature.
|
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
||||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(disp.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await postSim(page, { timeScale: SCALE });
|
await postSim(page, { timeScale: SCALE });
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
@@ -219,15 +238,16 @@ test('plane markers move while flying, the active route line is highlighted, and
|
|||||||
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||||
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
|
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 4) The route line for this contract is now IN-FLIGHT (solid layer), not pending.
|
// 4) The route line for this flight exists in the routes source (v0.12.0: one line
|
||||||
|
// per flying plane's current leg, keyed by planeId).
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight');
|
return feats.some((f) => f.properties.planeId === 'plane-starter');
|
||||||
}, { timeout: 15_000, message: 'waiting for the in-flight route line' })
|
}, { timeout: 15_000, message: 'waiting for the route line in the routes source' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// 5) Click the flying plane → status popup with model, status, fuel and the route.
|
// 5) Click the flying plane → status popup with model, status, fuel and the leg.
|
||||||
// Fetch + project in one evaluate so the click lands on a fresh position (the
|
// Fetch + project in one evaluate so the click lands on a fresh position (the
|
||||||
// marker drifts only ~1 px/s of screen space at scale 60).
|
// marker drifts only ~1 px/s of screen space at scale 60).
|
||||||
const pt = await page.evaluate(async () => {
|
const pt = await page.evaluate(async () => {
|
||||||
@@ -250,17 +270,20 @@ test('plane markers move while flying, the active route line is highlighted, and
|
|||||||
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||||
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
|
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
|
||||||
|
|
||||||
const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!;
|
const live = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`);
|
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||||
|
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||||
|
|
||||||
// 6) Let the flight finish, then fly home so the live instance stays in its
|
// 6) Let the flight finish, then fly home so the live instance stays in its
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||||
|
// package is delivered on arrival (in-deadline).
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
expect((await getState(page)).packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
await flyContract(page, DEST, HUB);
|
await loadAndFly(page, DEST, HUB);
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-1
@@ -14,7 +14,7 @@ interface StateBody {
|
|||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: SimState;
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
contracts: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
@@ -50,6 +50,11 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(row).toContainText('ATR 72-600');
|
await expect(row).toContainText('ATR 72-600');
|
||||||
await expect(row).toContainText('Frankfurt');
|
await expect(row).toContainText('Frankfurt');
|
||||||
|
|
||||||
|
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
|
||||||
|
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
||||||
|
await expect(loadout).toBeVisible();
|
||||||
|
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/);
|
||||||
|
|
||||||
// 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/);
|
||||||
});
|
});
|
||||||
|
|||||||
+160
-196
@@ -1,12 +1,12 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// v0.10.0 — right Jobs & Market pane against the live origin: open contracts render with
|
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||||
// names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows
|
||||||
// EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight"
|
// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same
|
||||||
// in the UI AND /api/state; market refuel through the pane deducts exactly
|
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
|
||||||
// (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog +
|
// match the catalog + economy constants exposed in /api/state. Packages are created via
|
||||||
// economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract
|
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||||
// (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
|
||||||
|
|
||||||
const SCALE = 60;
|
const SCALE = 60;
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
@@ -18,15 +18,18 @@ interface PlaneState {
|
|||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContractState {
|
interface PackageState {
|
||||||
id: string;
|
id: string;
|
||||||
originName: string;
|
originName: string;
|
||||||
destName: string;
|
destName: string;
|
||||||
reward: number;
|
reward: number;
|
||||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
|
createdSimTime: number;
|
||||||
|
deadlineSimTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogEntry {
|
interface CatalogEntry {
|
||||||
@@ -36,13 +39,14 @@ interface CatalogEntry {
|
|||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
burnPerKm: number;
|
burnPerKm: number;
|
||||||
speedKmh: number;
|
speedKmh: number;
|
||||||
|
capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
contracts: ContractState[];
|
packages: PackageState[];
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
fuelPricePerL: number;
|
fuelPricePerL: number;
|
||||||
sellFraction: number;
|
sellFraction: number;
|
||||||
@@ -61,6 +65,15 @@ async function postSim(page: Page, body: unknown) {
|
|||||||
return page.request.post('/api/sim', { data: body });
|
return page.request.post('/api/sim', { data: body });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||||
|
// otherwise surface as an unexplained landing-wait timeout.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||||
|
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).
|
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||||
@@ -78,6 +91,13 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
|||||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
|
||||||
|
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||||
|
const span = deadlineSimTime - createdSimTime;
|
||||||
|
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
|
||||||
|
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 60.
|
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 60.
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
@@ -94,155 +114,160 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
return nearest;
|
return nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
||||||
const created = await page.request.post('/api/test/create-contract', {
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao },
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
||||||
});
|
});
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as ContractState;
|
return (await created.json()) as PackageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create + accept + dispatch a contract on plane-starter via the API, then wait for arrival.
|
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||||
async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
const contract = await createContract(page, fromIcao, toIcao);
|
const s0 = await getState(page);
|
||||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
expect(acc.status()).toBe(200);
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
|
||||||
expect(disp.status()).toBe(200);
|
await setScale(page, SCALE);
|
||||||
await postSim(page, { timeScale: SCALE });
|
}
|
||||||
await expect
|
if (starter.status === 'flying') {
|
||||||
.poll(async () => {
|
await expect
|
||||||
const body = await getState(page);
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||||
const p = body.planes.find((x) => x.id === 'plane-starter');
|
timeout: 240_000,
|
||||||
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
}, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
}
|
||||||
|
if (starter.locationIcao !== HUB) {
|
||||||
|
const res = await page.request.post('/api/planes/plane-starter/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 === HUB, {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('open contracts render with names, reward and deadline; Accept moves the contract into the accepted group', async ({ page }) => {
|
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const right = page.getByTestId('right-pane');
|
const right = page.getByTestId('right-pane');
|
||||||
await expect(right).toBeVisible({ timeout: 15_000 });
|
await expect(right).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button.
|
// ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button.
|
||||||
const rows = page.locator('li[data-testid^="open-contract-"]');
|
const rows = page.locator('li[data-testid^="open-package-"]');
|
||||||
await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 });
|
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(rows.first()).toContainText(/→/);
|
await expect(rows.first()).toContainText(/→/);
|
||||||
await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€');
|
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||||
await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible();
|
|
||||||
|
|
||||||
// Deterministic target: a hook-created EDDF contract, accepted through the UI.
|
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
const contract = await createContract(page, HUB, nearest.icao);
|
const pkg = await createPackage(page, HUB, nearest.icao);
|
||||||
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||||
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
|
await expect(loadBtn).toBeEnabled();
|
||||||
|
|
||||||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
// Load through the UI — exactly one eligible plane, so the button acts directly.
|
||||||
await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up
|
await loadBtn.click();
|
||||||
await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible();
|
|
||||||
await page.getByTestId(`accept-${contract.id}`).click();
|
|
||||||
|
|
||||||
// Server truth first…
|
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
|
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded');
|
||||||
// …and the UI: gone from open, present in the accepted group with dispatch controls.
|
expect(body.packages.find((p) => p.id === pkg.id)?.planeId).toBe('plane-starter');
|
||||||
|
|
||||||
|
// The loaded package leaves the open list and shows in the on-board section.
|
||||||
await expect(row).not.toBeVisible();
|
await expect(row).not.toBeVisible();
|
||||||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => {
|
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
||||||
test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
||||||
|
|
||||||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
const pkg = await createPackage(page, 'LEMD', HUB);
|
||||||
// another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts).
|
await page.goto('/');
|
||||||
let body = await getState(page);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||||
expect(starter0).toBeDefined();
|
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
|
||||||
if (starter0!.status === 'flying') {
|
});
|
||||||
await postSim(page, { timeScale: SCALE });
|
|
||||||
await expect
|
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
test.setTimeout(240_000); // one short flight at scale 60 plus UI steps — typical run well under three minutes
|
||||||
timeout: 90_000,
|
await flyHomeViaApi(page);
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
body = await getState(page);
|
|
||||||
}
|
|
||||||
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
|
|
||||||
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
|
||||||
await flyViaApi(page, from, HUB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook contract on the shortest EDDF route, accepted through the UI.
|
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
const contract = await createContract(page, HUB, nearest.icao);
|
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||||
|
const loadRes = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(loadRes.status()).toBe(200);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||||
await page.getByTestId(`accept-${contract.id}`).click();
|
|
||||||
|
|
||||||
// Dispatch controls appear in the accepted group; pick plane-starter and dispatch.
|
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
||||||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
||||||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
// making the preview deterministic instead of racing the 1 s poll loop.
|
||||||
const picker = page.getByTestId(`dispatch-plane-${contract.id}`);
|
await postSim(page, { paused: true });
|
||||||
await expect(picker).toBeVisible();
|
const state = await getState(page);
|
||||||
await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option
|
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||||
await page.getByTestId(`dispatch-${contract.id}`).click();
|
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||||
|
|
||||||
|
// Fly to the destination through the UI — the button is the destination's name.
|
||||||
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
await expect(flyBtn).toBeEnabled();
|
||||||
|
await flyBtn.click();
|
||||||
|
|
||||||
|
// The on-board block now shows the plane in flight with its current leg…
|
||||||
|
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||||
|
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||||
|
await expect(leg).toBeVisible();
|
||||||
|
await expect(leg).toContainText('→');
|
||||||
|
|
||||||
// UI shows the in-flight indicator…
|
|
||||||
await expect(page.getByTestId(`contract-inflight-${contract.id}`)).toBeVisible({ timeout: 10_000 });
|
|
||||||
// …and /api/state agrees.
|
// …and /api/state agrees.
|
||||||
body = await getState(page);
|
let body = await getState(page);
|
||||||
let starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||||
expect(starter.status).toBe('flying');
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||||
|
|
||||||
// Fly to arrival at scale 60 (poll the authoritative state).
|
// Resume and fast-forward to arrival (poll the authoritative state).
|
||||||
await postSim(page, { timeScale: SCALE });
|
await postSim(page, { paused: false });
|
||||||
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const b = await getState(page);
|
const b = await getState(page);
|
||||||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||||||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// Market refuel through the pane: the flight genuinely burned fuel…
|
// The in-deadline package was delivered on arrival — it leaves the on-board list…
|
||||||
body = await getState(page);
|
body = await getState(page);
|
||||||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||||
expect(starter.fuel).toBeLessThan(starter.maxFuel);
|
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
|
||||||
const cashBefore = body.account.cash;
|
|
||||||
|
|
||||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
||||||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
expect(homeRes.status()).toBe(200);
|
||||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
|
||||||
await page.getByTestId('market-refuel').click();
|
|
||||||
|
|
||||||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
const b = await getState(page);
|
timeout: 120_000,
|
||||||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
body = await getState(page);
|
|
||||||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
expect(starter.fuel).toBe(starter.maxFuel);
|
|
||||||
|
|
||||||
// Fly home so the live instance stays in its onboarding state for the next run.
|
|
||||||
await flyViaApi(page, nearest.icao, HUB);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('market buy/sell render with price and payout previews from the catalog in /api/state', async ({ page }) => {
|
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||||
|
|
||||||
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).toBeDefined();
|
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
|
||||||
|
|
||||||
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.
|
||||||
@@ -255,91 +280,30 @@ test('market buy/sell render with price and payout previews from the catalog in
|
|||||||
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
||||||
// sellFraction × price, both from the snapshot's catalog/constants.
|
// sellFraction × price, both from the snapshot's catalog/constants.
|
||||||
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
expect(starter.status).toBe('available'); // test order: it flew home at the end of the previous test
|
expect(starter.status).toBe('available');
|
||||||
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
||||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
await expect(page.getByTestId('market-sell')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => {
|
// Refuel through the pane: the flight genuinely burned fuel…
|
||||||
test.setTimeout(240_000); // two short flights at scale 60 plus UI steps
|
if (starter.fuel < starter.maxFuel) {
|
||||||
|
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||||
|
const cashBefore = body.account.cash;
|
||||||
|
|
||||||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
||||||
// another airport (same pattern as the dispatch-loop test above).
|
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
||||||
let body = await getState(page);
|
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
||||||
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
|
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||||||
expect(starter0).toBeDefined();
|
await page.getByTestId('market-refuel').click();
|
||||||
if (starter0!.status === 'flying') {
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
.poll(async () => {
|
||||||
timeout: 90_000,
|
const b = await getState(page);
|
||||||
message: 'waiting for an in-flight plane-starter to land',
|
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
||||||
})
|
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
body = await getState(page);
|
const after = await getState(page);
|
||||||
|
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
||||||
}
|
}
|
||||||
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
|
|
||||||
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
|
||||||
await flyViaApi(page, from, HUB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two hook contracts, both originating at EDDF on the shortest route.
|
|
||||||
const nearest = await nearestToHub(page);
|
|
||||||
const a = await createContract(page, HUB, nearest.icao);
|
|
||||||
const b = await createContract(page, HUB, nearest.icao);
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
// Accept BOTH through the UI buttons.
|
|
||||||
for (const c of [a, b]) {
|
|
||||||
const row = page.locator(`li[data-testid="open-contract-${c.id}"]`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.getByTestId(`accept-${c.id}`).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const accA = page.getByTestId(`accepted-contract-${a.id}`);
|
|
||||||
const accB = page.getByTestId(`accepted-contract-${b.id}`);
|
|
||||||
await expect(accA).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(accB).toBeVisible();
|
|
||||||
|
|
||||||
// Both dispatch pickers render with the placeholder default — nothing preselected (v0.10.1)…
|
|
||||||
const pickerA = page.getByTestId(`dispatch-plane-${a.id}`);
|
|
||||||
const pickerB = page.getByTestId(`dispatch-plane-${b.id}`);
|
|
||||||
await expect(pickerA).toBeVisible();
|
|
||||||
await expect(pickerB).toBeVisible();
|
|
||||||
await expect(pickerA).toHaveValue('');
|
|
||||||
await expect(pickerB).toHaveValue('');
|
|
||||||
await expect(pickerA.locator('option[value=""]')).toHaveText(/select a plane/i);
|
|
||||||
|
|
||||||
// …and the Dispatch buttons stay disabled until a valid plane is chosen.
|
|
||||||
await expect(page.getByTestId(`dispatch-${a.id}`)).toBeDisabled();
|
|
||||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
|
||||||
|
|
||||||
// Select plane-starter in contract A's picker → contract B's picker must STILL show the
|
|
||||||
// placeholder (the v0.10.1 regression: selections used to appear shared across rows).
|
|
||||||
await pickerA.selectOption('plane-starter');
|
|
||||||
await expect(pickerA).toHaveValue('plane-starter');
|
|
||||||
await expect(pickerB).toHaveValue('');
|
|
||||||
|
|
||||||
// Dispatch A with the selected plane → it goes 'flying' and its row shows the in-flight…
|
|
||||||
await page.getByTestId(`dispatch-${a.id}`).click();
|
|
||||||
await expect(page.getByTestId(`contract-inflight-${a.id}`)).toBeVisible({ timeout: 10_000 });
|
|
||||||
body = await getState(page);
|
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
|
||||||
|
|
||||||
// …indicator, while B remains accepted with its own independent picker on the placeholder.
|
|
||||||
await expect(accB).toBeVisible();
|
|
||||||
await expect(pickerB).toHaveValue('');
|
|
||||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
|
||||||
|
|
||||||
// Restore: let A's flight finish and fly plane-starter home so later specs start clean.
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
|
||||||
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
|
||||||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
|
||||||
.toBe(true);
|
|
||||||
await flyViaApi(page, nearest.icao, HUB);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-19
@@ -1,14 +1,14 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
||||||
// raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS
|
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
|
||||||
// hook enabled in docker-compose.yml), and market top-up.
|
// hook enabled in docker-compose.yml), and market top-up.
|
||||||
|
|
||||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
||||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||||
const SCALE = 60;
|
const SCALE = 60;
|
||||||
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60
|
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60
|
||||||
// Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts.
|
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
||||||
const MARKET_TARGET = 10;
|
const MARKET_TARGET = 10;
|
||||||
|
|
||||||
interface SimState {
|
interface SimState {
|
||||||
@@ -21,7 +21,7 @@ interface StateBody {
|
|||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: SimState;
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
contracts: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -74,16 +74,16 @@ test('paused freezes the clock exactly across polls', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => {
|
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
const victim = before.contracts.find((c) => c.status === 'open');
|
const victim = before.packages.find((p) => p.status === 'open');
|
||||||
expect(victim).toBeDefined(); // steady state always has open contracts
|
expect(victim).toBeDefined(); // steady state always has open packages
|
||||||
|
|
||||||
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
||||||
// contract's deadline ~10 game-seconds out so expiry is observable at scale 60.
|
// package's deadline ~10 game-seconds out so expiry is observable at scale 60.
|
||||||
const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } });
|
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||||
expect(hook.status()).toBe(200);
|
expect(hook.status()).toBe(200);
|
||||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
||||||
expect(moved.id).toBe(victim!.id);
|
expect(moved.id).toBe(victim!.id);
|
||||||
@@ -94,24 +94,24 @@ test('an open contract past its short deadline expires and the market stays topp
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body;
|
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
||||||
return !!expiredBody;
|
return !!expiredBody;
|
||||||
}, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' })
|
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
const after = expiredBody!;
|
const after = expiredBody!;
|
||||||
expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired');
|
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
||||||
// Expired contracts leave the available set…
|
// Expired packages leave the available set…
|
||||||
expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
||||||
// …and spawn tops the market back up to the target — never below it.
|
// …and spawn tops the market back up to the target — never below it.
|
||||||
const openCount = after.contracts.filter((c) => c.status === 'open').length;
|
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => {
|
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const created = await page.request.post('/api/test/expire-contract');
|
const created = await page.request.post('/api/test/expire-package');
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
const c = (await created.json()) as { id: string; status: string };
|
const c = (await created.json()) as { id: string; status: string };
|
||||||
expect(c.status).toBe('open');
|
expect(c.status).toBe('open');
|
||||||
@@ -122,12 +122,12 @@ test('the hook can also create a fresh short-deadline contract, which then expir
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const body = await getState(page);
|
const body = await getState(page);
|
||||||
if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body;
|
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
||||||
return !!expiredBody;
|
return !!expiredBody;
|
||||||
}, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' })
|
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length;
|
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -10,7 +10,7 @@ interface StateBody {
|
|||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
contracts: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||||
@@ -32,10 +32,10 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
|
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(Array.isArray(body.contracts)).toBe(true);
|
expect(Array.isArray(body.packages)).toBe(true);
|
||||||
expect(body.contracts.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', 'deadlineSimTime', 'status']) {
|
||||||
expect(body.contracts[0], `contract.${key}`).toHaveProperty(key);
|
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,5 +78,5 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
expect(a.account.cash).toBe(before.account.cash);
|
expect(a.account.cash).toBe(before.account.cash);
|
||||||
expect(a.account.reputation).toBe(before.account.reputation);
|
expect(a.account.reputation).toBe(before.account.reputation);
|
||||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||||
expect(a.contracts.map((c) => c.id).sort()).toEqual(before.contracts.map((c) => c.id).sort());
|
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
||||||
});
|
});
|
||||||
|
|||||||
+115
-6
@@ -42,6 +42,46 @@ CREATE TABLE sim_state (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// The v0.11.0 DDL — the last schema before the packages pivot; used to build a
|
||||||
|
// "pre-v0.12.0" DB file for the migration test (has departure_sim_time +
|
||||||
|
// contract_id, has contracts, no packages/dest_icao).
|
||||||
|
const PRE_V12_DDL = `
|
||||||
|
CREATE TABLE planes (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
fuel REAL NOT NULL,
|
||||||
|
max_fuel REAL NOT NULL,
|
||||||
|
location_icao TEXT NOT NULL,
|
||||||
|
contract_id TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
departure_sim_time INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE contracts (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
origin_icao TEXT NOT NULL,
|
||||||
|
dest_icao TEXT NOT NULL,
|
||||||
|
reward REAL NOT NULL,
|
||||||
|
cargo_type TEXT NOT NULL,
|
||||||
|
cargo_count INTEGER NOT NULL,
|
||||||
|
deadline_sim_time INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
plane_id TEXT,
|
||||||
|
created_sim_time INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE account (
|
||||||
|
cash REAL NOT NULL,
|
||||||
|
reputation REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE sim_state (
|
||||||
|
clock_ms INTEGER NOT NULL,
|
||||||
|
time_scale REAL NOT NULL,
|
||||||
|
paused INTEGER NOT NULL,
|
||||||
|
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -64,9 +104,9 @@ describe('openGameDb', () => {
|
|||||||
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
|
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
|
||||||
expect(planes[0].fuel).toBe(planes[0].maxFuel);
|
expect(planes[0].fuel).toBe(planes[0].maxFuel);
|
||||||
|
|
||||||
const contracts = await inst.db.select().from(schema.contracts);
|
const packages = await inst.db.select().from(schema.packages);
|
||||||
expect(contracts.length).toBeGreaterThanOrEqual(8);
|
expect(packages.length).toBeGreaterThanOrEqual(8);
|
||||||
expect(contracts.every((c) => c.status === 'open')).toBe(true);
|
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account?.cash).toBeGreaterThan(0);
|
expect(account?.cash).toBeGreaterThan(0);
|
||||||
@@ -82,13 +122,13 @@ describe('openGameDb', () => {
|
|||||||
it('does not re-seed an existing DB — state survives reopen', async () => {
|
it('does not re-seed an existing DB — state survives reopen', async () => {
|
||||||
const file = path.join(dir, 'reopen.db');
|
const file = path.join(dir, 'reopen.db');
|
||||||
const first = await openGameDb(file);
|
const first = await openGameDb(file);
|
||||||
const contracts1 = (await first.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||||
const account1 = (await first.db.select().from(schema.account))[0];
|
const account1 = (await first.db.select().from(schema.account))[0];
|
||||||
|
|
||||||
// Simulate a process restart: brand-new instance over the same file.
|
// Simulate a process restart: brand-new instance over the same file.
|
||||||
const second = await openGameDb(file);
|
const second = await openGameDb(file);
|
||||||
const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||||
expect(contracts2).toEqual(contracts1);
|
expect(packages2).toEqual(packages1);
|
||||||
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,4 +168,73 @@ describe('openGameDb', () => {
|
|||||||
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
||||||
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
||||||
|
// Build a v0.11.0 file with an IN-FLIGHT plane (old dispatch state) and live contracts.
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(PRE_V12_DDL);
|
||||||
|
raw.exec(
|
||||||
|
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'flying', 4000, 6300, 'EDDF', 'c-1', 50.03, 8.57, 1789000000000)`,
|
||||||
|
);
|
||||||
|
raw.exec(
|
||||||
|
`INSERT INTO contracts VALUES ('c-1', 'EDDF', 'LEMD', 5000, 'Mail', 4, 1789100000000, 'accepted', 'plane-starter', 1788900000000)`,
|
||||||
|
);
|
||||||
|
raw.exec(`INSERT INTO account VALUES (54020.34, 62.57)`);
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288)');
|
||||||
|
const file = path.join(dir, 'pre-v12.db');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const inst = await openGameDb(file);
|
||||||
|
|
||||||
|
// Wallet + sim clock survive byte-identical…
|
||||||
|
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
|
||||||
|
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
||||||
|
|
||||||
|
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.locationIcao).toBe('EDDF'); // unchanged — grounded AT the origin
|
||||||
|
expect(plane.departureSimTime ?? null).toBeNull();
|
||||||
|
expect(plane.destIcao ?? null).toBeNull();
|
||||||
|
|
||||||
|
// …old contract data is wiped and the packages table exists (empty until spawn reseeds)…
|
||||||
|
let contractsGone = false;
|
||||||
|
try {
|
||||||
|
inst.raw.exec('SELECT COUNT(*) FROM contracts');
|
||||||
|
} catch {
|
||||||
|
contractsGone = true;
|
||||||
|
}
|
||||||
|
expect(contractsGone).toBe(true);
|
||||||
|
const packages = await inst.db.select().from(schema.packages);
|
||||||
|
expect(packages).toHaveLength(0);
|
||||||
|
|
||||||
|
// …and the orphaned contract_id column is dropped from planes.
|
||||||
|
const cols = new Set(inst.raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])));
|
||||||
|
expect(cols.has('contract_id')).toBe(false);
|
||||||
|
expect(cols.has('dest_icao')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
||||||
|
const file = path.join(dir, 'pre-v12-reopen.db');
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const raw = new SQL.Database();
|
||||||
|
raw.exec(PRE_V12_DDL);
|
||||||
|
raw.exec(
|
||||||
|
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL)`,
|
||||||
|
);
|
||||||
|
raw.exec(`INSERT INTO account VALUES (1000, 1)`);
|
||||||
|
raw.exec('INSERT INTO sim_state VALUES (1789000000000, 1, 0, 1789000000000)');
|
||||||
|
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||||
|
|
||||||
|
const first = await openGameDb(file); // migration runs here
|
||||||
|
// Simulate post-migration play: the plane flies again.
|
||||||
|
await first.db.update(schema.planes).set({ status: 'flying', destIcao: 'LEMD', departureSimTime: 1789000001000 });
|
||||||
|
await persist(first);
|
||||||
|
|
||||||
|
const second = await openGameDb(file); // must NOT ground the plane again
|
||||||
|
const plane = (await second.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-6
@@ -27,12 +27,12 @@ CREATE TABLE IF NOT EXISTS planes (
|
|||||||
fuel REAL NOT NULL,
|
fuel REAL NOT NULL,
|
||||||
max_fuel REAL NOT NULL,
|
max_fuel REAL NOT NULL,
|
||||||
location_icao TEXT NOT NULL,
|
location_icao TEXT NOT NULL,
|
||||||
contract_id 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
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS contracts (
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
origin_icao TEXT NOT NULL,
|
origin_icao TEXT NOT NULL,
|
||||||
dest_icao TEXT NOT NULL,
|
dest_icao TEXT NOT NULL,
|
||||||
@@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
|||||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||||
// already have everything from DDL above and this is a no-op for them.
|
// already have everything from DDL above and this is a no-op for them.
|
||||||
function migrate(raw: RawSqlJsDatabase): void {
|
function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||||
const cols = (table: string): Set<string> =>
|
const cols = (table: string): Set<string> =>
|
||||||
new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1])));
|
new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1])));
|
||||||
if (!cols('sim_state').has('last_real_tick_ms')) {
|
if (!cols('sim_state').has('last_real_tick_ms')) {
|
||||||
@@ -68,6 +68,37 @@ function migrate(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('planes').has('departure_sim_time')) {
|
if (!cols('planes').has('departure_sim_time')) {
|
||||||
raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER');
|
raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER');
|
||||||
}
|
}
|
||||||
|
// v0.12.0: the flight destination column (set by fly, cleared on arrival).
|
||||||
|
if (!cols('planes').has('dest_icao')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||||
|
// presence of the old `contracts` table (fresh and already-migrated DBs never
|
||||||
|
// have it, so this runs exactly once per file). Preserves account/sim_state rows
|
||||||
|
// and every plane row; planes are reset to GROUNDED at their locationIcao with
|
||||||
|
// departure_sim_time/dest_icao cleared — an in-flight plane from the old state
|
||||||
|
// simply lands back where it took off. Old contract data is wiped (the packages
|
||||||
|
// table starts empty and the engine's spawn rules reseed the market on the next
|
||||||
|
// tick). The orphaned pre-v0.12.0 `contract_id` column is dropped too (SQLite
|
||||||
|
// ≥3.35 DROP COLUMN; guarded so a failure can never block startup).
|
||||||
|
function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
|
||||||
|
const cols = new Set(
|
||||||
|
raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])),
|
||||||
|
);
|
||||||
|
raw.exec(
|
||||||
|
`UPDATE planes SET status = 'available', departure_sim_time = NULL, dest_icao = NULL` +
|
||||||
|
(cols.has('contract_id') ? ', contract_id = NULL' : ''),
|
||||||
|
);
|
||||||
|
if (cols.has('contract_id')) {
|
||||||
|
try {
|
||||||
|
raw.exec('ALTER TABLE planes DROP COLUMN contract_id');
|
||||||
|
} catch {
|
||||||
|
// Leaving the orphaned column is harmless — nothing references it anymore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw.exec('DROP TABLE contracts');
|
||||||
}
|
}
|
||||||
|
|
||||||
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
|
||||||
@@ -85,8 +116,17 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
}
|
}
|
||||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||||
raw.exec(DDL);
|
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||||
migrate(raw);
|
migrateColumns(raw);
|
||||||
|
|
||||||
|
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
||||||
|
// without this it would keep the dead contracts table forever.
|
||||||
|
const tables = new Set(
|
||||||
|
raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
|
||||||
|
);
|
||||||
|
if (tables.has('contracts')) {
|
||||||
|
migrateContractsToPackages(raw);
|
||||||
|
}
|
||||||
|
|
||||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||||
|
|
||||||
@@ -120,7 +160,7 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
|||||||
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);
|
await instance.db.insert(schema.planes).values(data.plane);
|
||||||
await instance.db.insert(schema.contracts).values(data.contracts);
|
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);
|
||||||
await persist(instance);
|
await persist(instance);
|
||||||
|
|||||||
+12
-6
@@ -11,7 +11,9 @@ export const planes = sqliteTable('planes', {
|
|||||||
fuel: real('fuel').notNull(),
|
fuel: real('fuel').notNull(),
|
||||||
maxFuel: real('max_fuel').notNull(),
|
maxFuel: real('max_fuel').notNull(),
|
||||||
locationIcao: text('location_icao').notNull(),
|
locationIcao: text('location_icao').notNull(),
|
||||||
contractId: text('contract_id'),
|
// Current flight destination (v0.12.0) — set by fly, cleared on arrival. The
|
||||||
|
// plane's origin is always its locationIcao while flying.
|
||||||
|
destIcao: text('dest_icao'),
|
||||||
lat: real('lat').notNull(),
|
lat: real('lat').notNull(),
|
||||||
lon: real('lon').notNull(),
|
lon: real('lon').notNull(),
|
||||||
// Sim-clock departure time of the current flight (null when available). While
|
// Sim-clock departure time of the current flight (null when available). While
|
||||||
@@ -20,7 +22,11 @@ export const planes = sqliteTable('planes', {
|
|||||||
departureSimTime: integer('departure_sim_time'),
|
departureSimTime: integer('departure_sim_time'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const contracts = sqliteTable('contracts', {
|
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||||
|
// is one cargo unit origin → dest with a reward and deadline. 'loaded' = bound to
|
||||||
|
// a plane's slot; it stays bound ('expired') or pays out ('delivered') when the
|
||||||
|
// plane arrives at its destination airport.
|
||||||
|
export const packages = sqliteTable('packages', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
originIcao: text('origin_icao').notNull(),
|
originIcao: text('origin_icao').notNull(),
|
||||||
destIcao: text('dest_icao').notNull(),
|
destIcao: text('dest_icao').notNull(),
|
||||||
@@ -28,9 +34,9 @@ export const contracts = sqliteTable('contracts', {
|
|||||||
cargoType: text('cargo_type').notNull(),
|
cargoType: text('cargo_type').notNull(),
|
||||||
cargoCount: integer('cargo_count').notNull(),
|
cargoCount: integer('cargo_count').notNull(),
|
||||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||||
// 'expired' = an open contract whose deadline passed (v0.7.0). Accepted/
|
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||||
// in-flight contracts never expire — they complete.
|
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||||
status: text('status', { enum: ['open', 'accepted', 'completed', 'expired'] }).notNull(),
|
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||||
planeId: text('plane_id'),
|
planeId: text('plane_id'),
|
||||||
createdSimTime: integer('created_sim_time').notNull(),
|
createdSimTime: integer('created_sim_time').notNull(),
|
||||||
});
|
});
|
||||||
@@ -50,6 +56,6 @@ export const simState = sqliteTable('sim_state', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type PlaneRow = typeof planes.$inferSelect;
|
export type PlaneRow = typeof planes.$inferSelect;
|
||||||
export type ContractRow = typeof contracts.$inferSelect;
|
export type PackageRow = typeof packages.$inferSelect;
|
||||||
export type AccountRow = typeof account.$inferSelect;
|
export type AccountRow = typeof account.$inferSelect;
|
||||||
export type SimStateRow = typeof simState.$inferSelect;
|
export type SimStateRow = typeof simState.$inferSelect;
|
||||||
|
|||||||
+194
-69
@@ -9,10 +9,11 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
import {
|
import {
|
||||||
acceptContract,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
dispatchPlane,
|
flyPlane,
|
||||||
GameActionError,
|
GameActionError,
|
||||||
|
loadPackage,
|
||||||
refuelPlane,
|
refuelPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
@@ -54,54 +55,107 @@ function expectActionError(promise: Promise<unknown>, status: number, messagePar
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert an accepted contract EDDF → LEMD bound to nothing, for dispatch tests.
|
// Insert a known OPEN package EDDF → LEMD for load tests.
|
||||||
async function insertAcceptedContract(inst: GameDb, id = 'c-acc'): Promise<void> {
|
async function insertOpenPackage(inst: GameDb, id = 'p-open', over: Partial<schema.PackageRow> = {}): Promise<void> {
|
||||||
await inst.db.insert(schema.contracts).values({
|
await inst.db.insert(schema.packages).values({
|
||||||
id,
|
id,
|
||||||
originIcao: HUB_ICAO,
|
originIcao: HUB_ICAO,
|
||||||
destIcao: 'LEMD',
|
destIcao: 'LEMD',
|
||||||
reward: 5_000,
|
reward: 1_000,
|
||||||
cargoType: 'Passengers',
|
cargoType: 'Mail',
|
||||||
cargoCount: 60,
|
cargoCount: 4,
|
||||||
deadlineSimTime: T0 + 24 * 3_600_000,
|
deadlineSimTime: T0 + 24 * 3_600_000,
|
||||||
status: 'accepted',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: T0,
|
createdSimTime: T0,
|
||||||
|
...over,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('acceptContract', () => {
|
describe('loadPackage', () => {
|
||||||
it('flips an open contract to accepted and persists it', async () => {
|
it('binds an open package to a grounded plane at its origin', async () => {
|
||||||
const inst = await freshDb('acc-open.db');
|
const inst = await freshDb('load-ok.db');
|
||||||
await acceptContract(inst, 'contract-1'); // seeded ids are contract-N
|
await insertOpenPackage(inst);
|
||||||
|
|
||||||
const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0];
|
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
||||||
expect(row.status).toBe('accepted');
|
|
||||||
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||||
|
expect(row.status).toBe('loaded');
|
||||||
|
expect(row.planeId).toBe('plane-starter');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['accepted', 'completed', 'expired'] as const)('rejects a %s contract with 409', async (status) => {
|
it.each(['loaded', 'delivered', 'expired', 'abandoned'] as const)(
|
||||||
const inst = await freshDb(`acc-${status}.db`);
|
'rejects a %s package with 409 (must be open)',
|
||||||
await inst.db.update(schema.contracts).set({ status }).where(eq(schema.contracts.id, 'contract-1'));
|
async (status) => {
|
||||||
|
const inst = await freshDb(`load-${status}.db`);
|
||||||
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
await expectActionError(acceptContract(inst, 'contract-1'), 409, new RegExp(status));
|
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects an unknown package id with 404', async () => {
|
||||||
|
const inst = await freshDb('load-punknown.db');
|
||||||
|
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('acc-unknown.db');
|
const inst = await freshDb('load-ghost.db');
|
||||||
await expectActionError(acceptContract(inst, 'nope'), 404);
|
await insertOpenPackage(inst);
|
||||||
|
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a flying plane with 409', async () => {
|
||||||
|
const inst = await freshDb('load-flying.db');
|
||||||
|
await insertOpenPackage(inst);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a plane that is not at the origin with 409', async () => {
|
||||||
|
const inst = await freshDb('load-notatorigin.db');
|
||||||
|
await insertOpenPackage(inst); // originates at EDDF
|
||||||
|
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const inst = await freshDb('load-full.db');
|
||||||
|
await insertOpenPackage(inst, 'p-new');
|
||||||
|
// Fill all atr72 slots with loaded cargo…
|
||||||
|
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) {
|
||||||
|
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts expired packages as occupying their slot too', async () => {
|
||||||
|
const inst = await freshDb('load-expiredslot.db');
|
||||||
|
await insertOpenPackage(inst, 'p-new');
|
||||||
|
// capacity − 1 loaded + 1 expired = full.
|
||||||
|
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) {
|
||||||
|
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
|
||||||
|
}
|
||||||
|
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
|
||||||
|
|
||||||
|
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dispatchPlane', () => {
|
describe('flyPlane', () => {
|
||||||
it('binds the plane: flying + departure at current clock + origin coords, contract bound to plane', async () => {
|
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||||
const inst = await freshDb('disp-ok.db');
|
const inst = await freshDb('fly-ok.db');
|
||||||
await insertAcceptedContract(inst);
|
|
||||||
|
|
||||||
await dispatchPlane(inst, 'plane-starter', 'c-acc');
|
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
expect(plane.contractId).toBe('c-acc');
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
@@ -109,58 +163,129 @@ describe('dispatchPlane', () => {
|
|||||||
expect(plane.lon).toBe(origin.lon);
|
expect(plane.lon).toBe(origin.lon);
|
||||||
// Fuel is frozen at the departure value while flying (live fuel is analytic).
|
// Fuel is frozen at the departure value while flying (live fuel is analytic).
|
||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||||
|
|
||||||
const contract = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'c-acc')))[0];
|
|
||||||
expect(contract.planeId).toBe('plane-starter');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['open', 'completed', 'expired'] as const)('rejects a %s contract with 409 (must be accepted)', async (status) => {
|
|
||||||
const inst = await freshDb(`disp-c${status}.db`);
|
|
||||||
await inst.db.insert(schema.contracts).values({
|
|
||||||
id: 'c-x', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
|
||||||
deadlineSimTime: T0 + 3_600_000, status, planeId: null, createdSimTime: T0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-x'), 409, new RegExp(status));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown contract id with 404', async () => {
|
|
||||||
const inst = await freshDb('disp-cunknown.db');
|
|
||||||
await expectActionError(dispatchPlane(inst, 'plane-starter', 'nope'), 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown plane id with 404', async () => {
|
|
||||||
const inst = await freshDb('disp-punknown.db');
|
|
||||||
await insertAcceptedContract(inst);
|
|
||||||
await expectActionError(dispatchPlane(inst, 'ghost', 'c-acc'), 404);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('disp-flying.db');
|
const inst = await freshDb('fly-flying.db');
|
||||||
await insertAcceptedContract(inst);
|
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-other' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a plane that is not at the origin with 409', async () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('disp-notatorigin.db');
|
const inst = await freshDb('fly-ghost.db');
|
||||||
await insertAcceptedContract(inst); // originates at EDDF
|
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
||||||
await inst.db.update(schema.planes)
|
});
|
||||||
.set({ locationIcao: 'LEMD' })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /not at|at LEMD/);
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
|
const inst = await freshDb('fly-unknownicao.db');
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
||||||
const inst = await freshDb('disp-nofuel.db');
|
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
|
||||||
await insertAcceptedContract(inst); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
|
|
||||||
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('abandonPackage', () => {
|
||||||
|
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
||||||
|
const REWARD = 1_000;
|
||||||
|
const WINDOW_MS = 10 * 3_600_000;
|
||||||
|
|
||||||
|
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
||||||
|
const inst = await freshDb(name);
|
||||||
|
// Always insert OPEN and load it through the real action…
|
||||||
|
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
|
||||||
|
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||||||
|
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||||
|
if (status === 'expired') {
|
||||||
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||||||
|
}
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
|
||||||
|
const inst = await setupLoaded('ab-start.db');
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
|
||||||
|
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||||||
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
|
expect(row.status).toBe('abandoned');
|
||||||
|
expect(row.planeId).toBeNull(); // slot freed
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||||||
|
const inst = await setupLoaded('ab-mid.db');
|
||||||
|
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||||
|
|
||||||
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
|
||||||
|
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at exactly 60% at and past the deadline', async () => {
|
||||||
|
for (const [name, clock] of [
|
||||||
|
['ab-deadline.db', T0 + WINDOW_MS],
|
||||||
|
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||||||
|
] as const) {
|
||||||
|
const inst = await setupLoaded(name);
|
||||||
|
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||||
|
|
||||||
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is free for an already-expired package', async () => {
|
||||||
|
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
|
||||||
|
expect(penalty).toBe(0);
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||||
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||||
|
expect(row.status).toBe('abandoned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors cash at €0 — no debt', async () => {
|
||||||
|
const inst = await setupLoaded('ab-floor.db');
|
||||||
|
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
|
||||||
|
|
||||||
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
|
||||||
|
expect(penalty).toBe(150);
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||||||
|
const inst = await setupLoaded('ab-flying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||||
|
const inst = await freshDb(`ab-${status}.db`);
|
||||||
|
await insertOpenPackage(inst, 'p-x', { status });
|
||||||
|
|
||||||
|
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown id with 404', async () => {
|
||||||
|
const inst = await freshDb('ab-unknown.db');
|
||||||
|
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,7 +331,7 @@ describe('sellPlane', () => {
|
|||||||
// A second plane so the starter is not "the last remaining one".
|
// A second plane so the starter is not "the last remaining one".
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: null,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
@@ -224,7 +349,7 @@ describe('sellPlane', () => {
|
|||||||
const inst = await freshDb('sell-flying.db');
|
const inst = await freshDb('sell-flying.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: T0,
|
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
||||||
@@ -269,7 +394,7 @@ describe('refuelPlane', () => {
|
|||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('refuel-flying.db');
|
const inst = await freshDb('refuel-flying.db');
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-x' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
|
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
|
||||||
|
|||||||
+99
-45
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { persist, type GameDb } from '@/lib/db/client';
|
import { persist, type GameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
@@ -7,17 +7,20 @@ import { haversineKm } from '@/lib/geo';
|
|||||||
import { runAction } from '@/lib/sim/engine';
|
import { runAction } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from './state';
|
import { getStateSnapshot } 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 { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
|
|
||||||
// Player actions (v0.8.0) — the core loop, server-side: accept a contract,
|
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
||||||
// dispatch a plane on it, buy/sell planes, refuel. Each function validates and
|
// load a package onto a grounded plane at its origin, fly to any airport, deliver
|
||||||
// mutates ONE throwaway-safe GameDb instance (persisted at the end); routes wrap
|
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
|
||||||
// them in engine.runAction() so tick + validate + mutate are atomic under the sim
|
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
|
||||||
// lock. Validation failures throw GameActionError carrying the HTTP status:
|
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
||||||
|
// validate + mutate are atomic under the sim lock. Validation failures throw
|
||||||
|
// GameActionError carrying the HTTP status:
|
||||||
// 404 — unknown entity id
|
// 404 — unknown entity id
|
||||||
// 409 — valid ids but the current state forbids the action (wrong contract/plane
|
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
||||||
// status, plane not at origin, insufficient fuel/funds, last plane)
|
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
|
||||||
// 400 — bad input that isn't an existence problem (unknown catalog model)
|
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
||||||
|
|
||||||
export class GameActionError extends Error {
|
export class GameActionError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -36,54 +39,72 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
|
|||||||
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Accept (POST /api/contracts/:id/accept) ---
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
||||||
|
|
||||||
// open → accepted. Single-player, so no ownership tracking beyond the status flip;
|
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
||||||
// an accepted contract is claimed by whoever dispatched it first (dispatch checks
|
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
|
||||||
// 'accepted' and binds the plane).
|
// cargo keeps occupying its space until delivered/abandoned).
|
||||||
export async function acceptContract(inst: GameDb, contractId: string): Promise<void> {
|
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
||||||
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
|
const [pkgRows, planeRows] = await Promise.all([
|
||||||
const contract = rows[0];
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||||
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
|
|
||||||
if (contract.status !== 'open') {
|
|
||||||
throw new GameActionError(409, `contract is ${contract.status}, only open contracts can be accepted`);
|
|
||||||
}
|
|
||||||
await inst.db.update(schema.contracts).set({ status: 'accepted' }).where(eq(schema.contracts.id, contractId));
|
|
||||||
await persist(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dispatch (POST /api/planes/:planeId/dispatch {contractId}) ---
|
|
||||||
|
|
||||||
// Bind an accepted contract to a grounded plane at its origin. The v0.7.0
|
|
||||||
// finalize path then handles the flight: position/fuel are pure functions of the
|
|
||||||
// clock from departureSimTime, and arrival awards cash + reputation automatically.
|
|
||||||
export async function dispatchPlane(inst: GameDb, planeId: string, contractId: string): Promise<void> {
|
|
||||||
const [planeRows, contractRows] = await Promise.all([
|
|
||||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
||||||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)),
|
|
||||||
]);
|
]);
|
||||||
const contract = contractRows[0];
|
const pkg = pkgRows[0];
|
||||||
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||||
if (contract.status !== 'accepted') {
|
if (pkg.status !== 'open') {
|
||||||
throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`);
|
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be loaded`);
|
||||||
}
|
}
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only available planes can be dispatched`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== contract.originIcao) {
|
if (plane.locationIcao !== pkg.originIcao) {
|
||||||
throw new GameActionError(
|
throw new GameActionError(
|
||||||
409,
|
409,
|
||||||
`plane is at ${plane.locationIcao} but the contract originates at ${contract.originIcao}`,
|
`plane is at ${plane.locationIcao} but the package originates at ${pkg.originIcao}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 origin = await airportByIcao(contract.originIcao);
|
const occupied = (
|
||||||
const dest = await airportByIcao(contract.destIcao);
|
await inst.db.select().from(schema.packages).where(
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
||||||
|
)
|
||||||
|
).length;
|
||||||
|
if (occupied >= type.capacity) {
|
||||||
|
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await inst.db.update(schema.packages)
|
||||||
|
.set({ status: 'loaded', planeId })
|
||||||
|
.where(eq(schema.packages.id, packageId));
|
||||||
|
await persist(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fly (POST /api/planes/:id/fly {destIcao}) ---
|
||||||
|
|
||||||
|
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
|
||||||
|
// loaded packages' destinations or pure repositioning. On arrival the engine
|
||||||
|
// delivers whatever is bound for that airport. Validation: plane 'available',
|
||||||
|
// destination in the airport dataset, enough fuel for the leg.
|
||||||
|
export async function flyPlane(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`);
|
||||||
|
if (plane.status !== 'available') {
|
||||||
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
||||||
|
if (!destFeature) throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||||
|
const origin = await airportByIcao(plane.locationIcao);
|
||||||
|
|
||||||
|
const type = PLANE_TYPES[plane.model];
|
||||||
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
|
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||||
const burnL = type.burnPerKm * distanceKm;
|
const burnL = type.burnPerKm * distanceKm;
|
||||||
if (burnL > plane.fuel) {
|
if (burnL > plane.fuel) {
|
||||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||||
@@ -96,16 +117,49 @@ export async function dispatchPlane(inst: GameDb, planeId: string, contractId: s
|
|||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({
|
.set({
|
||||||
status: 'flying',
|
status: 'flying',
|
||||||
contractId,
|
destIcao,
|
||||||
departureSimTime: sim.clockMs,
|
departureSimTime: sim.clockMs,
|
||||||
lat: origin.lat,
|
lat: origin.lat,
|
||||||
lon: origin.lon,
|
lon: origin.lon,
|
||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId));
|
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Abandon (POST /api/packages/:id/abandon) ---
|
||||||
|
|
||||||
|
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
|
||||||
|
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
||||||
|
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
||||||
|
// goes below €0. The slot is freed immediately.
|
||||||
|
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
|
||||||
|
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
|
const pkg = pkgRows[0];
|
||||||
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||||
|
if (pkg.status !== 'loaded' && pkg.status !== 'expired') {
|
||||||
|
throw new GameActionError(409, `package is ${pkg.status}, only loaded or expired packages can be abandoned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plane = pkg.planeId
|
||||||
|
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
||||||
|
: undefined;
|
||||||
|
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
||||||
|
if (plane.status !== 'available') {
|
||||||
|
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
const penalty = abandonPenalty(pkg, sim.clockMs);
|
||||||
|
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
|
||||||
|
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
||||||
|
await persist(inst);
|
||||||
|
return { penalty };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||||
|
|
||||||
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
||||||
@@ -132,7 +186,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
fuel: type.maxFuel, // full tank on delivery
|
fuel: type.maxFuel, // full tank on delivery
|
||||||
maxFuel: type.maxFuel,
|
maxFuel: type.maxFuel,
|
||||||
locationIcao: home.icao,
|
locationIcao: home.icao,
|
||||||
contractId: null,
|
destIcao: null,
|
||||||
lat: home.lat,
|
lat: home.lat,
|
||||||
lon: home.lon,
|
lon: home.lon,
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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.
|
||||||
|
capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLANE_TYPES: Record<string, PlaneType> = {
|
export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||||
@@ -25,6 +27,7 @@ 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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export interface CatalogEntry {
|
|||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
burnPerKm: number;
|
burnPerKm: number;
|
||||||
speedKmh: number;
|
speedKmh: number;
|
||||||
|
capacity: 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
|
||||||
@@ -59,5 +63,6 @@ export function catalogEntries(): CatalogEntry[] {
|
|||||||
maxFuel: t.maxFuel,
|
maxFuel: t.maxFuel,
|
||||||
burnPerKm: t.burnPerKm,
|
burnPerKm: t.burnPerKm,
|
||||||
speedKmh: t.speedKmh,
|
speedKmh: t.speedKmh,
|
||||||
|
capacity: t.capacity,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { abandonPenalty, abandonPenaltyPct, ABANDON_PENALTY_MIN, ABANDON_PENALTY_SPAN, elapsedFractionFor } from './penalty';
|
||||||
|
|
||||||
|
// The v0.12.0 abandon penalty: 15% of the payout right after creation, scaling
|
||||||
|
// linearly to 60% at the deadline; expired packages cost nothing. Pure math —
|
||||||
|
// the server action and the client preview both call these same functions.
|
||||||
|
|
||||||
|
const T0 = 1_750_000_000_000;
|
||||||
|
const WINDOW_MS = 10 * 3_600_000; // created → deadline span
|
||||||
|
const REWARD = 1_000;
|
||||||
|
|
||||||
|
const pkg = (status: string, over: Partial<{ reward: number; createdSimTime: number; deadlineSimTime: number }> = {}) => ({
|
||||||
|
status,
|
||||||
|
reward: REWARD,
|
||||||
|
createdSimTime: T0,
|
||||||
|
deadlineSimTime: T0 + WINDOW_MS,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('abandonPenaltyPct', () => {
|
||||||
|
it('is exactly 15% at the start of the window (fraction 0)', () => {
|
||||||
|
expect(abandonPenaltyPct(0)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is exactly 60% at the deadline (fraction 1)', () => {
|
||||||
|
expect(abandonPenaltyPct(1)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps out-of-range fractions to [0, 1]', () => {
|
||||||
|
expect(abandonPenaltyPct(-5)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
|
||||||
|
expect(abandonPenaltyPct(2)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is monotonic non-decreasing across the window', () => {
|
||||||
|
let prev = -1;
|
||||||
|
for (let i = 0; i <= 100; i++) {
|
||||||
|
const pct = abandonPenaltyPct(i / 100);
|
||||||
|
expect(pct).toBeGreaterThanOrEqual(prev);
|
||||||
|
prev = pct;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('elapsedFractionFor', () => {
|
||||||
|
it('is 0 at creation, 0.5 at the midpoint, 1 at and past the deadline', () => {
|
||||||
|
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0)).toBe(0);
|
||||||
|
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS / 2)).toBeCloseTo(0.5, 9);
|
||||||
|
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS)).toBe(1);
|
||||||
|
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS * 3)).toBe(1); // clamped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a zero/negative span as fully elapsed (max penalty)', () => {
|
||||||
|
expect(elapsedFractionFor(T0, T0, T0)).toBe(1);
|
||||||
|
expect(elapsedFractionFor(T0 + 5, T0, T0 + 5)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('abandonPenalty', () => {
|
||||||
|
it('charges exactly 15% of the payout right after creation', () => {
|
||||||
|
expect(abandonPenalty(pkg('loaded'), T0)).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales to exactly 37.5% at the midpoint and 60% at/past the deadline', () => {
|
||||||
|
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS / 2)).toBe(Math.round(REWARD * 0.375 * 100) / 100);
|
||||||
|
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS)).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||||
|
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS * 2)).toBe(Math.round(REWARD * 0.6 * 100) / 100); // clamped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is free for an already-expired package regardless of the clock', () => {
|
||||||
|
expect(abandonPenalty(pkg('expired'), T0)).toBe(0);
|
||||||
|
expect(abandonPenalty(pkg('expired'), T0 + WINDOW_MS * 5)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds to cents and scales with the reward, not a fixed amount', () => {
|
||||||
|
// €999.99 × 15% = 149.9985 → 150.00; €333 × 60% = 199.8 exactly.
|
||||||
|
expect(abandonPenalty(pkg('loaded', { reward: 999.99 }), T0)).toBe(150);
|
||||||
|
expect(abandonPenalty(pkg('loaded', { reward: 333 }), T0 + WINDOW_MS)).toBe(199.8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Abandon penalty (v0.12.0) — dropping a loaded package costs a fraction of its
|
||||||
|
// payout that grows with time pressure: 15% right after creation, up to 60% at
|
||||||
|
// the deadline. Pure function shared by the server action (lib/game/actions.ts)
|
||||||
|
// and the client-side preview in components/RightPane.tsx so both always agree.
|
||||||
|
|
||||||
|
export const ABANDON_PENALTY_MIN = 0.15; // fraction of the payout, at creation time
|
||||||
|
export const ABANDON_PENALTY_SPAN = 0.45; // added across the package's lifetime → max 60% at the deadline
|
||||||
|
|
||||||
|
// The penalty fraction for a given elapsedFraction ∈ [0, 1] (clamped).
|
||||||
|
export function abandonPenaltyPct(elapsedFraction: number): number {
|
||||||
|
const f = Math.min(1, Math.max(0, elapsedFraction));
|
||||||
|
return ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How much of the package's lifetime has elapsed at clockMs (clamped to [0, 1]).
|
||||||
|
export function elapsedFractionFor(createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||||
|
const span = deadlineSimTime - createdSimTime;
|
||||||
|
if (span <= 0) return 1; // degenerate — treat as fully elapsed (max penalty)
|
||||||
|
return Math.min(1, Math.max(0, (clockMs - createdSimTime) / span));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact € penalty for abandoning `pkg` at game time clockMs. An already-
|
||||||
|
// EXPIRED package abandons for free; cash is floored at 0 by the caller
|
||||||
|
// (account.cash = max(0, cash − penalty)). Rounded to cents like all money math.
|
||||||
|
export function abandonPenalty(
|
||||||
|
pkg: { status: string; reward: number; createdSimTime: number; deadlineSimTime: number },
|
||||||
|
clockMs: number,
|
||||||
|
): number {
|
||||||
|
if (pkg.status === 'expired') return 0;
|
||||||
|
const pct = abandonPenaltyPct(elapsedFractionFor(pkg.createdSimTime, pkg.deadlineSimTime, clockMs));
|
||||||
|
return Math.round(pkg.reward * pct * 100) / 100;
|
||||||
|
}
|
||||||
+37
-37
@@ -4,9 +4,9 @@ import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
|||||||
import {
|
import {
|
||||||
BASE_REWARD,
|
BASE_REWARD,
|
||||||
buildSeed,
|
buildSeed,
|
||||||
CONTRACT_COUNT_MAX,
|
PACKAGE_COUNT_MAX,
|
||||||
CONTRACT_COUNT_MIN,
|
PACKAGE_COUNT_MIN,
|
||||||
generateContract,
|
generatePackage,
|
||||||
HUB_ICAO,
|
HUB_ICAO,
|
||||||
mulberry32,
|
mulberry32,
|
||||||
REWARD_PER_KM,
|
REWARD_PER_KM,
|
||||||
@@ -41,7 +41,7 @@ describe('buildSeed', () => {
|
|||||||
fuel: type.maxFuel,
|
fuel: type.maxFuel,
|
||||||
maxFuel: type.maxFuel,
|
maxFuel: type.maxFuel,
|
||||||
locationIcao: HUB_ICAO,
|
locationIcao: HUB_ICAO,
|
||||||
contractId: null,
|
destIcao: null,
|
||||||
lat: 50.0333,
|
lat: 50.0333,
|
||||||
lon: 8.5706,
|
lon: 8.5706,
|
||||||
});
|
});
|
||||||
@@ -52,13 +52,13 @@ describe('buildSeed', () => {
|
|||||||
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
|
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds 8–12 open contracts between distinct fixture airports', () => {
|
it('seeds 8–12 open packages between distinct fixture airports', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
|
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
|
||||||
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
|
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
|
||||||
|
|
||||||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||||||
for (const c of seed.contracts) {
|
for (const c of seed.packages) {
|
||||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||||
expect(c.originIcao).not.toBe(c.destIcao);
|
expect(c.originIcao).not.toBe(c.destIcao);
|
||||||
@@ -66,12 +66,12 @@ describe('buildSeed', () => {
|
|||||||
expect(c.planeId).toBeNull();
|
expect(c.planeId).toBeNull();
|
||||||
expect(c.createdSimTime).toBe(NOW);
|
expect(c.createdSimTime).toBe(NOW);
|
||||||
}
|
}
|
||||||
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.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('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.contracts) {
|
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)));
|
||||||
@@ -80,7 +80,7 @@ describe('buildSeed', () => {
|
|||||||
|
|
||||||
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.contracts) {
|
for (const c of seed.packages) {
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ describe('buildSeed', () => {
|
|||||||
expect(seedWith(42)).toEqual(seedWith(42));
|
expect(seedWith(42)).toEqual(seedWith(42));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces different contracts for a different RNG seed', () => {
|
it('produces different packages for a different RNG seed', () => {
|
||||||
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
|
expect(seedWith(1).packages).not.toEqual(seedWith(2).packages);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an airport list too small to form a route', () => {
|
it('rejects an airport list too small to form a route', () => {
|
||||||
@@ -111,9 +111,9 @@ describe('buildSeed', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateContract (shared seed/spawn builder)', () => {
|
describe('generatePackage (shared seed/spawn builder)', () => {
|
||||||
it('builds an open contract between distinct fixture airports with the reward formula', () => {
|
it('builds an open package between distinct fixture airports with the reward formula', () => {
|
||||||
const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const c = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
|
|
||||||
expect(c.id).toBe('c-x');
|
expect(c.id).toBe('c-x');
|
||||||
expect(c.status).toBe('open');
|
expect(c.status).toBe('open');
|
||||||
@@ -134,39 +134,39 @@ describe('generateContract (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
||||||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||||||
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
|
|
||||||
const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
|
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
|
||||||
expect(c.id).toBe('c-2');
|
expect(c.id).toBe('c-2');
|
||||||
expect(c.createdSimTime).toBe(NOW + 1000);
|
expect(c.createdSimTime).toBe(NOW + 1000);
|
||||||
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract
|
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different package
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
|
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
|
||||||
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
|
const c = generatePackage({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
|
||||||
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
|
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
|
||||||
const rngA = mulberry32(42);
|
const rngA = mulberry32(42);
|
||||||
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
const expected: string[] = [];
|
const expected: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
||||||
}
|
}
|
||||||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
|
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an airport list too small to form a route', () => {
|
it('rejects an airport list too small to form a route', () => {
|
||||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
|
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
|
||||||
it('originIcao forces the origin; dest stays random but distinct', () => {
|
it('originIcao forces the origin; dest stays random but distinct', () => {
|
||||||
const c = generateContract({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
|
const c = generatePackage({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
|
||||||
expect(c.originIcao).toBe('EDDF');
|
expect(c.originIcao).toBe('EDDF');
|
||||||
expect(c.destIcao).not.toBe('EDDF');
|
expect(c.destIcao).not.toBe('EDDF');
|
||||||
|
|
||||||
@@ -176,13 +176,13 @@ describe('generateContract (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('destIcao forces the destination; origin stays random but distinct', () => {
|
it('destIcao forces the destination; origin stays random but distinct', () => {
|
||||||
const c = generateContract({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
|
const c = generatePackage({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
|
||||||
expect(c.destIcao).toBe('LEBC');
|
expect(c.destIcao).toBe('LEBC');
|
||||||
expect(c.originIcao).not.toBe('LEBC');
|
expect(c.originIcao).not.toBe('LEBC');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('both forced → the exact requested route with the distance-based reward', () => {
|
it('both forced → the exact requested route with the distance-based reward', () => {
|
||||||
const c = generateContract({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
|
const c = generatePackage({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
|
||||||
expect(c.originIcao).toBe('EDDF');
|
expect(c.originIcao).toBe('EDDF');
|
||||||
expect(c.destIcao).toBe('EHAM');
|
expect(c.destIcao).toBe('EHAM');
|
||||||
|
|
||||||
@@ -192,22 +192,22 @@ describe('generateContract (shared seed/spawn builder)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||||||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||||||
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on an unknown forced ICAO or identical endpoints', () => {
|
it('throws on an unknown forced ICAO or identical endpoints', () => {
|
||||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
|
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
|
||||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
|
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
|
||||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
|
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
|
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
|
||||||
// Same seed as a historical unforced call → same contract, proving forced
|
// Same seed as a historical unforced call → same package, proving forced
|
||||||
// endpoints consume no extra RNG draws when absent.
|
// endpoints consume no extra RNG draws when absent.
|
||||||
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-21
@@ -23,7 +23,7 @@ export function mulberry32(seed: number): Rng {
|
|||||||
a |= 0;
|
a |= 0;
|
||||||
a = (a + 0x6d2b79f5) | 0;
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
t = (t + Math.imul(t ^ (a >>> 7), 61 | t)) ^ t;
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -35,12 +35,12 @@ export interface SeedPlane {
|
|||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
contractId: null;
|
destIcao: null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedContract {
|
export interface SeedPackage {
|
||||||
id: string;
|
id: string;
|
||||||
originIcao: string;
|
originIcao: string;
|
||||||
destIcao: string;
|
destIcao: string;
|
||||||
@@ -55,7 +55,7 @@ export interface SeedContract {
|
|||||||
|
|
||||||
export interface SeedData {
|
export interface SeedData {
|
||||||
plane: SeedPlane;
|
plane: SeedPlane;
|
||||||
contracts: SeedContract[];
|
packages: SeedPackage[];
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
|
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
|
||||||
}
|
}
|
||||||
@@ -70,19 +70,19 @@ export const STARTING_REPUTATION = 10;
|
|||||||
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
||||||
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
|
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
|
||||||
|
|
||||||
export const CONTRACT_COUNT_MIN = 8;
|
export const PACKAGE_COUNT_MIN = 8;
|
||||||
export const CONTRACT_COUNT_MAX = 12; // inclusive
|
export const PACKAGE_COUNT_MAX = 12; // inclusive
|
||||||
|
|
||||||
// Market target (v0.7.0): the sim engine tops open contracts back up to this
|
// Market target (v0.7.0): the sim engine tops open packages back up to this
|
||||||
// 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_CONTRACTS = 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 — deliberately simple; real revenue/cost modeling is a
|
||||||
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
|
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
|
||||||
export const BASE_REWARD = 500;
|
export const BASE_REWARD = 500;
|
||||||
export const REWARD_PER_KM = 2.5;
|
export const REWARD_PER_KM = 2.5;
|
||||||
|
|
||||||
// Contract 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;
|
||||||
const DEADLINE_MAX_HOURS = 72;
|
const DEADLINE_MAX_HOURS = 72;
|
||||||
|
|
||||||
@@ -92,16 +92,16 @@ export function rewardForDistanceKm(km: number): number {
|
|||||||
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
|
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared contract 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 contracts 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-based reward, cargo flavor,
|
||||||
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
|
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
|
||||||
// the e2e test hook to force a near-term expiry). The RNG call order per contract
|
// the e2e test hook to force a near-term expiry). The RNG call order per package
|
||||||
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
|
// is fixed: origin, dest, cargoType, cargoCount, [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-contract 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.
|
||||||
export function generateContract(input: {
|
export function generatePackage(input: {
|
||||||
id: string;
|
id: string;
|
||||||
airports: SeedAirport[];
|
airports: SeedAirport[];
|
||||||
rng: Rng;
|
rng: Rng;
|
||||||
@@ -109,9 +109,9 @@ export function generateContract(input: {
|
|||||||
deadlineOffsetMs?: number;
|
deadlineOffsetMs?: number;
|
||||||
originIcao?: string;
|
originIcao?: string;
|
||||||
destIcao?: string;
|
destIcao?: string;
|
||||||
}): SeedContract {
|
}): SeedPackage {
|
||||||
const { airports, rng, nowMs } = input;
|
const { airports, rng, nowMs } = input;
|
||||||
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
|
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
||||||
|
|
||||||
let oi: number;
|
let oi: number;
|
||||||
if (input.originIcao !== undefined) {
|
if (input.originIcao !== undefined) {
|
||||||
@@ -179,22 +179,22 @@ export function buildSeed(input: {
|
|||||||
fuel: type.maxFuel, // starts full
|
fuel: type.maxFuel, // starts full
|
||||||
maxFuel: type.maxFuel,
|
maxFuel: type.maxFuel,
|
||||||
locationIcao: hub.icao,
|
locationIcao: hub.icao,
|
||||||
contractId: null,
|
destIcao: null,
|
||||||
lat: hub.lat,
|
lat: hub.lat,
|
||||||
lon: hub.lon,
|
lon: hub.lon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
// Stable index-based ids for the seed set; runtime-generated entities must use
|
// Stable index-based ids for the seed set; runtime-generated entities must use
|
||||||
// random UUIDs to avoid colliding with these.
|
// random UUIDs to avoid colliding with these.
|
||||||
const contracts: SeedContract[] = [];
|
const packages: SeedPackage[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs }));
|
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plane,
|
plane,
|
||||||
contracts,
|
packages,
|
||||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
||||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
// by ~0 instead of treating "time since install" as elapsed play time.
|
||||||
|
|||||||
+29
-27
@@ -15,15 +15,18 @@ export interface PlaneState {
|
|||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
contractId: string | null;
|
// Current flight destination (v0.12.0) — set by fly, null when grounded. The
|
||||||
|
// origin of the current leg is always locationIcao while flying.
|
||||||
|
destIcao: string | null;
|
||||||
|
destName: string | null;
|
||||||
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
|
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
|
||||||
// set by dispatch while flying so clients can compute progress/ETA from the clock.
|
// set by fly while flying so clients can compute progress/ETA from the clock.
|
||||||
departureSimTime: number | null;
|
departureSimTime: number | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractState {
|
export interface PackageState {
|
||||||
id: string;
|
id: string;
|
||||||
originIcao: string;
|
originIcao: string;
|
||||||
originName: string;
|
originName: string;
|
||||||
@@ -33,7 +36,7 @@ export interface ContractState {
|
|||||||
cargoType: string;
|
cargoType: string;
|
||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,7 @@ export interface StateSnapshot {
|
|||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
contracts: ContractState[];
|
packages: PackageState[];
|
||||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||||
// the client can compute buy/sell/refuel previews without a separate endpoint.
|
// the client can compute buy/sell/refuel previews without a separate endpoint.
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
@@ -55,11 +58,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
||||||
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
||||||
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
||||||
const [accountRows, simRows, planeRows, contractRows] = await Promise.all([
|
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
|
||||||
db.select().from(schema.account),
|
db.select().from(schema.account),
|
||||||
db.select().from(schema.simState),
|
db.select().from(schema.simState),
|
||||||
db.select().from(schema.planes),
|
db.select().from(schema.planes),
|
||||||
db.select().from(schema.contracts),
|
db.select().from(schema.packages),
|
||||||
]);
|
]);
|
||||||
const accountRow = accountRows[0];
|
const accountRow = accountRows[0];
|
||||||
const simRow = simRows[0];
|
const simRow = simRows[0];
|
||||||
@@ -72,14 +75,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
|
|
||||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||||
// departure values — live position and fuel are pure functions of the clock.
|
// departure values — live position and fuel are pure functions of the clock.
|
||||||
const contractById = new Map(contractRows.map((c) => [c.id, c]));
|
|
||||||
const livePlane = (p: schema.PlaneRow) => {
|
const livePlane = (p: schema.PlaneRow) => {
|
||||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null;
|
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) return null;
|
||||||
const contract = contractById.get(p.contractId);
|
|
||||||
const type = PLANE_TYPES[p.model];
|
const type = PLANE_TYPES[p.model];
|
||||||
const from = contract ? coordsByIcao.get(contract.originIcao) : undefined;
|
const from = coordsByIcao.get(p.locationIcao);
|
||||||
const to = contract ? coordsByIcao.get(contract.destIcao) : undefined;
|
const to = coordsByIcao.get(p.destIcao);
|
||||||
if (!contract || !type || !from || !to) return null;
|
if (!type || !from || !to) return null;
|
||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||||
@@ -102,25 +103,26 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
maxFuel: p.maxFuel,
|
maxFuel: p.maxFuel,
|
||||||
locationIcao: p.locationIcao,
|
locationIcao: p.locationIcao,
|
||||||
locationName: nameOf(p.locationIcao),
|
locationName: nameOf(p.locationIcao),
|
||||||
contractId: p.contractId,
|
destIcao: p.destIcao,
|
||||||
|
destName: p.destIcao ? nameOf(p.destIcao) : null,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
contracts: contractRows.map((c) => ({
|
packages: packageRows.map((p) => ({
|
||||||
id: c.id,
|
id: p.id,
|
||||||
originIcao: c.originIcao,
|
originIcao: p.originIcao,
|
||||||
originName: nameOf(c.originIcao),
|
originName: nameOf(p.originIcao),
|
||||||
destIcao: c.destIcao,
|
destIcao: p.destIcao,
|
||||||
destName: nameOf(c.destIcao),
|
destName: nameOf(p.destIcao),
|
||||||
reward: c.reward,
|
reward: p.reward,
|
||||||
cargoType: c.cargoType,
|
cargoType: p.cargoType,
|
||||||
cargoCount: c.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
deadlineSimTime: c.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: c.status,
|
status: p.status,
|
||||||
planeId: c.planeId,
|
planeId: p.planeId,
|
||||||
createdSimTime: c.createdSimTime,
|
createdSimTime: p.createdSimTime,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-52
@@ -13,8 +13,10 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||||
planes: [],
|
planes: [],
|
||||||
contracts: [],
|
packages: [],
|
||||||
catalog: [{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510 }],
|
catalog: [
|
||||||
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
|
||||||
|
],
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
sellFraction: 0.5,
|
sellFraction: 0.5,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -30,11 +32,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', contractId: 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 },
|
||||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: 'c1', 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 },
|
||||||
],
|
|
||||||
contracts: [
|
|
||||||
{ id: 'c1', originIcao: 'EDDF', originName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', reward: 100, cargoType: 'mail', cargoCount: 5, deadlineSimTime: 9e8, status: 'accepted', planeId: 'plane-2', createdSimTime: 0 },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -52,7 +51,7 @@ describe('planesGeoJson', () => {
|
|||||||
expect(flying.properties.flying).toBe(true);
|
expect(flying.properties.flying).toBe(true);
|
||||||
expect(flying.properties.status).toBe('flying');
|
expect(flying.properties.status).toBe('flying');
|
||||||
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
|
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
|
||||||
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport');
|
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport'); // from the plane's own dest fields (v0.12.0)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the raw model id when the catalog has no entry', () => {
|
it('falls back to the raw model id when the catalog has no entry', () => {
|
||||||
@@ -60,18 +59,18 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
snapshot({
|
||||||
catalog: [],
|
catalog: [],
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: 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 },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves routeText empty for a flying plane whose contract is missing from the snapshot', () => {
|
it('leaves routeText empty for a flying plane without a destination name', () => {
|
||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: 'gone', 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 },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -79,77 +78,62 @@ describe('planesGeoJson', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('routesGeoJson', () => {
|
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
|
||||||
it('returns an empty collection for a null snapshot', () => {
|
it('returns an empty collection for a null snapshot', () => {
|
||||||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws nothing for open/completed/expired contracts', () => {
|
it('draws nothing for grounded planes, whatever their packages', () => {
|
||||||
const mk = (status: string) => ({ id: `c-${status}`, originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status, planeId: null, createdSimTime: 0 });
|
|
||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({ contracts: [mk('open'), mk('completed'), mk('expired')] as StateSnapshot['contracts'] }),
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepted-but-undispatched contract → pending line origin→dest', () => {
|
it('flying plane → one line origin→dest_icao with the plane id', () => {
|
||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
contracts: [
|
planes: [
|
||||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EHAM', destName: 'A', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features).toHaveLength(1);
|
expect(fc.features).toHaveLength(1);
|
||||||
expect(fc.features[0].properties).toEqual({ contractId: 'c1', kind: 'pending' });
|
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
|
||||||
expect(fc.features[0].geometry.coordinates).toEqual([
|
expect(fc.features[0].geometry.coordinates).toEqual([
|
||||||
[8.56, 50.03], // EDDF [lon, lat]
|
[8.56, 50.03], // EDDF [lon, lat]
|
||||||
[4.76, 52.31], // EHAM
|
[4.76, 52.31], // EHAM
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepted + plane flying → inflight line', () => {
|
it('skips a flying plane whose endpoint is missing from the airport dataset', () => {
|
||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: 'c1', departureSimTime: 0, lat: 51, lon: 9 },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 },
|
||||||
],
|
|
||||||
contracts: [
|
|
||||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
AIRPORTS,
|
|
||||||
);
|
|
||||||
expect(fc.features).toHaveLength(1);
|
|
||||||
expect(fc.features[0].properties.kind).toBe('inflight');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepted + planeId set but plane grounded → still pending (defensive)', () => {
|
|
||||||
const fc = routesGeoJson(
|
|
||||||
snapshot({
|
|
||||||
planes: [
|
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
|
||||||
],
|
|
||||||
contracts: [
|
|
||||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
AIRPORTS,
|
|
||||||
);
|
|
||||||
expect(fc.features[0].properties.kind).toBe('pending');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips a contract whose endpoint is missing from the airport dataset', () => {
|
|
||||||
const fc = routesGeoJson(
|
|
||||||
snapshot({
|
|
||||||
contracts: [
|
|
||||||
{ id: 'c1', originIcao: 'ZZZZ', originName: '?', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
expect(fc.features).toHaveLength(0);
|
expect(fc.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('one line per flying plane — multiple planes draw multiple legs', () => {
|
||||||
|
const fc = routesGeoJson(
|
||||||
|
snapshot({
|
||||||
|
planes: [
|
||||||
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 },
|
||||||
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
AIRPORTS,
|
||||||
|
);
|
||||||
|
expect(fc.features.map((f) => f.properties.planeId).sort()).toEqual(['p1', 'p2']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-18
@@ -1,9 +1,10 @@
|
|||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { formatFuel } from './format';
|
import { formatFuel } from './format';
|
||||||
|
|
||||||
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature
|
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
|
||||||
// per plane, one LineString per accepted/in-flight contract. Kept out of
|
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
|
||||||
// components/Map.tsx so the classification logic is unit-testable without a DOM.
|
// current leg (origin → dest_icao). Kept out of components/Map.tsx so the
|
||||||
|
// classification logic is unit-testable without a DOM.
|
||||||
|
|
||||||
export interface PlaneFeatureProps {
|
export interface PlaneFeatureProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,8 +16,7 @@ export interface PlaneFeatureProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteFeatureProps {
|
export interface RouteFeatureProps {
|
||||||
contractId: string;
|
planeId: string;
|
||||||
kind: 'pending' | 'inflight';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapPointFeature {
|
export interface MapPointFeature {
|
||||||
@@ -41,12 +41,10 @@ const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollectio
|
|||||||
|
|
||||||
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
||||||
if (!state) return EMPTY_POINTS;
|
if (!state) return EMPTY_POINTS;
|
||||||
const contractById = new Map(state.contracts.map((c) => [c.id, c]));
|
|
||||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
||||||
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||||
const flying = p.status === 'flying';
|
const flying = p.status === 'flying';
|
||||||
const contract = p.contractId ? contractById.get(p.contractId) : undefined;
|
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||||
@@ -55,7 +53,7 @@ export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<Ma
|
|||||||
modelLabel: labelByModel.get(p.model) ?? p.model,
|
modelLabel: labelByModel.get(p.model) ?? p.model,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
fuelText: formatFuel(p.fuel, p.maxFuel),
|
fuelText: formatFuel(p.fuel, p.maxFuel),
|
||||||
routeText: flying && contract ? `${contract.originName} → ${contract.destName}` : '',
|
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
|
||||||
flying,
|
flying,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -69,22 +67,20 @@ export interface AirportCoord {
|
|||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One LineString per contract that is accepted but not yet dispatched ('pending')
|
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
|
||||||
// or already flying ('inflight' — planeId set and that plane's status is 'flying').
|
// Grounded planes draw nothing; a flying plane missing either endpoint from the
|
||||||
// Open/completed/expired contracts draw nothing.
|
// dataset is skipped, never guessed.
|
||||||
export function routesGeoJson(
|
export function routesGeoJson(
|
||||||
state: StateSnapshot | null,
|
state: StateSnapshot | null,
|
||||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
): FeatureCollection<MapLineFeature> {
|
): FeatureCollection<MapLineFeature> {
|
||||||
if (!state) return EMPTY_LINES;
|
if (!state) return EMPTY_LINES;
|
||||||
const planeById = new Map(state.planes.map((p) => [p.id, p]));
|
|
||||||
const features: MapLineFeature[] = [];
|
const features: MapLineFeature[] = [];
|
||||||
for (const c of state.contracts) {
|
for (const p of state.planes) {
|
||||||
if (c.status !== 'accepted') continue;
|
if (p.status !== 'flying' || !p.destIcao) continue;
|
||||||
const from = airportsByIcao.get(c.originIcao);
|
const from = airportsByIcao.get(p.locationIcao);
|
||||||
const to = airportsByIcao.get(c.destIcao);
|
const to = airportsByIcao.get(p.destIcao);
|
||||||
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
|
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
|
||||||
const plane = c.planeId ? planeById.get(c.planeId) : undefined;
|
|
||||||
features.push({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: {
|
geometry: {
|
||||||
@@ -94,7 +90,7 @@ export function routesGeoJson(
|
|||||||
[to.lon, to.lat],
|
[to.lon, to.lat],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' },
|
properties: { planeId: p.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
|
|||||||
+39
-27
@@ -3,13 +3,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the nine public endpoints (4 read + 5 player actions)', () => {
|
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
'/api/airports',
|
'/api/airports',
|
||||||
'/api/contracts/{id}/accept',
|
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
'/api/planes/{planeId}/dispatch',
|
'/api/packages/{id}/abandon',
|
||||||
|
'/api/packages/{id}/load',
|
||||||
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
@@ -57,7 +58,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(props.version.type).toBe('string');
|
expect(props.version.type).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes /api/state as a StateSnapshot with account, simState, planes and contracts', () => {
|
it('describes /api/state as a StateSnapshot with account, simState, planes and packages', () => {
|
||||||
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
|
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
|
||||||
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(snap).sort()).toEqual([
|
expect(Object.keys(snap).sort()).toEqual([
|
||||||
'account',
|
'account',
|
||||||
'catalog',
|
'catalog',
|
||||||
'contracts',
|
|
||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
@@ -74,8 +75,8 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
|
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
|
||||||
'account',
|
'account',
|
||||||
'catalog',
|
'catalog',
|
||||||
'contracts',
|
|
||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
@@ -83,23 +84,25 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
||||||
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
||||||
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
||||||
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
|
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
||||||
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
||||||
expect(snap.fuelPricePerL.type).toBe('number');
|
expect(snap.fuelPricePerL.type).toBe('number');
|
||||||
expect(snap.sellFraction.type).toBe('number');
|
expect(snap.sellFraction.type).toBe('number');
|
||||||
|
|
||||||
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
|
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
|
||||||
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
|
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'capacity', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
|
||||||
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
|
for (const key of ['burnPerKm', 'maxFuel', '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');
|
||||||
|
|
||||||
const plane = openApiSpec.components.schemas.PlaneState.properties;
|
const plane = openApiSpec.components.schemas.PlaneState.properties;
|
||||||
expect(Object.keys(plane).sort()).toEqual([
|
expect(Object.keys(plane).sort()).toEqual([
|
||||||
'contractId',
|
|
||||||
'departureSimTime',
|
'departureSimTime',
|
||||||
|
'destIcao',
|
||||||
|
'destName',
|
||||||
'fuel',
|
'fuel',
|
||||||
'id',
|
'id',
|
||||||
'lat',
|
'lat',
|
||||||
@@ -111,8 +114,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const contract = openApiSpec.components.schemas.ContractState.properties;
|
const pkg = openApiSpec.components.schemas.PackageState.properties;
|
||||||
expect(Object.keys(contract).sort()).toEqual([
|
expect(Object.keys(pkg).sort()).toEqual([
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
'cargoType',
|
'cargoType',
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
@@ -130,8 +133,8 @@ describe('OpenAPI spec', () => {
|
|||||||
const sim = openApiSpec.components.schemas.SimState.properties;
|
const sim = openApiSpec.components.schemas.SimState.properties;
|
||||||
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
|
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
|
||||||
|
|
||||||
const contractStatus = openApiSpec.components.schemas.ContractState.properties.status;
|
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
||||||
expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']);
|
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
|
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
|
||||||
@@ -157,7 +160,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(error)).toEqual(['error']);
|
expect(Object.keys(error)).toEqual(['error']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents the five action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
it('documents the six 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 {
|
||||||
@@ -168,8 +171,9 @@ describe('OpenAPI spec', () => {
|
|||||||
|
|
||||||
// Every action returns the full StateSnapshot on success (response convention).
|
// Every action returns the full StateSnapshot on success (response convention).
|
||||||
for (const p of [
|
for (const p of [
|
||||||
'/api/contracts/{id}/accept',
|
'/api/packages/{id}/load',
|
||||||
'/api/planes/{planeId}/dispatch',
|
'/api/packages/{id}/abandon',
|
||||||
|
'/api/planes/{planeId}/fly',
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
'/api/market/sell-plane',
|
'/api/market/sell-plane',
|
||||||
'/api/planes/{planeId}/refuel',
|
'/api/planes/{planeId}/refuel',
|
||||||
@@ -180,15 +184,19 @@ describe('OpenAPI spec', () => {
|
|||||||
|
|
||||||
// Error shapes per endpoint.
|
// Error shapes per endpoint.
|
||||||
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
||||||
expect(errorCodes('/api/contracts/{id}/accept')).toEqual(['404', '409']);
|
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/dispatch')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
||||||
|
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/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(['404', '409']);
|
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
|
||||||
|
|
||||||
// Request bodies: dispatch/buy/sell take their dedicated schemas; accept/refuel take none.
|
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none.
|
||||||
expect(post('/api/planes/{planeId}/dispatch').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
'#/components/schemas/DispatchRequest',
|
'#/components/schemas/LoadPackageRequest',
|
||||||
|
);
|
||||||
|
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
|
'#/components/schemas/FlyPlaneRequest',
|
||||||
);
|
);
|
||||||
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',
|
||||||
@@ -196,19 +204,23 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
'#/components/schemas/SellPlaneRequest',
|
'#/components/schemas/SellPlaneRequest',
|
||||||
);
|
);
|
||||||
expect(post('/api/contracts/{id}/accept').requestBody).toBeUndefined();
|
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
|
||||||
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
|
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
|
||||||
|
|
||||||
// Path parameters are declared on the parameterized routes.
|
// Path parameters are declared on the parameterized routes.
|
||||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||||
expect(paramNames('/api/contracts/{id}/accept')).toEqual(['id']);
|
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
|
||||||
expect(paramNames('/api/planes/{planeId}/dispatch')).toEqual(['planeId']);
|
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
|
||||||
|
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
|
||||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
||||||
|
|
||||||
// Request schema shapes.
|
// Request schema shapes.
|
||||||
const dispatch = openApiSpec.components.schemas.DispatchRequest;
|
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
||||||
expect(dispatch.required).toEqual(['contractId']);
|
expect(load.required).toEqual(['planeId']);
|
||||||
expect(dispatch.properties.contractId.type).toBe('string');
|
expect(load.properties.planeId.type).toBe('string');
|
||||||
|
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
|
||||||
|
expect(fly.required).toEqual(['destIcao']);
|
||||||
|
expect(fly.properties.destIcao.type).toBe('string');
|
||||||
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');
|
||||||
|
|||||||
+89
-34
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'Flight Ops API',
|
title: 'Flight Ops API',
|
||||||
description:
|
description:
|
||||||
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (accept / dispatch / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -37,7 +37,7 @@ export const openApiSpec = {
|
|||||||
get: {
|
get: {
|
||||||
summary: 'Game state snapshot',
|
summary: 'Game state snapshot',
|
||||||
description:
|
description:
|
||||||
'Full current game state for client polling — account, sim clock, planes and contracts, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
|
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
||||||
operationId: 'getState',
|
operationId: 'getState',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -55,7 +55,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Set sim time controls',
|
summary: 'Set sim time controls',
|
||||||
description:
|
description:
|
||||||
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; contract expiry, arrival finalization and market spawn run inside that tick.',
|
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.',
|
||||||
operationId: 'setSimControls',
|
operationId: 'setSimControls',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: false,
|
required: false,
|
||||||
@@ -102,37 +102,75 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/contracts/{id}/accept': {
|
'/api/packages/{id}/load': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Accept a contract',
|
summary: 'Load a package onto a plane',
|
||||||
description:
|
description:
|
||||||
'Claim an open contract: status flips open → accepted (v0.8.0). The sim clock is ticked first, so the returned snapshot reflects current game time. No body.',
|
'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.',
|
||||||
operationId: 'acceptContract',
|
operationId: 'loadPackage',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
],
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/LoadPackageRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Accepted — full state snapshot (contract now "accepted")',
|
description: 'Loaded — full state snapshot (package now "loaded" on that plane)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown contract id',
|
description: 'Unknown package id or plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'Contract is not "open" (accepted / completed / expired)',
|
description:
|
||||||
|
'Package not "open", plane not grounded, plane not at the origin, or no free slot on the plane',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/planes/{planeId}/dispatch': {
|
'/api/packages/{id}/abandon': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Dispatch a plane on an accepted contract',
|
summary: 'Abandon a loaded package',
|
||||||
description:
|
description:
|
||||||
'Bind an ACCEPTED contract to a grounded plane at its origin (v0.8.0). The plane becomes "flying" with departureSimTime = current sim clock; the engine then flies it analytically and pays out reward + reputation on arrival. Validation: contract must be "accepted", plane "available" and located at the contract origin, with enough fuel for the leg (burnPerKm × great-circle km).',
|
'Drop a LOADED (or already EXPIRED) package while its plane is GROUNDED (v0.12.0). Penalty = fraction of the payout scaling with time pressure — 15% at creation up to 60% at the deadline; expired packages cost nothing and cash floors at €0. The slot is freed immediately. No body.',
|
||||||
operationId: 'dispatchPlane',
|
operationId: 'abandonPackage',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown package id',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Package not "loaded"/"expired", or its plane is in flight',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/planes/{planeId}/fly': {
|
||||||
|
post: {
|
||||||
|
summary: 'Fly a plane to an airport',
|
||||||
|
description:
|
||||||
|
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there.',
|
||||||
|
operationId: 'flyPlane',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
],
|
],
|
||||||
@@ -140,26 +178,26 @@ export const openApiSpec = {
|
|||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/DispatchRequest' },
|
schema: { $ref: '#/components/schemas/FlyPlaneRequest' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Dispatched — full state snapshot (plane "flying", departure set)',
|
description: 'Flying — full state snapshot (plane "flying", departure + destination set)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description: 'Invalid body (not an object, unknown field, or missing/non-string contractId)',
|
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' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown plane id or contract id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description:
|
description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)',
|
||||||
'Contract not "accepted", plane not "available", plane not at the origin, or insufficient fuel for the leg',
|
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -317,9 +355,9 @@ export const openApiSpec = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/PlaneState' },
|
items: { $ref: '#/components/schemas/PlaneState' },
|
||||||
},
|
},
|
||||||
contracts: {
|
packages: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/ContractState' },
|
items: { $ref: '#/components/schemas/PackageState' },
|
||||||
},
|
},
|
||||||
catalog: {
|
catalog: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -329,19 +367,20 @@ export const openApiSpec = {
|
|||||||
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
|
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
|
||||||
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
||||||
},
|
},
|
||||||
required: ['account', 'simState', 'planes', 'contracts', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
||||||
},
|
},
|
||||||
CatalogEntry: {
|
CatalogEntry: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/dispatch.' },
|
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
|
||||||
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
|
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
|
||||||
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
|
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
|
||||||
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
|
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
|
||||||
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
|
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
|
||||||
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).' },
|
||||||
},
|
},
|
||||||
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh'],
|
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh', 'capacity'],
|
||||||
},
|
},
|
||||||
AccountState: {
|
AccountState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -373,12 +412,19 @@ export const openApiSpec = {
|
|||||||
paused: { type: 'boolean' },
|
paused: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DispatchRequest: {
|
LoadPackageRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
contractId: { type: 'string', description: 'An ACCEPTED contract whose origin the plane is at.' },
|
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' },
|
||||||
},
|
},
|
||||||
required: ['contractId'],
|
required: ['planeId'],
|
||||||
|
},
|
||||||
|
FlyPlaneRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — a loaded package\'s destination or repositioning.' },
|
||||||
|
},
|
||||||
|
required: ['destIcao'],
|
||||||
},
|
},
|
||||||
BuyPlaneRequest: {
|
BuyPlaneRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -411,10 +457,17 @@ export const openApiSpec = {
|
|||||||
maxFuel: { type: 'number' },
|
maxFuel: { type: 'number' },
|
||||||
locationIcao: { type: 'string' },
|
locationIcao: { type: 'string' },
|
||||||
locationName: { type: 'string' },
|
locationName: { type: 'string' },
|
||||||
contractId: { type: ['string', 'null'] },
|
destIcao: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: 'Current flight destination (set by fly, cleared on arrival); null when grounded.',
|
||||||
|
},
|
||||||
|
destName: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: 'Display name of destIcao; null when grounded.',
|
||||||
|
},
|
||||||
departureSimTime: {
|
departureSimTime: {
|
||||||
type: ['integer', 'null'],
|
type: ['integer', 'null'],
|
||||||
description: 'Sim-clock epoch ms of the current flight (set by dispatch); null when grounded.',
|
description: 'Sim-clock epoch ms of the current flight (set by fly); null when grounded.',
|
||||||
},
|
},
|
||||||
lat: { type: 'number' },
|
lat: { type: 'number' },
|
||||||
lon: { type: 'number' },
|
lon: { type: 'number' },
|
||||||
@@ -427,13 +480,14 @@ export const openApiSpec = {
|
|||||||
'maxFuel',
|
'maxFuel',
|
||||||
'locationIcao',
|
'locationIcao',
|
||||||
'locationName',
|
'locationName',
|
||||||
'contractId',
|
'destIcao',
|
||||||
|
'destName',
|
||||||
'departureSimTime',
|
'departureSimTime',
|
||||||
'lat',
|
'lat',
|
||||||
'lon',
|
'lon',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ContractState: {
|
PackageState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
@@ -445,8 +499,9 @@ export const openApiSpec = {
|
|||||||
cargoType: { type: 'string' },
|
cargoType: { type: 'string' },
|
||||||
cargoCount: { type: 'integer' },
|
cargoCount: { type: 'integer' },
|
||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open contract whose deadline passed; accepted/in-flight contracts never expire.
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
status: { enum: ['open', 'accepted', 'completed', 'expired'] },
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
|
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
|
||||||
planeId: { type: ['string', 'null'] },
|
planeId: { type: ['string', 'null'] },
|
||||||
createdSimTime: { type: 'integer' },
|
createdSimTime: { type: 'integer' },
|
||||||
},
|
},
|
||||||
|
|||||||
+135
-102
@@ -8,15 +8,15 @@ 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 { MARKET_TARGET_OPEN_CONTRACTS, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor } from './movement';
|
import { arrivalTimeFor } from './movement';
|
||||||
import {
|
import {
|
||||||
expireContracts,
|
deliverPackages,
|
||||||
finalizeArrivals,
|
expirePackages,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
reputationGainFor,
|
reputationGainFor,
|
||||||
spawnCoverageContracts,
|
spawnCoveragePackages,
|
||||||
spawnOpenContracts,
|
spawnOpenPackages,
|
||||||
tickOnce,
|
tickOnce,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
|
|
||||||
@@ -91,37 +91,41 @@ describe('tickOnce — clock advance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('expireContracts', () => {
|
describe('expirePackages', () => {
|
||||||
it('flips only open contracts past their deadline; accepted ones never expire', async () => {
|
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
|
||||||
const inst = await freshDb('expiry.db');
|
const inst = await freshDb('expiry.db');
|
||||||
const clock = T0 + 100_000;
|
const clock = T0 + 100_000;
|
||||||
|
|
||||||
await inst.db.insert(schema.contracts).values([
|
await inst.db.insert(schema.packages).values([
|
||||||
{ id: 'c-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||||
{ id: 'c-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||||
{ id: 'c-accepted-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'accepted', planeId: null, createdSimTime: T0 },
|
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
|
||||||
|
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const n = await expireContracts(inst, clock);
|
const n = await expirePackages(inst, clock);
|
||||||
expect(n).toBe(1);
|
expect(n).toBe(2); // the open and the loaded one
|
||||||
|
|
||||||
const byId = new Map((await inst.db.select().from(schema.contracts)).map((c) => [c.id, c.status]));
|
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
|
||||||
expect(byId.get('c-open-past')).toBe('expired');
|
expect(byId.get('p-open-past')).toBe('expired');
|
||||||
expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet
|
expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet
|
||||||
expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire
|
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
||||||
|
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||||
|
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a deadline exactly at the clock counts as expired', async () => {
|
it('a deadline exactly at the clock counts as expired', async () => {
|
||||||
const inst = await freshDb('expiry-edge.db');
|
const inst = await freshDb('expiry-edge.db');
|
||||||
await inst.db.insert(schema.contracts).values({
|
await inst.db.insert(schema.packages).values({
|
||||||
id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
|
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
|
||||||
});
|
});
|
||||||
expect(await expireContracts(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('finalizeArrivals', () => {
|
describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||||
const FROM = 'EDDF';
|
const FROM = 'EDDF';
|
||||||
const TO = 'LEMD';
|
const TO = 'LEMD';
|
||||||
|
|
||||||
@@ -133,25 +137,21 @@ describe('finalizeArrivals', () => {
|
|||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const type = PLANE_TYPES.atr72;
|
const type = PLANE_TYPES.atr72;
|
||||||
|
|
||||||
// A dispatched flight: contract accepted + bound to the plane, plane flying
|
// A manual flight (v0.12.0 fly): plane flying with dest_icao set and its fuel
|
||||||
// with its fuel frozen at departure (the v0.8.0 dispatch step writes these).
|
// frozen at departure — no contract involved anymore.
|
||||||
await inst.db.insert(schema.contracts).values({
|
|
||||||
id: 'c-flight', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers',
|
|
||||||
cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: 'plane-starter', createdSimTime: T0,
|
|
||||||
});
|
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', contractId: 'c-flight', departureSimTime: T0, fuel: type.maxFuel })
|
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
return { inst, from, to, km, type };
|
return { inst, from, to, km, type };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does nothing before the arrival time', async () => {
|
it('does nothing before the arrival time', async () => {
|
||||||
const { inst, km, type } = await setupFlight('finalize-early.db');
|
const { inst, km, type } = await setupFlight('deliver-early.db');
|
||||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
||||||
expect(await finalizeArrivals(inst, clock)).toBe(0);
|
expect(await deliverPackages(inst, clock)).toBe(0);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||||
@@ -159,12 +159,20 @@ describe('finalizeArrivals', () => {
|
|||||||
expect(account.cash).toBe(accountBefore.cash); // no award before landing
|
expect(account.cash).toBe(accountBefore.cash); // no award before landing
|
||||||
});
|
});
|
||||||
|
|
||||||
it('awards cash + reputation exactly once and grounds the plane at the destination', async () => {
|
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
||||||
const { inst, to, km, type } = await setupFlight('finalize-late.db');
|
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
|
||||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
|
||||||
|
// Three packages on the plane: one in-deadline for TO, one expired for TO,
|
||||||
|
// one loaded for a DIFFERENT airport (stays bound for a later leg).
|
||||||
|
await inst.db.insert(schema.packages).values([
|
||||||
|
{ id: 'p-pay', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers', cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
{ id: 'p-late', originIcao: FROM, destIcao: TO, reward: 7_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 3_600_000, status: 'expired', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
expect(await finalizeArrivals(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available');
|
expect(plane.status).toBe('available');
|
||||||
@@ -173,65 +181,90 @@ describe('finalizeArrivals', () => {
|
|||||||
expect(plane.lon).toBe(to.lon);
|
expect(plane.lon).toBe(to.lon);
|
||||||
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
|
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
|
||||||
expect(plane.departureSimTime).toBeNull();
|
expect(plane.departureSimTime).toBeNull();
|
||||||
|
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
|
||||||
|
|
||||||
const contract = (await inst.db.select().from(schema.contracts)).find((c) => c.id === 'c-flight')!;
|
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p]));
|
||||||
expect(contract.status).toBe('completed');
|
expect(byId.get('p-pay')!.status).toBe('delivered');
|
||||||
|
expect(byId.get('p-late')!.status).toBe('expired'); // still expired — just unbound…
|
||||||
|
expect(byId.get('p-late')!.planeId).toBeNull(); // …slot freed, no reward
|
||||||
|
expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo…
|
||||||
|
expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // exact reward
|
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out
|
||||||
expect(account.reputation).toBeCloseTo(
|
expect(account.reputation).toBeCloseTo(
|
||||||
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
||||||
9,
|
9,
|
||||||
);
|
);
|
||||||
|
|
||||||
// A later tick must not award again — the plane is no longer 'flying'.
|
// A later tick must not award again — the plane is no longer 'flying'.
|
||||||
expect(await finalizeArrivals(inst, clock + 3_600_000)).toBe(0);
|
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
|
||||||
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
|
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
|
||||||
expect(accountAfter.cash).toBe(account.cash);
|
expect(accountAfter.cash).toBe(account.cash);
|
||||||
expect(accountAfter.reputation).toBe(account.reputation);
|
expect(accountAfter.reputation).toBe(account.reputation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
|
||||||
|
const { inst, km } = await setupFlight('deliver-multi.db');
|
||||||
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
|
||||||
|
await inst.db.insert(schema.packages).values([
|
||||||
|
{ id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards
|
||||||
|
expect(account.reputation).toBeCloseTo(
|
||||||
|
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
|
||||||
|
9,
|
||||||
|
); // rep per delivered package
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves planes without route info alone', async () => {
|
it('leaves planes without route info alone', async () => {
|
||||||
const inst = await freshDb('finalize-noroute.db');
|
const inst = await freshDb('deliver-noroute.db');
|
||||||
// Flying plane whose contract row is missing — defensive skip, no crash.
|
// Flying plane whose dest_icao is missing — defensive skip, no crash.
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' });
|
.set({ status: 'flying', departureSimTime: T0 });
|
||||||
expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0);
|
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('spawnOpenContracts', () => {
|
describe('spawnOpenPackages', () => {
|
||||||
it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => {
|
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
|
||||||
const inst = await freshDb('spawn.db');
|
const inst = await freshDb('spawn.db');
|
||||||
const clock = T0 + 123_456;
|
const clock = T0 + 123_456;
|
||||||
|
|
||||||
// Leave exactly 3 open contracts (the rest of the seeded set is "done").
|
// Leave exactly 3 open packages (the rest of the seeded set is "done").
|
||||||
const all = await inst.db.select().from(schema.contracts);
|
const all = await inst.db.select().from(schema.packages);
|
||||||
for (const c of all.slice(3)) {
|
for (const p of all.slice(3)) {
|
||||||
await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id));
|
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const needed = await spawnOpenContracts(inst, clock, mulberry32(7));
|
const needed = await spawnOpenPackages(inst, clock, mulberry32(7));
|
||||||
expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3);
|
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3);
|
||||||
|
|
||||||
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
||||||
for (const c of openRows.slice(3)) { // the spawned ones
|
for (const p of openRows.slice(3)) { // the spawned ones
|
||||||
expect(c.createdSimTime).toBe(clock); // created at game time, not real time
|
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
|
||||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
expect(icaoSet.has(p.originIcao)).toBe(true);
|
||||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
expect(icaoSet.has(p.destIcao)).toBe(true);
|
||||||
expect(c.originIcao).not.toBe(c.destIcao);
|
expect(p.originIcao).not.toBe(p.destIcao);
|
||||||
expect(c.planeId).toBeNull();
|
expect(p.planeId).toBeNull();
|
||||||
// Same deadline window as the seed: now + 6–72h game time.
|
// Same deadline window as the seed: now + 6–72h game time.
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already at target → nothing more to spawn.
|
// Already at target → nothing more to spawn.
|
||||||
expect(await spawnOpenContracts(inst, clock, mulberry32(7))).toBe(0);
|
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
|
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
|
||||||
@@ -239,103 +272,103 @@ describe('spawnOpenContracts', () => {
|
|||||||
const b = await freshDb('spawn-b.db');
|
const b = await freshDb('spawn-b.db');
|
||||||
const clock = T0 + 999;
|
const clock = T0 + 999;
|
||||||
|
|
||||||
// Wipe all contracts in both so both spawn from zero with the same RNG.
|
// Wipe all packages in both so both spawn from zero with the same RNG.
|
||||||
for (const inst of [a, b]) {
|
for (const inst of [a, b]) {
|
||||||
const rows = await inst.db.select().from(schema.contracts);
|
const rows = await inst.db.select().from(schema.packages);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, r.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, r.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await spawnOpenContracts(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||||
expect(await spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||||
|
|
||||||
const strip = (rows: schema.ContractRow[]) =>
|
const strip = (rows: schema.PackageRow[]) =>
|
||||||
rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
||||||
expect(strip(await a.db.select().from(schema.contracts))).toEqual(
|
expect(strip(await a.db.select().from(schema.packages))).toEqual(
|
||||||
strip(await b.db.select().from(schema.contracts)),
|
strip(await b.db.select().from(schema.packages)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => {
|
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
||||||
// The seeded starter plane is grounded at EDDF with no seeded contract from
|
// The seeded starter plane is grounded at EDDF with no seeded package from
|
||||||
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
|
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
|
||||||
|
|
||||||
it('spawns an open contract originating at the airport of each uncovered grounded plane', async () => {
|
it('spawns an open package originating at the airport of each uncovered grounded plane', async () => {
|
||||||
const inst = await freshDb('coverage.db');
|
const inst = await freshDb('coverage.db');
|
||||||
const clock = T0 + 5_000;
|
const clock = T0 + 5_000;
|
||||||
|
|
||||||
// Wipe all seeded contracts so coverage is the only source of new ones.
|
// Wipe all seeded packages so coverage is the only source of new ones.
|
||||||
for (const c of await inst.db.select().from(schema.contracts)) {
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const n = await spawnCoverageContracts(inst, clock, mulberry32(7));
|
const n = await spawnCoveragePackages(inst, clock, mulberry32(7));
|
||||||
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
|
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
|
||||||
|
|
||||||
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
|
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||||
expect(openRows.length).toBe(1);
|
expect(openRows.length).toBe(1);
|
||||||
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
|
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
|
||||||
expect(openRows[0].createdSimTime).toBe(clock);
|
expect(openRows[0].createdSimTime).toBe(clock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when every grounded airport already has an open contract', async () => {
|
it('does nothing when every grounded airport already has an open package', async () => {
|
||||||
const inst = await freshDb('coverage-covered.db');
|
const inst = await freshDb('coverage-covered.db');
|
||||||
for (const c of await inst.db.select().from(schema.contracts)) {
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
// One open contract from EDDF covers the starter plane's airport.
|
// One open package from EDDF covers the starter plane's airport.
|
||||||
await inst.db.insert(schema.contracts).values({
|
await inst.db.insert(schema.packages).values({
|
||||||
id: 'c-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
|
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0);
|
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0);
|
||||||
expect((await inst.db.select().from(schema.contracts)).length).toBe(1); // nothing added
|
expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added
|
||||||
});
|
});
|
||||||
|
|
||||||
it('spawns one contract per uncovered airport when planes sit at several airports', async () => {
|
it('spawns one package per uncovered airport when planes sit at several airports', async () => {
|
||||||
const inst = await freshDb('coverage-multi.db');
|
const inst = await freshDb('coverage-multi.db');
|
||||||
for (const c of await inst.db.select().from(schema.contracts)) {
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
|
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
|
||||||
await inst.db.insert(schema.planes).values([
|
await inst.db.insert(schema.planes).values([
|
||||||
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', contractId: null, lat: 40.4, lon: -3.6, departureSimTime: null },
|
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null },
|
||||||
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null },
|
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7));
|
const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7));
|
||||||
expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD
|
expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD
|
||||||
|
|
||||||
const origins = (await inst.db.select().from(schema.contracts))
|
const origins = (await inst.db.select().from(schema.packages))
|
||||||
.filter((c) => c.status === 'open')
|
.filter((p) => p.status === 'open')
|
||||||
.map((c) => c.originIcao)
|
.map((p) => p.originIcao)
|
||||||
.sort();
|
.sort();
|
||||||
expect(origins).toEqual(['EDDF', 'LEMD']);
|
expect(origins).toEqual(['EDDF', 'LEMD']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores flying planes — they cannot take new work until they land', async () => {
|
it('ignores flying planes — they cannot take new work until they land', async () => {
|
||||||
const inst = await freshDb('coverage-flying.db');
|
const inst = await freshDb('coverage-flying.db');
|
||||||
for (const c of await inst.db.select().from(schema.contracts)) {
|
for (const p of await inst.db.select().from(schema.packages)) {
|
||||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
|
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||||
}
|
}
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
|
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs inside tickOnce: a fresh DB gains an EDDF-origin contract after one advancing tick', async () => {
|
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
|
||||||
const inst = await freshDb('coverage-tick.db');
|
const inst = await freshDb('coverage-tick.db');
|
||||||
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
|
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
|
||||||
|
|
||||||
await tickOnce(inst);
|
await tickOnce(inst);
|
||||||
|
|
||||||
const fromEdff = (await inst.db.select().from(schema.contracts)).filter(
|
const fromEdff = (await inst.db.select().from(schema.packages)).filter(
|
||||||
(c) => c.status === 'open' && c.originIcao === 'EDDF',
|
(p) => p.status === 'open' && p.originIcao === 'EDDF',
|
||||||
);
|
);
|
||||||
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
|
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
|
||||||
});
|
});
|
||||||
|
|||||||
+66
-54
@@ -1,13 +1,13 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
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 {
|
import {
|
||||||
generateContract,
|
generatePackage,
|
||||||
MARKET_TARGET_OPEN_CONTRACTS,
|
MARKET_TARGET_OPEN_PACKAGES,
|
||||||
mulberry32,
|
mulberry32,
|
||||||
type Rng,
|
type Rng,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
@@ -29,7 +29,7 @@ export const MAX_TICK_ELAPSED_MS = 2_000;
|
|||||||
export const MIN_TIME_SCALE = 1;
|
export const MIN_TIME_SCALE = 1;
|
||||||
export const MAX_TIME_SCALE = 64; // top of the power-of-two ladder (v0.9.2)
|
export const MAX_TIME_SCALE = 64; // top of the power-of-two ladder (v0.9.2)
|
||||||
|
|
||||||
// Reputation gain per completed contract: BASE + PER_KM × great-circle km, rounded
|
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
||||||
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
||||||
// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10.
|
// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10.
|
||||||
export const REP_BASE = 1;
|
export const REP_BASE = 1;
|
||||||
@@ -72,12 +72,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||||
|
|
||||||
if (advanceMs > 0) {
|
if (advanceMs > 0) {
|
||||||
await expireContracts(inst, clockMs);
|
await expirePackages(inst, clockMs);
|
||||||
await finalizeArrivals(inst, clockMs);
|
await deliverPackages(inst, clockMs);
|
||||||
await spawnOpenContracts(inst, clockMs);
|
await spawnOpenPackages(inst, clockMs);
|
||||||
// AFTER finalization on purpose: a plane that just landed gets work spawned
|
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||||
// at its new location within the same tick.
|
// at its new location within the same tick.
|
||||||
await spawnCoverageContracts(inst, clockMs);
|
await spawnCoveragePackages(inst, clockMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||||
@@ -88,42 +88,45 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight
|
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
|
||||||
// contracts never expire — they complete. Returns the number flipped.
|
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
|
||||||
export async function expireContracts(inst: GameDb, clockMs: number): Promise<number> {
|
// occupying their slot, deliver no reward on arrival, and abandon for free).
|
||||||
const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'));
|
// Delivered/abandoned packages are terminal and never flip. Returns the count.
|
||||||
|
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
|
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const c of open) {
|
for (const p of rows) {
|
||||||
if (c.deadlineSimTime <= clockMs) {
|
if (p.deadlineSimTime <= clockMs) {
|
||||||
await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id));
|
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize in-flight planes whose arrival time has passed: award the contract
|
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||||
// reward (cash) + reputation gain exactly once, mark the contract completed, and
|
// ground the plane at its dest_icao with its remaining fuel, then for every
|
||||||
// ground the plane at the destination with its remaining fuel. Idempotent — a
|
// package bound to it and destined for that airport — 'loaded' ones still inside
|
||||||
// finalized plane leaves the 'flying' state, so it can never be awarded twice.
|
// their deadline are delivered (cash += reward + reputation gain per package),
|
||||||
export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<number> {
|
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
|
||||||
const [planeRows, contractRows] = await Promise.all([
|
// on the plane for a later leg. Idempotent — a finalized plane leaves the
|
||||||
|
// 'flying' state, so it can never be awarded twice. Returns planes landed.
|
||||||
|
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
|
const [planeRows, packageRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.planes),
|
inst.db.select().from(schema.planes),
|
||||||
inst.db.select().from(schema.contracts),
|
inst.db.select().from(schema.packages),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||||
const contractById = new Map(contractRows.map((c) => [c.id, c]));
|
|
||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const p of planeRows) {
|
for (const p of planeRows) {
|
||||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue;
|
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) continue;
|
||||||
const contract = contractById.get(p.contractId);
|
|
||||||
const type = PLANE_TYPES[p.model];
|
const type = PLANE_TYPES[p.model];
|
||||||
const from = contract ? byIcao.get(contract.originIcao) : undefined;
|
const from = byIcao.get(p.locationIcao);
|
||||||
const to = contract ? byIcao.get(contract.destIcao) : undefined;
|
const to = byIcao.get(p.destIcao);
|
||||||
if (!contract || !type || !from || !to) continue; // no route info — leave alone
|
if (!type || !from || !to) continue; // no route info — leave alone
|
||||||
|
|
||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||||
@@ -137,35 +140,44 @@ export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<n
|
|||||||
lon: to.lon,
|
lon: to.lon,
|
||||||
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
|
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
|
destIcao: null,
|
||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, p.id));
|
.where(eq(schema.planes.id, p.id));
|
||||||
await inst.db.update(schema.contracts)
|
|
||||||
.set({ status: 'completed' })
|
// Deliver every bound package whose destination is the airport just reached.
|
||||||
.where(eq(schema.contracts.id, contract.id));
|
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||||
// Fresh account read per award so multiple arrivals in one tick all count.
|
for (const pk of onBoard) {
|
||||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||||
if (!fresh) throw new Error('account row missing');
|
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
||||||
await inst.db.update(schema.account).set({
|
// Fresh account read per award so multiple deliveries in one tick all count.
|
||||||
cash: fresh.cash + contract.reward,
|
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
if (!fresh) throw new Error('account row missing');
|
||||||
});
|
await inst.db.update(schema.account).set({
|
||||||
|
cash: fresh.cash + pk.reward,
|
||||||
|
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
||||||
|
});
|
||||||
|
} else if (pk.status === 'expired') {
|
||||||
|
// Expired cargo keeps no value — just free the slot.
|
||||||
|
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the
|
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
|
||||||
// SAME builder as the first-run seed (generateContract), so spawned contracts are
|
// SAME builder as the first-run seed (generatePackage), so spawned packages are
|
||||||
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
|
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
|
||||||
export async function spawnOpenContracts(
|
export async function spawnOpenPackages(
|
||||||
inst: GameDb,
|
inst: GameDb,
|
||||||
clockMs: number,
|
clockMs: number,
|
||||||
rng?: Rng,
|
rng?: Rng,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const openCount = (
|
const openCount = (
|
||||||
await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'))
|
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
|
||||||
).length;
|
).length;
|
||||||
const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount;
|
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
|
||||||
if (needed <= 0) return 0;
|
if (needed <= 0) return 0;
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
@@ -178,27 +190,27 @@ export async function spawnOpenContracts(
|
|||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||||
|
|
||||||
for (let i = 0; i < needed; i++) {
|
for (let i = 0; i < needed; i++) {
|
||||||
await inst.db.insert(schema.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
||||||
}
|
}
|
||||||
return needed;
|
return needed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
|
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
|
||||||
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
|
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
|
||||||
// contract originating there — so work is always available where the fleet sits
|
// package originating there — so work is always available where the fleet sits
|
||||||
// (including onboarding at EDDF). One contract per uncovered airport, generated
|
// (including onboarding at EDDF). One package per uncovered airport, generated
|
||||||
// with generateContract and origin forced to that airport. Runs after
|
// with generatePackage and origin forced to that airport. Runs after
|
||||||
// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage
|
// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage
|
||||||
// too; and after finalizeArrivals, so a plane that just landed gets work spawned
|
// too; and after deliverPackages, so a plane that just landed gets work spawned
|
||||||
// at its new location within the same tick. Returns the number created.
|
// at its new location within the same tick. Returns the number created.
|
||||||
export async function spawnCoverageContracts(
|
export async function spawnCoveragePackages(
|
||||||
inst: GameDb,
|
inst: GameDb,
|
||||||
clockMs: number,
|
clockMs: number,
|
||||||
rng?: Rng,
|
rng?: Rng,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const [planeRows, openRows] = await Promise.all([
|
const [planeRows, openRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.planes),
|
inst.db.select().from(schema.planes),
|
||||||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')),
|
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
// Distinct airports with at least one grounded plane (flying planes don't count —
|
||||||
@@ -224,8 +236,8 @@ export async function spawnCoverageContracts(
|
|||||||
let n = 0;
|
let n = 0;
|
||||||
for (const icao of missing) {
|
for (const icao of missing) {
|
||||||
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
||||||
await inst.db.insert(schema.contracts).values(
|
await inst.db.insert(schema.packages).values(
|
||||||
generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
||||||
);
|
);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SNAPSHOT = {
|
|||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
|
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
|
||||||
planes: [],
|
planes: [],
|
||||||
contracts: [],
|
packages: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
// Browsers run on the shared remote Playwright server (Docker, 192.168.1.4:3000);
|
const remoteRunner = true;
|
||||||
// the app under test is always the prod build behind Traefik (docker compose).
|
|
||||||
// NOTE: never point this at a local `next dev` — Turbopack's HMR WS fails for
|
|
||||||
// non-loopback clients and the page renders blank; a second instance also splits state.
|
|
||||||
const remoteRunner = process.env.E2E_REMOTE === '1';
|
|
||||||
|
|
||||||
if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) {
|
if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) {
|
||||||
process.env.PW_TEST_CONNECT_WS_ENDPOINT = 'ws://192.168.1.4:3000/';
|
process.env.PW_TEST_CONNECT_WS_ENDPOINT = 'ws://192.168.1.4:3000/';
|
||||||
@@ -15,7 +11,7 @@ export default defineConfig({
|
|||||||
// Serial execution: e2e/state.spec.ts restarts the compose container mid-run,
|
// Serial execution: e2e/state.spec.ts restarts the compose container mid-run,
|
||||||
// which would break any map test running concurrently in another worker.
|
// which would break any map test running concurrently in another worker.
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 300_000,
|
||||||
retries: 1,
|
retries: 1,
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl',
|
baseURL: process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl',
|
||||||
@@ -25,8 +21,8 @@ export default defineConfig({
|
|||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
command: 'docker compose up -d --build',
|
command: 'docker compose up -d --build',
|
||||||
url: 'https://flight.dev.kkhost.pl/api/airports',
|
url: 'https://flight.dev.kkhost.pl',
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 300_000,
|
timeout: 600_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user