diff --git a/CHANGELOG.md b/CHANGELOG.md index 965ef6f..ebc53fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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 - **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 diff --git a/app/api/contracts/[id]/accept/route.test.ts b/app/api/contracts/[id]/accept/route.test.ts deleted file mode 100644 index 98096c7..0000000 --- a/app/api/contracts/[id]/accept/route.test.ts +++ /dev/null @@ -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>; - 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/); - }); -}); diff --git a/app/api/contracts/[id]/accept/route.ts b/app/api/contracts/[id]/accept/route.ts deleted file mode 100644 index d1a0647..0000000 --- a/app/api/contracts/[id]/accept/route.ts +++ /dev/null @@ -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)); -} diff --git a/app/api/market/sell-plane/route.test.ts b/app/api/market/sell-plane/route.test.ts index 161539c..70d161d 100644 --- a/app/api/market/sell-plane/route.test.ts +++ b/app/api/market/sell-plane/route.test.ts @@ -49,7 +49,7 @@ describe('POST /api/market/sell-plane', () => { // A second, grounded plane so "flying" is the reason for rejection. await db.insert(schema.planes).values({ 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')); diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts new file mode 100644 index 0000000..b4fb39a --- /dev/null +++ b/app/api/packages/[id]/abandon/route.test.ts @@ -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); + }); +}); diff --git a/app/api/packages/[id]/abandon/route.ts b/app/api/packages/[id]/abandon/route.ts new file mode 100644 index 0000000..b2d96f1 --- /dev/null +++ b/app/api/packages/[id]/abandon/route.ts @@ -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)); +} diff --git a/app/api/packages/[id]/load/route.test.ts b/app/api/packages/[id]/load/route.test.ts new file mode 100644 index 0000000..f840e80 --- /dev/null +++ b/app/api/packages/[id]/load/route.test.ts @@ -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>; + 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); + }); +}); diff --git a/app/api/packages/[id]/load/route.ts b/app/api/packages/[id]/load/route.ts new file mode 100644 index 0000000..3597140 --- /dev/null +++ b/app/api/packages/[id]/load/route.ts @@ -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; + 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)); +} diff --git a/app/api/planes/[planeId]/dispatch/route.test.ts b/app/api/planes/[planeId]/dispatch/route.test.ts deleted file mode 100644 index 3bbca71..0000000 --- a/app/api/planes/[planeId]/dispatch/route.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/app/api/planes/[planeId]/dispatch/route.ts b/app/api/planes/[planeId]/dispatch/route.ts deleted file mode 100644 index 4343ef3..0000000 --- a/app/api/planes/[planeId]/dispatch/route.ts +++ /dev/null @@ -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; - 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)); -} diff --git a/app/api/planes/[planeId]/fly/route.test.ts b/app/api/planes/[planeId]/fly/route.test.ts new file mode 100644 index 0000000..d3e2907 --- /dev/null +++ b/app/api/planes/[planeId]/fly/route.test.ts @@ -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); + }); +}); diff --git a/app/api/planes/[planeId]/fly/route.ts b/app/api/planes/[planeId]/fly/route.ts new file mode 100644 index 0000000..db09725 --- /dev/null +++ b/app/api/planes/[planeId]/fly/route.ts @@ -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; + 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)); +} diff --git a/app/api/planes/[planeId]/refuel/route.test.ts b/app/api/planes/[planeId]/refuel/route.test.ts index ed6d071..3d69aa0 100644 --- a/app/api/planes/[planeId]/refuel/route.test.ts +++ b/app/api/planes/[planeId]/refuel/route.test.ts @@ -52,7 +52,7 @@ describe('POST /api/planes/:planeId/refuel', () => { it('rejects a flying plane with 409', async () => { const { db } = await getGameDb(); 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')); const res = await post('plane-starter'); @@ -61,7 +61,7 @@ describe('POST /api/planes/:planeId/refuel', () => { // Ground it again for later tests. 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')); }); diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index 08326c5..c3e7791 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -30,8 +30,8 @@ describe('GET /api/state', () => { expect(Object.keys(body).sort()).toEqual([ 'account', 'catalog', - 'contracts', 'fuelPricePerL', + 'packages', 'planes', 'sellFraction', 'simState', @@ -48,15 +48,18 @@ describe('GET /api/state', () => { const planes = body.planes as Array>; 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(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>; - expect(contracts.length).toBeGreaterThan(0); + const packages = body.packages as Array>; + expect(packages.length).toBeGreaterThan(0); 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.burnPerKm).toBe(type.burnPerKm); 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. diff --git a/app/api/test/create-contract/route.ts b/app/api/test/create-package/route.ts similarity index 75% rename from app/api/test/create-contract/route.ts rename to app/api/test/create-package/route.ts index 2a7152a..ebdf567 100644 --- a/app/api/test/create-contract/route.ts +++ b/app/api/test/create-package/route.ts @@ -2,23 +2,24 @@ import { randomInt, randomUUID } from 'node:crypto'; import { getGameDb, persist } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; 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'; -// TEST-ONLY hook (v0.8.0) — lets the e2e suite create an open contract with a -// specified origin and/or destination (and optional deadline offset), so the full -// accept → dispatch → fly loop can be exercised on a known, short route without -// waiting for random spawns. Disabled unless FLIGHT_TEST_HOOKS=1 (set in -// docker-compose.yml for the e2e instance only); every other deployment 404s here. -// Sibling of /api/test/expire-contract — same gate, same conventions. +// TEST-ONLY hook (v0.8.0, renamed create-contract → create-package in v0.12.0) — +// lets the e2e suite create an open package with a specified origin and/or +// destination (and optional deadline offset), so the full load → fly loop can be +// exercised on a known, short route without waiting for random spawns. Disabled +// unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance +// only); every other deployment 404s here. Sibling of /api/test/expire-package — +// same gate, same conventions. // -// POST /api/test/create-contract +// POST /api/test/create-package // body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number } // - 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 -// hook deadline of expire-contract) — pass an explicit value to override. -// Returns 201 + the created contract row. +// hook deadline of expire-package) — pass an explicit value to override. +// Returns 201 + the created package row. export async function POST(req: Request) { if (process.env.FLIGHT_TEST_HOOKS !== '1') { @@ -52,7 +53,7 @@ export async function POST(req: Request) { try { 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 airports = fc.features.map((f) => ({ @@ -70,7 +71,7 @@ export async function POST(req: Request) { } const rng = mulberry32(randomInt(0, 2 ** 31)); - const created = generateContract({ + const created = generatePackage({ id: randomUUID(), airports, rng, @@ -79,7 +80,7 @@ export async function POST(req: Request) { originIcao: input.originIcao 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); return Response.json(created, { status: 201 }); } catch (err) { diff --git a/app/api/test/expire-contract/route.ts b/app/api/test/expire-package/route.ts similarity index 56% rename from app/api/test/expire-contract/route.ts rename to app/api/test/expire-package/route.ts index ef7295b..4874d98 100644 --- a/app/api/test/expire-contract/route.ts +++ b/app/api/test/expire-package/route.ts @@ -3,18 +3,19 @@ import { eq } from 'drizzle-orm'; import { getGameDb, persist } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; 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'; -// TEST-ONLY hook (v0.7.0) — lets the e2e suite observe contract expiry without -// waiting hours of game time. Disabled unless FLIGHT_TEST_HOOKS=1, which is set in -// docker-compose.yml for the e2e instance only; every other deployment 404s here. +// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite +// observe package expiry without waiting hours of game time. Disabled unless +// 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 -// body { contractId?: string } -// - with contractId: moves that OPEN contract's deadline to now + HOOK_DEADLINE_MS +// POST /api/test/expire-package +// body { packageId?: string } +// - 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. -// - 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×) @@ -33,9 +34,9 @@ export async function POST(req: Request) { if (typeof body !== 'object' || body === null || Array.isArray(body)) { return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); } - const contractId = (body as Record).contractId; - if (contractId !== undefined && typeof contractId !== 'string') { - return Response.json({ error: 'contractId must be a string' }, { status: 400 }); + const packageId = (body as Record).packageId; + if (packageId !== undefined && typeof packageId !== 'string') { + return Response.json({ error: 'packageId must be a string' }, { status: 400 }); } try { @@ -43,15 +44,15 @@ export async function POST(req: Request) { const sim = await tickNow(); // clock is current before we set the deadline const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS); - if (typeof contractId === 'string') { - const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)); + if (typeof packageId === 'string') { + const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); const row = rows[0]; 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); - 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); } @@ -63,8 +64,8 @@ export async function POST(req: Request) { lon: f.properties.lon, })); const rng = mulberry32(randomInt(0, 2 ** 31)); - const created = generateContract({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS }); - await inst.db.insert(schema.contracts).values(created); + const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS }); + await inst.db.insert(schema.packages).values(created); await persist(inst); return Response.json(created, { status: 201 }); } catch (err) { diff --git a/app/globals.css b/app/globals.css index ec04bcd..5800b80 100644 --- a/app/globals.css +++ b/app/globals.css @@ -330,3 +330,17 @@ body { white-space: nowrap; 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); +} diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index e2b8db5..460fe8a 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -1,7 +1,7 @@ 'use client'; 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 { formatCash, formatFuel, formatSimClock } from '@/lib/format'; 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_COMMIT_DEBOUNCE_MS = 250; -function planeLocation(p: PlaneState, contracts: ContractState[]): string { - if (p.status === 'flying' && p.contractId) { - const c = contracts.find((c) => c.id === p.contractId); - if (c) return `${p.locationName} → ${c.destName}`; - } +// Flying planes show their current leg from the snapshot's dest fields (v0.12.0). +function planeLocation(p: PlaneState): string { + if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`; 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) { const [busy, setBusy] = useState(false); @@ -156,7 +163,8 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
{formatFuel(p.fuel, p.maxFuel)} - {planeLocation(p, state.contracts)} + {loadoutFor(state, p)} + {planeLocation(p)}
))} diff --git a/components/Map.tsx b/components/Map.tsx index 4704b21..05083d6 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -20,13 +20,13 @@ export const MAX_ZOOM = 11; const AIRPORTS_SOURCE_ID = 'airports'; 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_GROUNDED_LAYER_ID = 'planes-grounded'; const PLANES_FLYING_LAYER_ID = 'planes-flying'; const ROUTES_SOURCE_ID = 'routes'; -const ROUTES_PENDING_LAYER_ID = 'routes-pending'; -const ROUTES_INFLIGHT_LAYER_ID = 'routes-inflight'; +const ROUTES_LEGS_LAYER_ID = 'routes-legs'; function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); @@ -198,25 +198,11 @@ export default function FlightMap({ state }: MapProps) { data: routesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()), }); } - if (!map.getLayer(ROUTES_PENDING_LAYER_ID)) { + if (!map.getLayer(ROUTES_LEGS_LAYER_ID)) { map.addLayer({ - id: ROUTES_PENDING_LAYER_ID, + id: ROUTES_LEGS_LAYER_ID, type: 'line', 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: { 'line-color': '#f59e0b', // bright amber — actively flying the leg 'line-width': 3, diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 150404e..fa4dea3 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -1,8 +1,9 @@ 'use client'; 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 { abandonPenalty } from '@/lib/game/penalty'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; interface RightPaneProps { @@ -15,75 +16,85 @@ interface RightPaneProps { 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). -function dispatchReason(p: PlaneState, contract: ContractState): string | null { - if (p.status !== 'available') return 'in flight'; - if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`; - return null; +// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which +// keeps its slot until it is abandoned or the plane lands at its destination. +export function slotsUsedFor(state: StateSnapshot, planeId: string): number { + return state.packages.filter( + (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 -// while it stays eligible, else NOTHING. v0.10.1 removed the "auto-track first eligible" fallback — -// it made every row display the same plane by default (selections looked shared across contracts) -// and left no way to show "nothing chosen". null = placeholder shown + Dispatch disabled; a poll -// that makes the pick ineligible (plane flew / moved away) drops back to the placeholder too. -export function dispatchSelection( - planes: PlaneState[], - contract: ContractState, - selectedId: string | null, -): PlaneState | null { - if (selectedId === null) return null; - return planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? null; +// Planes that can load `pkg` right now: GROUNDED at the package's origin with a +// free slot (capacity from the snapshot catalog). The Load button is enabled iff +// this list is non-empty; exactly one entry loads directly, several show a picker. +export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] { + return state.planes.filter((p) => { + if (p.status !== 'available') return false; + if (p.locationIcao !== pkg.originIcao) return false; + const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0; + return slotsUsedFor(state, p.id) < capacity; + }); } -// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only -// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch -// button bound to the selection. Each row owns its selection in local state and defaults to the -// "Select a plane…" placeholder — never preselecting — so rows stay visibly independent and a -// dispatch can't fire on the wrong contract by accident. -function DispatchControls({ - contract, - planes, +// Load controls for one open package: a single Load button when exactly one plane +// is eligible (it acts on that plane directly), a small plane picker + Load when +// several are, and a disabled button with the reason otherwise. Each row owns its +// selection in local state and defaults to the "Select a plane…" placeholder — +// never preselecting — so rows stay visibly independent (v0.10.1 pattern). +function LoadControls({ + pkg, + eligible, busy, - onDispatch, + onLoad, }: { - contract: ContractState; - planes: PlaneState[]; + pkg: PackageState; + eligible: PlaneState[]; busy: boolean; - onDispatch: (planeId: string) => void; + onLoad: (planeId: string) => void; }) { const [selectedId, setSelectedId] = useState(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 ( + + ); + } return (
); @@ -98,7 +109,7 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr if (state !== null) setActionError(null); }, [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). const [buyModelId, setBuyModelId] = useState(null); const [sellPlaneId, setSellPlaneId] = useState(null); @@ -143,8 +154,14 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr if (!state) return null; const clockMs = state.simState.clockMs; - const open = state.contracts.filter((c) => c.status === 'open'); - const accepted = state.contracts.filter((c) => c.status === 'accepted'); + const open = state.packages.filter((p) => p.status === 'open'); + + // 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 buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null; @@ -167,70 +184,110 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr

Jobs & Market

-

Open contracts

+

Packages

{open.length === 0 ? ( -

No open contracts.

+

No open packages.

) : ( -
    - {open.map((c) => ( -
  • -
    - {c.originName} → {c.destName} - -
    -
    - {formatCash(c.reward)} - {c.cargoType} × {c.cargoCount} - - {formatDeadlineRelative(c.deadlineSimTime, clockMs)} - -
    -
  • - ))} +
      + {open.map((pkg) => { + const eligible = loadEligiblePlanes(state, pkg); + return ( +
    • +
      + {pkg.originName} → {pkg.destName} + {eligible.length > 0 ? ( + void runAction(`/api/packages/${pkg.id}/load`, { planeId })} + /> + ) : ( + + )} +
      +
      + {formatCash(pkg.reward)} + {pkg.cargoType} × {pkg.cargoCount} + + {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} + +
      +
    • + ); + })}
    )}
-

Accepted

- {accepted.length === 0 ? ( -

Nothing accepted yet.

+

On board

+ {onboard.length === 0 ? ( +

Nothing on board.

) : ( -
    - {accepted.map((c) => ( -
  • + onboard.map((plane) => { + const cargo = state.packages.filter( + (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'), + ); + return ( +
    - {c.originName} → {c.destName} - {c.planeId ? ( - - in flight - - ) : null} + {planeName(plane)} + {plane.status === 'flying' ? ( + <> + in flight + {plane.destName && ( + + {plane.locationName} → {plane.destName} + + )} + + ) : ( + {plane.locationIcao} + )}
    -
    - {formatCash(c.reward)} - {c.cargoType} × {c.cargoCount} - {formatDeadlineRelative(c.deadlineSimTime, clockMs)} -
    - {!c.planeId && ( - void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })} - /> - )} -
  • - ))} -
+ {cargo.map((pkg) => { + const penalty = abandonPenalty(pkg, clockMs); + return ( +
+
+ + +
+
+ {pkg.originName} → {pkg.destName} + {formatCash(pkg.reward)} + {pkg.status === 'expired' ? ( + expired + ) : ( + {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} + )} +
+
+ ); + })} + + ); + }) )}
diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index 06ae7e7..b58e493 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -1,26 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { dispatchSelection } from './RightPane'; -import type { ContractState, PlaneState } from '@/lib/game/state'; +import { loadEligiblePlanes, slotsUsedFor } from './RightPane'; +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 -// explicit pick while it stays eligible (available AND at the contract origin), else NOTHING -// (placeholder). The old "auto-track first eligible" fallback made every accepted-contract row -// display the same plane, so selections looked shared across contracts. +// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while +// it is GROUNDED at the package's origin with a free slot (capacity from the +// snapshot catalog; expired packages still occupy theirs). The Load button acts +// directly on the single eligible plane, or shows a picker when several qualify. -const contract: ContractState = { - id: 'contract-a', - 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, -}; +const CATALOG = [ + { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 }, +]; function plane(over: Partial & Pick): PlaneState { return { @@ -30,7 +19,8 @@ function plane(over: Partial & Pick): PlaneState { maxFuel: 100, locationIcao: 'EDDF', locationName: 'Frankfurt Main', - contractId: null, + destIcao: null, + destName: null, departureSimTime: null, lat: 50, lon: 8.5, @@ -38,29 +28,93 @@ function plane(over: Partial & Pick): PlaneState { }; } -describe('dispatchSelection (per-contract dispatch picker rule)', () => { - it('defaults to the placeholder — no auto-selection of the first eligible plane', () => { - const planes = [plane({ id: 'p1' }), plane({ id: 'p2' })]; // both eligible at EDDF - expect(dispatchSelection(planes, contract, null)).toBeNull(); +function pkg(over: Partial & Pick): PackageState { + return { + originIcao: 'EDDF', + 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)', () => { - const planes = [plane({ id: 'p1' }), plane({ id: 'p2', locationIcao: 'EDDM' })]; - expect(dispatchSelection(planes, contract, 'p1')?.id).toBe('p1'); + it('is 0 for a plane with no bound cargo', () => { + expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0); }); +}); - it('drops to the placeholder when a poll makes the pick ineligible (flew away)', () => { - const planes = [plane({ id: 'p1', status: 'flying' })]; - expect(dispatchSelection(planes, contract, 'p1')).toBeNull(); +describe('loadEligiblePlanes (Load button eligibility)', () => { + it('includes only grounded planes at the package origin with a free slot', () => { + 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', () => { - const planes = [plane({ id: 'p1', locationIcao: 'EDDM' })]; - expect(dispatchSelection(planes, contract, 'p1')).toBeNull(); + it('excludes a plane whose slots are full (capacity from the catalog)', () => { + const packages = Array.from({ length: 6 }, (_, i) => + 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', () => { - const planes = [plane({ id: 'p1' })]; - expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull(); + it('a full plane with one slot freed by an expired package still counts as full', () => { + const packages = [ + ...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([]); }); }); diff --git a/docker-compose.yml b/docker-compose.yml index b1af854..41d6bd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,9 @@ services: # Dedicated dir — do NOT mount over /app/data (would mask airports_eu.csv). - "./db:/app/db" environment: - # e2e-only affordance (v0.7.0): enables POST /api/test/expire-contract so the - # sim suite can force a near-term contract expiry; 404 without this var. + # e2e-only affordance (v0.7.0): enables POST /api/test/create-package and + # /api/test/expire-package so the suites can force known routes/expiries; + # 404 without this var. FLIGHT_TEST_HOOKS: "1" networks: - dev_net diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index a7373e6..e48b996 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -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 -// contract → dispatch plane-starter → watch it fly at scale 60 → arrival pays the -// exact reward + reputation and grounds the plane with reduced fuel — plus the -// refuel cost check (folded into the loop, where the tank is genuinely partial) -// and the "dispatch without accept" 4xx path. Contracts are created on a known -// short route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1 in -// docker-compose.yml), with the destination picked as the closest airport to EDDF -// from /api/airports so arrival is fast at scale 60. +// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): +// the full core loop through the UI — hook creates a short package at EDDF → Load +// via the right pane → click its destination in the on-board list → fly at 60× → +// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula +// penalty while grounded; expired packages deliver/abandon with no reward and no +// penalty. Mirrors lib/game/penalty.ts for exact-value assertions. -const SCALE = 60; -const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts -// Mirrors of server constants — kept in sync manually (same pattern as sim.spec.ts). -const BURN_PER_KM = 1.75; // atr72, lib/game/catalog.ts -const FUEL_PRICE_PER_L = 0.8; // lib/game/catalog.ts +const SCALE = 60; // fastest sim speed — keeps flights to seconds of real time +const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) +const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable + +// 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_PER_KM = 0.0005; -interface PlaneState { - id: string; - status: 'available' | 'flying'; - fuel: number; - maxFuel: number; - 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; +// 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; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; - planes: PlaneState[]; - contracts: ContractState[]; -} - -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))); + planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>; + 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; + }>; } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); - expect(res.status()).toBe(200); + expect(res.ok()).toBeTruthy(); return (await res.json()) as StateBody; } -async function postSim(page: Page, body: unknown) { - return page.request.post('/api/sim', { data: body }); +// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. +async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise { + 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). -test.afterEach(async ({ page }) => { - const res = await postSim(page, { timeScale: 1, paused: false }); +async function postSim(page: Page, body: Record): Promise { + const res = await page.request.post('/api/sim', { data: body }); + 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 { + const res = await page.request.post('/api/sim', { data: { timeScale: scale } }); expect(res.status()).toBe(200); -}); - -// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route. -async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { - 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; + const body = (await res.json()) as { timeScale: number }; + expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); } -// Create + accept + dispatch a contract from → to on plane-starter, then poll at -// scale 60 until the plane lands at `to`. Used for the main flight and for -// restoring the starter plane to EDDF between runs. -async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise { - const contract = await createContract(page, fromIcao, toIcao); - - 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 } }>; - }; +// Closest airport to the hub from /api/airports → shortest possible flight. Returns both +// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts. +async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { + const res = await page.request.get('/api/airports'); + expect(res.ok()).toBeTruthy(); + const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; for (const f of fc.features) { 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)) { - nearest = f.properties; + const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon); + 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 { + 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. - await postSim(page, { timeScale: SCALE }); - let prevDist = -1; - for (let i = 0; i < 3; i++) { - await sleep(700); - snap = await getState(page); - plane = snap.planes.find((p) => p.id === 'plane-starter')!; - const distFromOrigin = haversineKm(hub.lat, hub.lon, plane.lat, plane.lon); - expect(distFromOrigin).toBeGreaterThan(prevDist); // strictly along the arc - prevDist = distFromOrigin; +test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~3 min at 60×) + two short flights + refuel + // Self-heal: the starter plane may be anywhere from a previous run/spec. + await flyHomeViaApi(page); + const before = await getState(page); + expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB); + + // Nearest airport to the hub → shortest possible flight (fastest e2e loop). + const nearest = await nearestToHub(page); + + // 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). - let arrived: StateBody | undefined; - await expect - .poll(async () => { - snap = await getState(page); - const p = snap.planes.find((x) => x.id === 'plane-starter'); - if (p && p.status === 'available' && p.locationIcao === nearest.icao) arrived = snap; - return !!arrived; - }, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` }) - .toBe(true); + // Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked + // against the exact game clock the server used (a later GET would tick the clock further). + await page.goto('/'); + const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`); + await expect(abandonBtn).toBeEnabled(); + const [res] = await Promise.all([ + page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }), + abandonBtn.click(), + ]); + 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 after = arrived!; - expect(after.account.cash).toBeCloseTo(before.account.cash + contract.reward, 6); + const expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs); + expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor + 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 - expect(after.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 6); - - 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); + // B is still on board — abandon it via the API to leave a clean state. + const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`); + expect(resB.ok()).toBeTruthy(); }); -test('dispatch without accept is rejected with 409; unknown contract id with 404', async ({ page }) => { - const body = await getState(page); - const starter = body.planes.find((p) => p.id === 'plane-starter')!; +test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { + test.setTimeout(480_000); // two ~3 min real-time legs at 60× (HUB↔LEMD) plus expiry wait + await flyHomeViaApi(page); + const before = await getState(page); - // A contract originating where the plane actually sits — so ONLY the missing - // accept is what blocks dispatch. - const created = await page.request.post('/api/test/create-contract', { - data: { originIcao: starter.locationIcao, destIcao: HUB === starter.locationIcao ? 'LEMD' : HUB }, - }); - expect(created.status()).toBe(201); - const contract = (await created.json()) as ContractState; + // A: load, let it expire while loaded, then FLY to its destination — arrival must + // unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's + // destination differs from A's so it stays bound on the plane at LEMD arrival and can + // still be abandoned (an expired package whose dest matches the arrival airport is + // unbound by the tick instead). + const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline + 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 } }); - expect(noAccept.status()).toBe(409); - expect(((await noAccept.json()) as { error: string }).error).toMatch(/open/); + // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). + await setScale(page, SCALE); + 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' } }); - expect(unknown.status()).toBe(404); + // Fly to the destination — arrival unbinds A for free (no reward, no rep). + 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))); +} diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 253280a..8c2057a 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,20 +13,21 @@ test('GET /api/version returns the app version from package.json', async ({ page 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'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record }; expect(spec.info.title).toBe('Flight Ops API'); 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([ '/api/airports', - '/api/contracts/{id}/accept', '/api/market/buy-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/sim', '/api/state', diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index b09f688..4496e17 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -1,11 +1,12 @@ 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 // deterministic generated weather), plane markers whose positions come from the -// /api/state polls, active contract route lines (pending dashed vs in-flight -// solid), and the plane status popup. Contracts are created on a known EDDF→EDDV -// route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1). +// /api/state polls, each FLYING plane's current leg as a route line (origin → +// dest_icao; grounded planes draw nothing), and the plane status popup. Packages +// 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 // Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable @@ -20,19 +21,18 @@ interface PlaneState { fuel: number; maxFuel: number; locationIcao: string; - contractId: string | null; + locationName: string; + destIcao: string | null; + destName: string | null; lat: number; lon: number; } -interface ContractState { +interface PackageState { id: string; - originIcao: string; originName: string; - destIcao: string; destName: string; - reward: number; - status: 'open' | 'accepted' | 'completed' | 'expired'; + status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; } @@ -40,7 +40,7 @@ interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; - contracts: ContractState[]; + packages: PackageState[]; } 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 }); } +// 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 { + 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). test.afterEach(async ({ page }) => { const res = await postSim(page, { timeScale: 1, paused: false }); expect(res.status()).toBe(200); }); -// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route. -async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { - const created = await page.request.post('/api/test/create-contract', { +// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. +async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise { + const created = await page.request.post('/api/test/create-package', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, }); expect(created.status()).toBe(201); - return (await created.json()) as 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. -async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise { - const contract = await createContract(page, fromIcao, toIcao); - 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 }); +async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise { + const pkg = await createPackage(page, fromIcao, toIcao); + const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); + expect(load.status()).toBe(200); + const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } }); + expect(fly.status()).toBe(200); + await setScale(page, 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: 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); - return contract; + return pkg; } // 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 }); -test('plane markers move while flying, the active route line is highlighted, 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('plane markers move while flying, the current leg draws a route line, and the plane popup shows status + route', async ({ page }) => { + test.setTimeout(600_000); // self-heal from a far airport (~4 min worst case at 60×) + main flight + cleanup await page.goto('/'); 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'); expect(foundStarter).toBeDefined(); if (foundStarter!.status === 'flying') { - await postSim(page, { timeScale: SCALE }); + await setScale(page, SCALE); await expect .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', }) .toBe(true); } const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; 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. - const contract = await createContract(page, HUB, DEST); - const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); - expect(acc.status()).toBe(200); + // 1) Load the hook-created package — a GROUNDED plane draws no route line yet. + const pkg = await createPackage(page, HUB, DEST); + const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); + expect(load.status()).toBe(200); - // 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 }>; + 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 .poll(async () => { const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record }>; - return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending'); - }, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' }) + return !feats.some((f) => f.properties.planeId === 'plane-starter'); + }, { timeout: 15_000, message: 'waiting for the grounded plane to draw no route line' }) .toBe(true); - // 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature. - const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); - expect(disp.status()).toBe(200); + // 2) Fly the plane and speed up — its current leg must appear as a route line. + const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); + expect(fly.status()).toBe(200); await postSim(page, { timeScale: SCALE }); 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')!; 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 .poll(async () => { const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record }>; - return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight'); - }, { timeout: 15_000, message: 'waiting for the in-flight route line' }) + return feats.some((f) => f.properties.planeId === 'plane-starter'); + }, { timeout: 15_000, message: 'waiting for the route line in the routes source' }) .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 // marker drifts only ~1 px/s of screen space at scale 60). 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-fuel')).toHaveText(/\d+ \/ \d+ L/); - const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!; - await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`); + const live = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!; + // 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 - // 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 .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === DEST; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }) .toBe(true); + expect((await getState(page)).packages.find((x) => x.id === pkg.id)?.status).toBe('delivered'); - await flyContract(page, DEST, HUB); + await loadAndFly(page, DEST, HUB); }); diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index 367fb18..76f4d5e 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -14,7 +14,7 @@ interface StateBody { account: { cash: number; reputation: number }; simState: SimState; planes: Array>; - contracts: Array>; + packages: Array>; } async function getState(page: Page): Promise { @@ -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('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). await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); }); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 4a2f0a8..d557bcb 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -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 -// names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created -// EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight" -// in the UI AND /api/state; market refuel through the pane deducts exactly -// (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog + -// economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract -// (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. +// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with +// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows +// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same +// formula as the server); flying planes show their current leg; market buy/sell/refuel previews +// match the catalog + economy constants exposed in /api/state. Packages are created via +// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. const SCALE = 60; const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts @@ -18,15 +18,18 @@ interface PlaneState { fuel: number; maxFuel: number; locationIcao: string; + destIcao: string | null; } -interface ContractState { +interface PackageState { id: string; originName: string; destName: string; reward: number; - status: 'open' | 'accepted' | 'completed' | 'expired'; + status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; + createdSimTime: number; + deadlineSimTime: number; } interface CatalogEntry { @@ -36,13 +39,14 @@ interface CatalogEntry { maxFuel: number; burnPerKm: number; speedKmh: number; + capacity: number; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; - contracts: ContractState[]; + packages: PackageState[]; catalog: CatalogEntry[]; fuelPricePerL: number; sellFraction: number; @@ -61,6 +65,15 @@ async function postSim(page: Page, body: unknown) { return page.request.post('/api/sim', { data: body }); } +// Set the time scale and VERIFY from the response that it stuck — a silent reset would +// otherwise surface as an unexplained landing-wait timeout. +async function setScale(page: Page, scale: number): Promise { + 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). test.afterEach(async ({ page }) => { 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))); } +// 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. async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> { 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; } -// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route. -async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { - const created = await page.request.post('/api/test/create-contract', { - data: { originIcao: fromIcao, destIcao: toIcao }, +// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. +async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise { + const created = await page.request.post('/api/test/create-package', { + data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs }, }); 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. -async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise { - const contract = await createContract(page, fromIcao, toIcao); - 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); +// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. +async function flyHomeViaApi(page: Page): Promise { + const s0 = await getState(page); + const starter = s0.planes.find((p) => p.id === 'plane-starter')!; + if (starter.status === 'flying' || starter.locationIcao !== HUB) { + // Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×). + await setScale(page, SCALE); + } + if (starter.status === 'flying') { + await expect + .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { + timeout: 240_000, + message: 'waiting for an in-flight plane-starter to land', + }) + .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.setTimeout(90_000); +test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => { + 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('/'); const right = page.getByTestId('right-pane'); await expect(right).toBeVisible({ timeout: 15_000 }); - // ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button. - const rows = page.locator('li[data-testid^="open-contract-"]'); - await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 }); + // ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button. + const rows = page.locator('li[data-testid^="open-package-"]'); + await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toContainText(/→/); - await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€'); - await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible(); + await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€'); - // 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 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}"]`); - await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up - await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible(); - await page.getByTestId(`accept-${contract.id}`).click(); - - // Server truth first… + // Load through the UI — exactly one eligible plane, so the button acts directly. + await loadBtn.click(); const body = await getState(page); - expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted'); - // …and the UI: gone from open, present in the accepted group with dispatch controls. + expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded'); + 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(); - const accRow = page.getByTestId(`accepted-contract-${contract.id}`); - await expect(accRow).toBeVisible({ timeout: 10_000 }); - await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible(); + await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 }); }); -test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => { - test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes +test('Load is disabled when no plane sits at the package origin', async ({ page }) => { + 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 - // another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts). - let body = await getState(page); - const starter0 = body.planes.find((p) => p.id === 'plane-starter'); - expect(starter0).toBeDefined(); - if (starter0!.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); - 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); - } + const pkg = await createPackage(page, 'LEMD', HUB); + await page.goto('/'); + const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled(); +}); + +test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => { + test.setTimeout(240_000); // one short flight at scale 60 plus UI steps — typical run well under three minutes + await flyHomeViaApi(page); - // Hook contract on the shortest EDDF route, accepted through the UI. 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('/'); - const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`); - await expect(row).toBeVisible({ timeout: 15_000 }); - await page.getByTestId(`accept-${contract.id}`).click(); + const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); + await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 }); - // Dispatch controls appear in the accepted group; pick plane-starter and dispatch. - const accRow = page.getByTestId(`accepted-contract-${contract.id}`); - await expect(accRow).toBeVisible({ timeout: 10_000 }); - const picker = page.getByTestId(`dispatch-plane-${contract.id}`); - await expect(picker).toBeVisible(); - await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option - await page.getByTestId(`dispatch-${contract.id}`).click(); + // The abandon button previews the exact penalty at the snapshot's clock (same formula as the server). + // Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value, + // making the preview deterministic instead of racing the 1 s poll loop. + await postSim(page, { paused: true }); + const state = await getState(page); + const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs); + 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. - body = await getState(page); - let starter = body.planes.find((p) => p.id === 'plane-starter')!; - expect(starter.status).toBe('flying'); + let body = await getState(page); + expect(body.planes.find((p) => p.id === 'plane-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). - await postSim(page, { timeScale: SCALE }); + // Resume and fast-forward to arrival (poll the authoritative state). + await postSim(page, { paused: false }); + await setScale(page, SCALE); await expect .poll(async () => { const b = await getState(page); const p = b.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)` }) + }, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` }) .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); - starter = body.planes.find((p) => p.id === 'plane-starter')!; - expect(starter.fuel).toBeLessThan(starter.maxFuel); - const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100; - const cashBefore = body.account.cash; + expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered'); + await expect(onboardRow).not.toBeVisible({ timeout: 15_000 }); - // …the refuel picker lists it, the preview matches the exact cost, and the click deducts it. - await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 }); - await page.getByTestId('market-refuel-plane').selectOption('plane-starter'); - 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). + // Restore: fly home so the live instance stays in its onboarding state for the next run. + const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); + expect(homeRes.status()).toBe(200); await expect - .poll(async () => { - const b = await getState(page); - return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001; - }, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' }) + .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { + timeout: 120_000, + message: 'waiting for plane-starter to land back at the hub', + }) .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.setTimeout(60_000); +test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => { + 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) expect(body.catalog.length).toBeGreaterThan(0); 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('/'); // 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 // sellFraction × price, both from the snapshot's catalog/constants. 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 }); 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')).toBeVisible(); -}); -test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => { - test.setTimeout(240_000); // two short flights at scale 60 plus UI steps + // Refuel through the pane: the flight genuinely burned fuel… + 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 - // another airport (same pattern as the dispatch-loop test above). - let body = await getState(page); - const starter0 = body.planes.find((p) => p.id === 'plane-starter'); - expect(starter0).toBeDefined(); - if (starter0!.status === 'flying') { - await postSim(page, { timeScale: SCALE }); + // …the refuel picker lists it, the preview matches the exact cost, and the click deducts it. + await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 }); + await page.getByTestId('market-refuel-plane').selectOption('plane-starter'); + 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 - .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', - }) + .poll(async () => { + const b = await getState(page); + 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); - 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); }); diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index 4a742a9..878e96a 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -1,14 +1,14 @@ import { test, expect, type Page } from '@playwright/test'; // 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. // 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 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; interface SimState { @@ -21,7 +21,7 @@ interface StateBody { account: { cash: number; reputation: number }; simState: SimState; planes: Array>; - 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)); @@ -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); const before = await getState(page); - const victim = before.contracts.find((c) => c.status === 'open'); - expect(victim).toBeDefined(); // steady state always has open contracts + const victim = before.packages.find((p) => p.status === 'open'); + expect(victim).toBeDefined(); // steady state always has open packages // 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. - const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } }); + // package's deadline ~10 game-seconds out so expiry is observable at scale 60. + const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } }); expect(hook.status()).toBe(200); const moved = (await hook.json()) as { id: string; deadlineSimTime: number }; 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 .poll(async () => { 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; - }, { 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); const after = expiredBody!; - expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired'); - // Expired contracts leave the available set… - expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false); + expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired'); + // Expired packages leave the available set… + 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. - 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); }); -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); - 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); const c = (await created.json()) as { id: string; status: string }; 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 .poll(async () => { 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; - }, { 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); - 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 }); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 0a4661a..6d31dc2 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -10,7 +10,7 @@ interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: Array>; - contracts: Array>; + packages: Array>; } 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(Array.isArray(body.contracts)).toBe(true); - expect(body.contracts.length).toBeGreaterThan(0); + expect(Array.isArray(body.packages)).toBe(true); + expect(body.packages.length).toBeGreaterThan(0); 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.reputation).toBe(before.account.reputation); 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()); }); diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index 11dee8d..5787b79 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -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; beforeAll(async () => { @@ -64,9 +104,9 @@ describe('openGameDb', () => { expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV expect(planes[0].fuel).toBe(planes[0].maxFuel); - const contracts = await inst.db.select().from(schema.contracts); - expect(contracts.length).toBeGreaterThanOrEqual(8); - expect(contracts.every((c) => c.status === 'open')).toBe(true); + const packages = await inst.db.select().from(schema.packages); + expect(packages.length).toBeGreaterThanOrEqual(8); + expect(packages.every((p) => p.status === 'open')).toBe(true); const account = (await inst.db.select().from(schema.account))[0]; expect(account?.cash).toBeGreaterThan(0); @@ -82,13 +122,13 @@ describe('openGameDb', () => { it('does not re-seed an existing DB — state survives reopen', async () => { const file = path.join(dir, 'reopen.db'); 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]; // Simulate a process restart: brand-new instance over the same file. const second = await openGameDb(file); - const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort(); - expect(contracts2).toEqual(contracts1); + const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort(); + expect(packages2).toEqual(packages1); 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. 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'); + }); }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 32d8389..2ec2925 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -27,12 +27,12 @@ CREATE TABLE IF NOT EXISTS planes ( fuel REAL NOT NULL, max_fuel REAL NOT NULL, location_icao TEXT NOT NULL, - contract_id TEXT, + dest_icao TEXT, lat REAL NOT NULL, lon REAL NOT NULL, departure_sim_time INTEGER ); -CREATE TABLE IF NOT EXISTS contracts ( +CREATE TABLE IF NOT EXISTS packages ( id TEXT PRIMARY KEY NOT NULL, origin_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 // 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. -function migrate(raw: RawSqlJsDatabase): void { +function migrateColumns(raw: RawSqlJsDatabase): void { const cols = (table: string): Set => new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1]))); 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')) { 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 @@ -85,8 +116,17 @@ async function open(file: string): Promise { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); - raw.exec(DDL); - migrate(raw); + raw.exec(DDL); // no-op for tables that already exist in an older file + 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 }; @@ -120,7 +160,7 @@ async function seedEmpty(instance: GameDb): Promise { const data = buildSeed({ airports, rng, nowMs: Date.now() }); 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.simState).values(data.simState); await persist(instance); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index eb566a1..31b3a46 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -11,7 +11,9 @@ export const planes = sqliteTable('planes', { fuel: real('fuel').notNull(), maxFuel: real('max_fuel').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(), lon: real('lon').notNull(), // 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'), }); -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(), originIcao: text('origin_icao').notNull(), destIcao: text('dest_icao').notNull(), @@ -28,9 +34,9 @@ export const contracts = sqliteTable('contracts', { cargoType: text('cargo_type').notNull(), cargoCount: integer('cargo_count').notNull(), deadlineSimTime: integer('deadline_sim_time').notNull(), - // 'expired' = an open contract whose deadline passed (v0.7.0). Accepted/ - // in-flight contracts never expire — they complete. - status: text('status', { enum: ['open', 'accepted', 'completed', 'expired'] }).notNull(), + // 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded + // expired packages keep occupying their slot, deliver no reward, and abandon free. + status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(), planeId: text('plane_id'), createdSimTime: integer('created_sim_time').notNull(), }); @@ -50,6 +56,6 @@ export const simState = sqliteTable('sim_state', { }); 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 SimStateRow = typeof simState.$inferSelect; diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 5d32a45..023cb97 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -9,10 +9,11 @@ import { loadAirportsGeoJson } from '@/lib/airports'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { HUB_ICAO } from './seed'; import { - acceptContract, + abandonPackage, buyPlane, - dispatchPlane, + flyPlane, GameActionError, + loadPackage, refuelPlane, sellPlane, } from './actions'; @@ -54,54 +55,107 @@ function expectActionError(promise: Promise, status: number, messagePar ); } -// Insert an accepted contract EDDF → LEMD bound to nothing, for dispatch tests. -async function insertAcceptedContract(inst: GameDb, id = 'c-acc'): Promise { - await inst.db.insert(schema.contracts).values({ +// Insert a known OPEN package EDDF → LEMD for load tests. +async function insertOpenPackage(inst: GameDb, id = 'p-open', over: Partial = {}): Promise { + await inst.db.insert(schema.packages).values({ id, originIcao: HUB_ICAO, destIcao: 'LEMD', - reward: 5_000, - cargoType: 'Passengers', - cargoCount: 60, + reward: 1_000, + cargoType: 'Mail', + cargoCount: 4, deadlineSimTime: T0 + 24 * 3_600_000, - status: 'accepted', + status: 'open', planeId: null, createdSimTime: T0, + ...over, }); } -describe('acceptContract', () => { - it('flips an open contract to accepted and persists it', async () => { - const inst = await freshDb('acc-open.db'); - await acceptContract(inst, 'contract-1'); // seeded ids are contract-N +describe('loadPackage', () => { + it('binds an open package to a grounded plane at its origin', async () => { + const inst = await freshDb('load-ok.db'); + await insertOpenPackage(inst); - const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0]; - expect(row.status).toBe('accepted'); + await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF + + 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) => { - const inst = await freshDb(`acc-${status}.db`); - await inst.db.update(schema.contracts).set({ status }).where(eq(schema.contracts.id, 'contract-1')); + it.each(['loaded', 'delivered', 'expired', 'abandoned'] as const)( + 'rejects a %s package with 409 (must be open)', + 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 () => { - const inst = await freshDb('acc-unknown.db'); - await expectActionError(acceptContract(inst, 'nope'), 404); + it('rejects an unknown plane id with 404', async () => { + const inst = await freshDb('load-ghost.db'); + 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', () => { - it('binds the plane: flying + departure at current clock + origin coords, contract bound to plane', async () => { - const inst = await freshDb('disp-ok.db'); - await insertAcceptedContract(inst); +describe('flyPlane', () => { + it('sets flying + departure at current clock + dest_icao + origin coords', async () => { + const inst = await freshDb('fly-ok.db'); - await dispatchPlane(inst, 'plane-starter', 'c-acc'); + await flyPlane(inst, 'plane-starter', 'LEMD'); const plane = (await inst.db.select().from(schema.planes))[0]; 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 const fc = await loadAirportsGeoJson(); const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; @@ -109,58 +163,129 @@ describe('dispatchPlane', () => { expect(plane.lon).toBe(origin.lon); // Fuel is frozen at the departure value while flying (live fuel is analytic). 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 () => { - const inst = await freshDb('disp-flying.db'); - await insertAcceptedContract(inst); + const inst = await freshDb('fly-flying.db'); 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')); - 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 () => { - const inst = await freshDb('disp-notatorigin.db'); - await insertAcceptedContract(inst); // originates at EDDF - await inst.db.update(schema.planes) - .set({ locationIcao: 'LEMD' }) - .where(eq(schema.planes.id, 'plane-starter')); + it('rejects an unknown plane id with 404', async () => { + const inst = await freshDb('fly-ghost.db'); + await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404); + }); - 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 () => { - const inst = await freshDb('disp-nofuel.db'); - await insertAcceptedContract(inst); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L + const inst = await freshDb('fly-nofuel.db'); // 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 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". await inst.db.insert(schema.planes).values({ 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; @@ -224,7 +349,7 @@ describe('sellPlane', () => { const inst = await freshDb('sell-flying.db'); await inst.db.insert(schema.planes).values({ 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/); @@ -269,7 +394,7 @@ describe('refuelPlane', () => { it('rejects a flying plane with 409', async () => { const inst = await freshDb('refuel-flying.db'); 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')); await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 6965718..904823a 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -1,5 +1,5 @@ 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 { persist, type GameDb } from '@/lib/db/client'; import { loadAirportsGeoJson } from '@/lib/airports'; @@ -7,17 +7,20 @@ import { haversineKm } from '@/lib/geo'; import { runAction } from '@/lib/sim/engine'; import { getStateSnapshot } from './state'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; +import { abandonPenalty } from './penalty'; import { HUB_ICAO } from './seed'; -// Player actions (v0.8.0) — the core loop, server-side: accept a contract, -// dispatch a plane on it, buy/sell planes, refuel. Each function validates and -// mutates ONE throwaway-safe GameDb instance (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: +// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side: +// load a package onto a grounded plane at its origin, fly to any airport, deliver +// on arrival, abandon for a penalty while grounded; plus buy/sell planes and +// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance +// (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 -// 409 — valid ids but the current state forbids the action (wrong contract/plane -// status, plane not at origin, insufficient fuel/funds, last plane) -// 400 — bad input that isn't an existence problem (unknown catalog model) +// 409 — valid ids but the current state forbids the action (wrong package/plane +// status, plane not at origin, no free slot, insufficient fuel/funds, last plane) +// 400 — bad input that isn't an existence problem (unknown airport/model) export class GameActionError extends Error { 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 }; } -// --- 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; -// an accepted contract is claimed by whoever dispatched it first (dispatch checks -// 'accepted' and binds the plane). -export async function acceptContract(inst: GameDb, contractId: string): Promise { - const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)); - const contract = rows[0]; - 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 { - const [planeRows, contractRows] = await Promise.all([ +// open → loaded, bound to the plane. The plane must be GROUNDED at the package's +// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired +// cargo keeps occupying its space until delivered/abandoned). +export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise { + const [pkgRows, planeRows] = await Promise.all([ + inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)), 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]; - if (!contract) throw new GameActionError(404, `contract ${contractId} not found`); - if (contract.status !== 'accepted') { - throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`); + const pkg = pkgRows[0]; + if (!pkg) throw new GameActionError(404, `package ${packageId} not found`); + if (pkg.status !== 'open') { + throw new GameActionError(409, `package is ${pkg.status}, only open packages can be loaded`); } 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 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( 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]; if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data - const origin = await airportByIcao(contract.originIcao); - const dest = await airportByIcao(contract.destIcao); - const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon); + const occupied = ( + await inst.db.select().from(schema.packages).where( + 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 { + 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; 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`); @@ -96,16 +117,49 @@ export async function dispatchPlane(inst: GameDb, planeId: string, contractId: s await inst.db.update(schema.planes) .set({ status: 'flying', - contractId, + destIcao, departureSimTime: sim.clockMs, lat: origin.lat, lon: origin.lon, }) .where(eq(schema.planes.id, planeId)); - await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId)); 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}) --- // 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 maxFuel: type.maxFuel, locationIcao: home.icao, - contractId: null, + destIcao: null, lat: home.lat, lon: home.lon, departureSimTime: null, diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index 4289493..f7d320b 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -13,6 +13,8 @@ export interface PlaneType { // Market price in € (v0.8.0): buying costs `price`, selling pays out // SELL_FRACTION × price. price: number; + // Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane. + capacity: number; } export const PLANE_TYPES: Record = { @@ -25,6 +27,7 @@ export const PLANE_TYPES: Record = { // €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. price: 200_000, + capacity: 6, }, }; @@ -47,6 +50,7 @@ export interface CatalogEntry { maxFuel: number; burnPerKm: number; speedKmh: number; + capacity: number; } // 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, burnPerKm: t.burnPerKm, speedKmh: t.speedKmh, + capacity: t.capacity, })); } diff --git a/lib/game/penalty.test.ts b/lib/game/penalty.test.ts new file mode 100644 index 0000000..8c05e18 --- /dev/null +++ b/lib/game/penalty.test.ts @@ -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); + }); +}); diff --git a/lib/game/penalty.ts b/lib/game/penalty.ts new file mode 100644 index 0000000..e32cc19 --- /dev/null +++ b/lib/game/penalty.ts @@ -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; +} diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index e55e226..4d8c291 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -4,9 +4,9 @@ import { PLANE_TYPES, STARTER_MODEL } from './catalog'; import { BASE_REWARD, buildSeed, - CONTRACT_COUNT_MAX, - CONTRACT_COUNT_MIN, - generateContract, + PACKAGE_COUNT_MAX, + PACKAGE_COUNT_MIN, + generatePackage, HUB_ICAO, mulberry32, REWARD_PER_KM, @@ -41,7 +41,7 @@ describe('buildSeed', () => { fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: HUB_ICAO, - contractId: null, + destIcao: null, lat: 50.0333, lon: 8.5706, }); @@ -52,13 +52,13 @@ describe('buildSeed', () => { 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); - expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN); - expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX); + expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN); + expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX); 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.destIcao)).toBe(true); expect(c.originIcao).not.toBe(c.destIcao); @@ -66,12 +66,12 @@ describe('buildSeed', () => { expect(c.planeId).toBeNull(); 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)', () => { 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 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))); @@ -80,7 +80,7 @@ describe('buildSeed', () => { it('sets deadlines inside the 6–72h game-time window after now', () => { 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).toBeLessThanOrEqual(NOW + 72 * 3_600_000); } @@ -97,8 +97,8 @@ describe('buildSeed', () => { expect(seedWith(42)).toEqual(seedWith(42)); }); - it('produces different contracts for a different RNG seed', () => { - expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts); + it('produces different packages for a different RNG seed', () => { + expect(seedWith(1).packages).not.toEqual(seedWith(2).packages); }); it('rejects an airport list too small to form a route', () => { @@ -111,9 +111,9 @@ describe('buildSeed', () => { }); }); -describe('generateContract (shared seed/spawn builder)', () => { - it('builds an open contract between distinct fixture airports with the reward formula', () => { - const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); +describe('generatePackage (shared seed/spawn builder)', () => { + it('builds an open package between distinct fixture airports with the reward formula', () => { + const c = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); expect(c.id).toBe('c-x'); 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', () => { - const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); - const b = 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 = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); 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.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)', () => { - 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); }); - 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 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[] = []; 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 }); - 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', () => { - 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)', () => { 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.destIcao).not.toBe('EDDF'); @@ -176,13 +176,13 @@ describe('generateContract (shared seed/spawn builder)', () => { }); 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.originIcao).not.toBe('LEBC'); }); 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.destIcao).toBe('EHAM'); @@ -192,22 +192,22 @@ describe('generateContract (shared seed/spawn builder)', () => { }); 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 b = 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 = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' }); expect(a).toEqual(b); }); 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(() => generateContract({ 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: 'XXXX' })).toThrow(/unknown origin/); + expect(() => generatePackage({ 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, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/); }); 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. - const a = generateContract({ 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 a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); + const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined }); expect(a).toEqual(b); }); }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index a0d726b..b572bdf 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -23,7 +23,7 @@ export function mulberry32(seed: number): Rng { a |= 0; a = (a + 0x6d2b79f5) | 0; 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; }; } @@ -35,12 +35,12 @@ export interface SeedPlane { fuel: number; maxFuel: number; locationIcao: string; - contractId: null; + destIcao: null; lat: number; lon: number; } -export interface SeedContract { +export interface SeedPackage { id: string; originIcao: string; destIcao: string; @@ -55,7 +55,7 @@ export interface SeedContract { export interface SeedData { plane: SeedPlane; - contracts: SeedContract[]; + packages: SeedPackage[]; account: { cash: number; reputation: 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.) export const HUB_ICAO = 'EDDF'; // Frankfurt Main -export const CONTRACT_COUNT_MIN = 8; -export const CONTRACT_COUNT_MAX = 12; // inclusive +export const PACKAGE_COUNT_MIN = 8; +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. -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 // later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km. export const BASE_REWARD = 500; 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_MAX_HOURS = 72; @@ -92,16 +92,16 @@ export function rewardForDistanceKm(km: number): number { return Math.round(BASE_REWARD + REWARD_PER_KM * km); } -// Shared contract generator, used by BOTH the first-run seed and the runtime -// market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random +// Shared package generator, used by BOTH the first-run seed and the runtime +// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random // distinct airports from the dataset, distance-based reward, cargo flavor, // 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]. // 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. -export function generateContract(input: { +export function generatePackage(input: { id: string; airports: SeedAirport[]; rng: Rng; @@ -109,9 +109,9 @@ export function generateContract(input: { deadlineOffsetMs?: number; originIcao?: string; destIcao?: string; -}): SeedContract { +}): SeedPackage { 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; if (input.originIcao !== undefined) { @@ -179,22 +179,22 @@ export function buildSeed(input: { fuel: type.maxFuel, // starts full maxFuel: type.maxFuel, locationIcao: hub.icao, - contractId: null, + destIcao: null, lat: hub.lat, 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 // random UUIDs to avoid colliding with these. - const contracts: SeedContract[] = []; + const packages: SeedPackage[] = []; 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 { plane, - contracts, + packages, account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, // lastRealTickMs starts at the seed moment so the first tick-on-read advances // by ~0 instead of treating "time since install" as elapsed play time. diff --git a/lib/game/state.ts b/lib/game/state.ts index eb3abce..d7e5cb1 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -15,15 +15,18 @@ export interface PlaneState { maxFuel: number; locationIcao: 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, - // 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; lat: number; lon: number; } -export interface ContractState { +export interface PackageState { id: string; originIcao: string; originName: string; @@ -33,7 +36,7 @@ export interface ContractState { cargoType: string; cargoCount: number; deadlineSimTime: number; - status: 'open' | 'accepted' | 'completed' | 'expired'; + status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number; } @@ -42,7 +45,7 @@ export interface StateSnapshot { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; - contracts: ContractState[]; + packages: PackageState[]; // 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. catalog: CatalogEntry[]; @@ -55,11 +58,11 @@ export async function getStateSnapshot(): Promise { // 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 // 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.simState), db.select().from(schema.planes), - db.select().from(schema.contracts), + db.select().from(schema.packages), ]); const accountRow = accountRows[0]; const simRow = simRows[0]; @@ -72,14 +75,12 @@ export async function getStateSnapshot(): Promise { // 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. - const contractById = new Map(contractRows.map((c) => [c.id, c])); const livePlane = (p: schema.PlaneRow) => { - if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null; - const contract = contractById.get(p.contractId); + if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) return null; const type = PLANE_TYPES[p.model]; - const from = contract ? coordsByIcao.get(contract.originIcao) : undefined; - const to = contract ? coordsByIcao.get(contract.destIcao) : undefined; - if (!contract || !type || !from || !to) return null; + const from = coordsByIcao.get(p.locationIcao); + const to = coordsByIcao.get(p.destIcao); + if (!type || !from || !to) return null; const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); @@ -102,25 +103,26 @@ export async function getStateSnapshot(): Promise { maxFuel: p.maxFuel, locationIcao: p.locationIcao, locationName: nameOf(p.locationIcao), - contractId: p.contractId, + destIcao: p.destIcao, + destName: p.destIcao ? nameOf(p.destIcao) : null, departureSimTime: p.departureSimTime, lat: live?.lat ?? p.lat, lon: live?.lon ?? p.lon, }; }), - contracts: contractRows.map((c) => ({ - id: c.id, - originIcao: c.originIcao, - originName: nameOf(c.originIcao), - destIcao: c.destIcao, - destName: nameOf(c.destIcao), - reward: c.reward, - cargoType: c.cargoType, - cargoCount: c.cargoCount, - deadlineSimTime: c.deadlineSimTime, - status: c.status, - planeId: c.planeId, - createdSimTime: c.createdSimTime, + packages: packageRows.map((p) => ({ + id: p.id, + originIcao: p.originIcao, + originName: nameOf(p.originIcao), + destIcao: p.destIcao, + destName: nameOf(p.destIcao), + reward: p.reward, + cargoType: p.cargoType, + cargoCount: p.cargoCount, + deadlineSimTime: p.deadlineSimTime, + status: p.status, + planeId: p.planeId, + createdSimTime: p.createdSimTime, })), }; } diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 94728fd..b601b81 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -13,8 +13,10 @@ function snapshot(overrides: Partial = {}): StateSnapshot { account: { cash: 0, reputation: 0 }, simState: { clockMs: 0, timeScale: 1, paused: false }, planes: [], - contracts: [], - catalog: [{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510 }], + packages: [], + catalog: [ + { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 }, + ], fuelPricePerL: 0.8, sellFraction: 0.5, ...overrides, @@ -30,11 +32,8 @@ describe('planesGeoJson', () => { const fc = planesGeoJson( snapshot({ 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-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 }, - ], - 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 }, + { 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', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 }, ], }), ); @@ -52,7 +51,7 @@ describe('planesGeoJson', () => { expect(flying.properties.flying).toBe(true); expect(flying.properties.status).toBe('flying'); 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', () => { @@ -60,18 +59,18 @@ describe('planesGeoJson', () => { snapshot({ catalog: [], 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'); }); - 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( snapshot({ 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', () => { expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); - it('draws nothing for open/completed/expired contracts', () => { - 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 }); + it('draws nothing for grounded planes, whatever their packages', () => { 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, ); 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( snapshot({ - contracts: [ - { id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EHAM', destName: 'A', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 }, + planes: [ + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 }, ], }), AIRPORTS, ); 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([ [8.56, 50.03], // EDDF [lon, lat] [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( snapshot({ planes: [ - { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: 'c1', departureSimTime: 0, lat: 51, lon: 9 }, - ], - 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 }, + { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 }, ], }), AIRPORTS, ); 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']); + }); }); diff --git a/lib/mapData.ts b/lib/mapData.ts index 8528dca..05907a7 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -1,9 +1,10 @@ import type { StateSnapshot } from './game/state'; import { formatFuel } from './format'; -// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature -// per plane, one LineString per accepted/in-flight contract. Kept out of -// components/Map.tsx so the classification logic is unit-testable without a DOM. +// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model +// v0.12.0): one Point feature per plane, one LineString per FLYING plane's +// current leg (origin → dest_icao). Kept out of components/Map.tsx so the +// classification logic is unit-testable without a DOM. export interface PlaneFeatureProps { id: string; @@ -15,8 +16,7 @@ export interface PlaneFeatureProps { } export interface RouteFeatureProps { - contractId: string; - kind: 'pending' | 'inflight'; + planeId: string; } export interface MapPointFeature { @@ -41,12 +41,10 @@ const EMPTY_LINES: FeatureCollection = { type: 'FeatureCollectio export function planesGeoJson(state: StateSnapshot | null): FeatureCollection { 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. const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label])); const features: MapPointFeature[] = state.planes.map((p) => { const flying = p.status === 'flying'; - const contract = p.contractId ? contractById.get(p.contractId) : undefined; return { type: 'Feature', geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, @@ -55,7 +53,7 @@ export function planesGeoJson(state: StateSnapshot | null): FeatureCollection, ): FeatureCollection { if (!state) return EMPTY_LINES; - const planeById = new Map(state.planes.map((p) => [p.id, p])); const features: MapLineFeature[] = []; - for (const c of state.contracts) { - if (c.status !== 'accepted') continue; - const from = airportsByIcao.get(c.originIcao); - const to = airportsByIcao.get(c.destIcao); + for (const p of state.planes) { + if (p.status !== 'flying' || !p.destIcao) continue; + const from = airportsByIcao.get(p.locationIcao); + const to = airportsByIcao.get(p.destIcao); if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess - const plane = c.planeId ? planeById.get(c.planeId) : undefined; features.push({ type: 'Feature', geometry: { @@ -94,7 +90,7 @@ export function routesGeoJson( [to.lon, to.lat], ], }, - properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' }, + properties: { planeId: p.id }, }); } return { type: 'FeatureCollection', features }; diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 3640d4d..a51ead0 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,13 +3,14 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; 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([ '/api/airports', - '/api/contracts/{id}/accept', '/api/market/buy-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/sim', '/api/state', @@ -57,7 +58,7 @@ describe('OpenAPI spec', () => { 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; expect(schema.$ref).toBe('#/components/schemas/StateSnapshot'); @@ -65,8 +66,8 @@ describe('OpenAPI spec', () => { expect(Object.keys(snap).sort()).toEqual([ 'account', 'catalog', - 'contracts', 'fuelPricePerL', + 'packages', 'planes', 'sellFraction', 'simState', @@ -74,8 +75,8 @@ describe('OpenAPI spec', () => { expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([ 'account', 'catalog', - 'contracts', 'fuelPricePerL', + 'packages', 'planes', 'sellFraction', 'simState', @@ -83,23 +84,25 @@ describe('OpenAPI spec', () => { expect(snap.account.$ref).toBe('#/components/schemas/AccountState'); expect(snap.simState.$ref).toBe('#/components/schemas/SimState'); 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.fuelPricePerL.type).toBe('number'); expect(snap.sellFraction.type).toBe('number'); 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) { 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.label.type).toBe('string'); const plane = openApiSpec.components.schemas.PlaneState.properties; expect(Object.keys(plane).sort()).toEqual([ - 'contractId', 'departureSimTime', + 'destIcao', + 'destName', 'fuel', 'id', 'lat', @@ -111,8 +114,8 @@ describe('OpenAPI spec', () => { 'status', ]); - const contract = openApiSpec.components.schemas.ContractState.properties; - expect(Object.keys(contract).sort()).toEqual([ + const pkg = openApiSpec.components.schemas.PackageState.properties; + expect(Object.keys(pkg).sort()).toEqual([ 'cargoCount', 'cargoType', 'createdSimTime', @@ -130,8 +133,8 @@ describe('OpenAPI spec', () => { const sim = openApiSpec.components.schemas.SimState.properties; expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']); - const contractStatus = openApiSpec.components.schemas.ContractState.properties.status; - expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']); + const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; + expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']); }); 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']); }); - 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. const post = (p: string) => ((openApiSpec.paths as Record)[p]?.post ?? {}) as { @@ -168,8 +171,9 @@ describe('OpenAPI spec', () => { // Every action returns the full StateSnapshot on success (response convention). for (const p of [ - '/api/contracts/{id}/accept', - '/api/planes/{planeId}/dispatch', + '/api/packages/{id}/load', + '/api/packages/{id}/abandon', + '/api/planes/{planeId}/fly', '/api/market/buy-plane', '/api/market/sell-plane', '/api/planes/{planeId}/refuel', @@ -180,15 +184,19 @@ describe('OpenAPI spec', () => { // Error shapes per endpoint. 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/planes/{planeId}/dispatch')).toEqual(['400', '404', '409']); + expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); + expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); + expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']); - // Request bodies: dispatch/buy/sell take their dedicated schemas; accept/refuel take none. - expect(post('/api/planes/{planeId}/dispatch').requestBody?.content['application/json'].schema.$ref).toBe( - '#/components/schemas/DispatchRequest', + // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none. + expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe( + '#/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( '#/components/schemas/BuyPlaneRequest', @@ -196,19 +204,23 @@ describe('OpenAPI spec', () => { expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe( '#/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(); // Path parameters are declared on the parameterized routes. const paramNames = (p: string) => post(p).parameters?.map((x) => x.name); - expect(paramNames('/api/contracts/{id}/accept')).toEqual(['id']); - expect(paramNames('/api/planes/{planeId}/dispatch')).toEqual(['planeId']); + expect(paramNames('/api/packages/{id}/load')).toEqual(['id']); + expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']); + expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']); expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']); // Request schema shapes. - const dispatch = openApiSpec.components.schemas.DispatchRequest; - expect(dispatch.required).toEqual(['contractId']); - expect(dispatch.properties.contractId.type).toBe('string'); + const load = openApiSpec.components.schemas.LoadPackageRequest; + expect(load.required).toEqual(['planeId']); + 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; expect(buy.required).toEqual(['model']); expect(buy.properties.model.type).toBe('string'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 68c5588..07d1a81 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -8,7 +8,7 @@ export const openApiSpec = { info: { title: 'Flight Ops API', 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. version: pkg.version, }, @@ -37,7 +37,7 @@ export const openApiSpec = { get: { summary: 'Game state snapshot', 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', responses: { 200: { @@ -55,7 +55,7 @@ export const openApiSpec = { post: { summary: 'Set sim time controls', 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', requestBody: { required: false, @@ -102,37 +102,75 @@ export const openApiSpec = { }, }, }, - '/api/contracts/{id}/accept': { + '/api/packages/{id}/load': { post: { - summary: 'Accept a contract', + summary: 'Load a package onto a plane', 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.', - operationId: 'acceptContract', + '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: 'loadPackage', parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoadPackageRequest' }, + }, + }, + }, responses: { 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' } } }, }, + 400: { + description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, 404: { - description: 'Unknown contract id', + description: 'Unknown package id or plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 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' } } }, }, }, }, }, - '/api/planes/{planeId}/dispatch': { + '/api/packages/{id}/abandon': { post: { - summary: 'Dispatch a plane on an accepted contract', + summary: 'Abandon a loaded package', 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).', - operationId: 'dispatchPlane', + '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: '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: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], @@ -140,26 +178,26 @@ export const openApiSpec = { required: true, content: { 'application/json': { - schema: { $ref: '#/components/schemas/DispatchRequest' }, + schema: { $ref: '#/components/schemas/FlyPlaneRequest' }, }, }, }, responses: { 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' } } }, }, 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' } } }, }, 404: { - description: 'Unknown plane id or contract id', + description: 'Unknown plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { - description: - 'Contract not "accepted", plane not "available", plane not at the origin, or insufficient fuel for the leg', + description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, @@ -317,9 +355,9 @@ export const openApiSpec = { type: 'array', items: { $ref: '#/components/schemas/PlaneState' }, }, - contracts: { + packages: { type: 'array', - items: { $ref: '#/components/schemas/ContractState' }, + items: { $ref: '#/components/schemas/PackageState' }, }, catalog: { type: 'array', @@ -329,19 +367,20 @@ export const openApiSpec = { 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.' }, }, - required: ['account', 'simState', 'planes', 'contracts', 'catalog', 'fuelPricePerL', 'sellFraction'], + required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], }, CatalogEntry: { type: 'object', 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").' }, price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' }, 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: { type: 'object', @@ -373,12 +412,19 @@ export const openApiSpec = { paused: { type: 'boolean' }, }, }, - DispatchRequest: { + LoadPackageRequest: { type: 'object', 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: { type: 'object', @@ -411,10 +457,17 @@ export const openApiSpec = { maxFuel: { type: 'number' }, locationIcao: { 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: { 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' }, lon: { type: 'number' }, @@ -427,13 +480,14 @@ export const openApiSpec = { 'maxFuel', 'locationIcao', 'locationName', - 'contractId', + 'destIcao', + 'destName', 'departureSimTime', 'lat', 'lon', ], }, - ContractState: { + PackageState: { type: 'object', properties: { id: { type: 'string' }, @@ -445,8 +499,9 @@ export const openApiSpec = { cargoType: { type: 'string' }, cargoCount: { type: 'integer' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, - // 'expired' = an open contract whose deadline passed; accepted/in-flight contracts never expire. - status: { enum: ['open', 'accepted', 'completed', 'expired'] }, + // 'expired' = an open OR loaded package whose deadline passed on a tick; + // loaded expired packages keep their slot, deliver no reward, abandon free. + status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }, planeId: { type: ['string', 'null'] }, createdSimTime: { type: 'integer' }, }, diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 10d003c..ee9de66 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -8,15 +8,15 @@ import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; 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 { - expireContracts, - finalizeArrivals, + deliverPackages, + expirePackages, MAX_TICK_ELAPSED_MS, reputationGainFor, - spawnCoverageContracts, - spawnOpenContracts, + spawnCoveragePackages, + spawnOpenPackages, tickOnce, } from './engine'; @@ -91,37 +91,41 @@ describe('tickOnce — clock advance', () => { }); }); -describe('expireContracts', () => { - it('flips only open contracts past their deadline; accepted ones never expire', async () => { +describe('expirePackages', () => { + it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { const inst = await freshDb('expiry.db'); const clock = T0 + 100_000; - await inst.db.insert(schema.contracts).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: 'c-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 }, + await inst.db.insert(schema.packages).values([ + { id: 'p-open-past', 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: '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); - expect(n).toBe(1); + const n = await expirePackages(inst, clock); + 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])); - expect(byId.get('c-open-past')).toBe('expired'); - expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet - expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire + const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status])); + expect(byId.get('p-open-past')).toBe('expired'); + expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet + 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 () => { const inst = await freshDb('expiry-edge.db'); - await inst.db.insert(schema.contracts).values({ - id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, + await inst.db.insert(schema.packages).values({ + id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, 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 TO = 'LEMD'; @@ -133,25 +137,21 @@ describe('finalizeArrivals', () => { const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const type = PLANE_TYPES.atr72; - // A dispatched flight: contract accepted + bound to the plane, plane flying - // with its fuel frozen at departure (the v0.8.0 dispatch step writes these). - 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, - }); + // A manual flight (v0.12.0 fly): plane flying with dest_icao set and its fuel + // frozen at departure — no contract involved anymore. 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')); return { inst, from, to, km, type }; } 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 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 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 }); - it('awards cash + reputation exactly once and grounds the plane at the destination', async () => { - const { inst, to, km, type } = await setupFlight('finalize-late.db'); + it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { + const { inst, to, km, type } = await setupFlight('deliver-mixed.db'); 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 - expect(await finalizeArrivals(inst, clock)).toBe(1); + expect(await deliverPackages(inst, clock)).toBe(1); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); @@ -173,65 +181,90 @@ describe('finalizeArrivals', () => { expect(plane.lon).toBe(to.lon); expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel 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')!; - expect(contract.status).toBe('completed'); + const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p])); + 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]!; - 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( Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, 9, ); // 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]!; expect(accountAfter.cash).toBe(account.cash); 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 () => { - const inst = await freshDb('finalize-noroute.db'); - // Flying plane whose contract row is missing — defensive skip, no crash. + const inst = await freshDb('deliver-noroute.db'); + // Flying plane whose dest_icao is missing — defensive skip, no crash. await inst.db.update(schema.planes) - .set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' }); - expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0); + .set({ status: 'flying', departureSimTime: T0 }); + expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0); }); }); -describe('spawnOpenContracts', () => { - it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => { +describe('spawnOpenPackages', () => { + it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => { const inst = await freshDb('spawn.db'); const clock = T0 + 123_456; - // Leave exactly 3 open contracts (the rest of the seeded set is "done"). - const all = await inst.db.select().from(schema.contracts); - for (const c of all.slice(3)) { - await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id)); + // Leave exactly 3 open packages (the rest of the seeded set is "done"). + const all = await inst.db.select().from(schema.packages); + for (const p of all.slice(3)) { + await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id)); } - const needed = await spawnOpenContracts(inst, clock, mulberry32(7)); - expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3); + const needed = await spawnOpenPackages(inst, clock, mulberry32(7)); + expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3); - const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open'); - expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS); + const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); + expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES); const fc = await loadAirportsGeoJson(); const icaoSet = new Set(fc.features.map((f) => f.properties.icao)); - for (const c of openRows.slice(3)) { // the spawned ones - expect(c.createdSimTime).toBe(clock); // created at game time, not real time - expect(icaoSet.has(c.originIcao)).toBe(true); - expect(icaoSet.has(c.destIcao)).toBe(true); - expect(c.originIcao).not.toBe(c.destIcao); - expect(c.planeId).toBeNull(); + for (const p of openRows.slice(3)) { // the spawned ones + expect(p.createdSimTime).toBe(clock); // created at game time, not real time + expect(icaoSet.has(p.originIcao)).toBe(true); + expect(icaoSet.has(p.destIcao)).toBe(true); + expect(p.originIcao).not.toBe(p.destIcao); + expect(p.planeId).toBeNull(); // Same deadline window as the seed: now + 6–72h game time. - expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1); - expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000); + expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1); + expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000); } // 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 () => { @@ -239,103 +272,103 @@ describe('spawnOpenContracts', () => { const b = await freshDb('spawn-b.db'); 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]) { - const rows = await inst.db.select().from(schema.contracts); + const rows = await inst.db.select().from(schema.packages); 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 spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS); + expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); + expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); - const strip = (rows: schema.ContractRow[]) => - rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); - expect(strip(await a.db.select().from(schema.contracts))).toEqual( - strip(await b.db.select().from(schema.contracts)), + const strip = (rows: schema.PackageRow[]) => + rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); + expect(strip(await a.db.select().from(schema.packages))).toEqual( + strip(await b.db.select().from(schema.packages)), ); }); }); -describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => { - // The seeded starter plane is grounded at EDDF with no seeded contract from +describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => { + // 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. - 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 clock = T0 + 5_000; - // Wipe all seeded contracts so coverage is the only source of new ones. - for (const c of await inst.db.select().from(schema.contracts)) { - await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id)); + // Wipe all seeded packages so coverage is the only source of new ones. + for (const p of await inst.db.select().from(schema.packages)) { + 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) - 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[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport 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'); - for (const c of await inst.db.select().from(schema.contracts)) { - await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id)); + for (const p of await inst.db.select().from(schema.packages)) { + await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } - // One open contract from EDDF covers the starter plane's airport. - await inst.db.insert(schema.contracts).values({ - id: 'c-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, + // One open package from EDDF covers the starter plane's airport. + await inst.db.insert(schema.packages).values({ + id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0, }); - expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0); - expect((await inst.db.select().from(schema.contracts)).length).toBe(1); // nothing added + expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); + 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'); - for (const c of await inst.db.select().from(schema.contracts)) { - await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id)); + for (const p of await inst.db.select().from(schema.packages)) { + 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. 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-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, 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', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null }, ]); - const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7)); - expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD + const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7)); + expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD - const origins = (await inst.db.select().from(schema.contracts)) - .filter((c) => c.status === 'open') - .map((c) => c.originIcao) + const origins = (await inst.db.select().from(schema.packages)) + .filter((p) => p.status === 'open') + .map((p) => p.originIcao) .sort(); expect(origins).toEqual(['EDDF', 'LEMD']); }); it('ignores flying planes — they cannot take new work until they land', async () => { const inst = await freshDb('coverage-flying.db'); - for (const c of await inst.db.select().from(schema.contracts)) { - await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id)); + for (const p of await inst.db.select().from(schema.packages)) { + await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } 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')); - 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'); vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1 await tickOnce(inst); - const fromEdff = (await inst.db.select().from(schema.contracts)).filter( - (c) => c.status === 'open' && c.originIcao === 'EDDF', + const fromEdff = (await inst.db.select().from(schema.packages)).filter( + (p) => p.status === 'open' && p.originIcao === 'EDDF', ); expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline }); diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 49d20c8..4f90897 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -1,13 +1,13 @@ 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 * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { - generateContract, - MARKET_TARGET_OPEN_CONTRACTS, + generatePackage, + MARKET_TARGET_OPEN_PACKAGES, mulberry32, type Rng, type SeedAirport, @@ -29,7 +29,7 @@ export const MAX_TICK_ELAPSED_MS = 2_000; export const MIN_TIME_SCALE = 1; 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 // cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10. export const REP_BASE = 1; @@ -72,12 +72,12 @@ export async function tickOnce(inst: GameDb): Promise { const clockMs = Math.round(sim.clockMs + advanceMs); if (advanceMs > 0) { - await expireContracts(inst, clockMs); - await finalizeArrivals(inst, clockMs); - await spawnOpenContracts(inst, clockMs); - // AFTER finalization on purpose: a plane that just landed gets work spawned + await expirePackages(inst, clockMs); + await deliverPackages(inst, clockMs); + await spawnOpenPackages(inst, clockMs); + // AFTER delivery on purpose: a plane that just landed gets work spawned // 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. @@ -88,42 +88,45 @@ export async function tickOnce(inst: GameDb): Promise { return updated; } -// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight -// contracts never expire — they complete. Returns the number flipped. -export async function expireContracts(inst: GameDb, clockMs: number): Promise { - const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')); +// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave +// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep +// occupying their slot, deliver no reward on arrival, and abandon for free). +// Delivered/abandoned packages are terminal and never flip. Returns the count. +export async function expirePackages(inst: GameDb, clockMs: number): Promise { + const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded'])); let n = 0; - for (const c of open) { - if (c.deadlineSimTime <= clockMs) { - await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id)); + for (const p of rows) { + if (p.deadlineSimTime <= clockMs) { + await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id)); n++; } } return n; } -// Finalize in-flight planes whose arrival time has passed: award the contract -// reward (cash) + reputation gain exactly once, mark the contract completed, and -// ground the plane at the destination with its remaining fuel. Idempotent — a -// finalized plane leaves the 'flying' state, so it can never be awarded twice. -export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise { - const [planeRows, contractRows] = await Promise.all([ +// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery): +// ground the plane at its dest_icao with its remaining fuel, then for every +// package bound to it and destined for that airport — 'loaded' ones still inside +// their deadline are delivered (cash += reward + reputation gain per package), +// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded +// 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 { + const [planeRows, packageRows] = await Promise.all([ inst.db.select().from(schema.planes), - inst.db.select().from(schema.contracts), + inst.db.select().from(schema.packages), ]); const fc = await loadAirportsGeoJson(); 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; for (const p of planeRows) { - if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue; - const contract = contractById.get(p.contractId); + if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) continue; const type = PLANE_TYPES[p.model]; - const from = contract ? byIcao.get(contract.originIcao) : undefined; - const to = contract ? byIcao.get(contract.destIcao) : undefined; - if (!contract || !type || !from || !to) continue; // no route info — leave alone + const from = byIcao.get(p.locationIcao); + const to = byIcao.get(p.destIcao); + if (!type || !from || !to) continue; // no route info — leave alone const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; @@ -137,35 +140,44 @@ export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise pk.planeId === p.id && pk.destIcao === to.icao); + for (const pk of onBoard) { + if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) { + await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id)); + // Fresh account read per award so multiple deliveries in one tick all count. + const fresh = (await inst.db.select().from(schema.account))[0]; + 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++; } return n; } -// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the -// SAME builder as the first-run seed (generateContract), so spawned contracts are +// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the +// SAME builder as the first-run seed (generatePackage), so spawned packages are // indistinguishable from seeded ones. RNG is injectable for deterministic tests. -export async function spawnOpenContracts( +export async function spawnOpenPackages( inst: GameDb, clockMs: number, rng?: Rng, ): Promise { 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; - const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount; + const needed = MARKET_TARGET_OPEN_PACKAGES - openCount; if (needed <= 0) return 0; const fc = await loadAirportsGeoJson(); @@ -178,27 +190,27 @@ export async function spawnOpenContracts( const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); 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; } // 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 -// contract originating there — so work is always available where the fleet sits -// (including onboarding at EDDF). One contract per uncovered airport, generated -// with generateContract and origin forced to that airport. Runs after -// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage -// too; and after finalizeArrivals, so a plane that just landed gets work spawned +// package originating there — so work is always available where the fleet sits +// (including onboarding at EDDF). One package per uncovered airport, generated +// with generatePackage and origin forced to that airport. Runs after +// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage +// 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. -export async function spawnCoverageContracts( +export async function spawnCoveragePackages( inst: GameDb, clockMs: number, rng?: Rng, ): Promise { const [planeRows, openRows] = await Promise.all([ 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 — @@ -224,8 +236,8 @@ export async function spawnCoverageContracts( let n = 0; for (const icao of missing) { if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs - await inst.db.insert(schema.contracts).values( - generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }), + await inst.db.insert(schema.packages).values( + generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }), ); n++; } diff --git a/lib/useGameState.test.ts b/lib/useGameState.test.ts index 9fb4707..8201745 100644 --- a/lib/useGameState.test.ts +++ b/lib/useGameState.test.ts @@ -8,7 +8,7 @@ const SNAPSHOT = { account: { cash: 20_000, reputation: 10 }, simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false }, planes: [], - contracts: [], + packages: [], }; afterEach(() => { diff --git a/package.json b/package.json index 42b97d6..c138e11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.11.0", + "version": "0.12.0", "private": true, "scripts": { "dev": "next dev -p 3000", diff --git a/playwright.config.ts b/playwright.config.ts index bf3e1f9..72b01de 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,6 @@ import { defineConfig } from '@playwright/test'; -// Browsers run on the shared remote Playwright server (Docker, 192.168.1.4:3000); -// 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'; +const remoteRunner = true; if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT) { 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, // which would break any map test running concurrently in another worker. workers: 1, - timeout: 30_000, + timeout: 300_000, retries: 1, use: { baseURL: process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl', @@ -25,8 +21,8 @@ export default defineConfig({ ? undefined : { command: 'docker compose up -d --build', - url: 'https://flight.dev.kkhost.pl/api/airports', + url: 'https://flight.dev.kkhost.pl', reuseExistingServer: true, - timeout: 300_000, + timeout: 600_000, }, -}); +}); \ No newline at end of file