diff --git a/CHANGELOG.md b/CHANGELOG.md index a818977..898205d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.8.0 — 2026-09-17 13:54 +- **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies +- **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane +- **Dispatch**: `POST /api/planes/:planeId/dispatch` body `{contractId}` — validates in order: contract exists & status 'accepted' (409 otherwise), plane exists & 'available', `plane.locationIcao === contract.originIcao` (409 "not at origin"), fuel sufficiency `burnPerKm × haversineKm(origin,dest) ≤ plane.fuel` (409 "insufficient fuel"); unknown ids → 404. On success sets the plane 'flying' with `departureSimTime = current clockMs` and lat/lon pinned to the origin — the v0.7.0 finalize path then flies it analytically and pays out on arrival, unchanged +- **Market**: `POST /api/market/buy-plane` body `{model}` (400 unknown catalog model; 409 insufficient funds) deducts the full price and delivers a full-tank 'available' plane at home base EDDF; `POST /api/market/sell-plane` body `{planeId}` (409 if flying, 409 for the last remaining plane — the company always keeps ≥1 aircraft) pays out 50% of catalog price and deletes the row. Catalog gains `price: 200_000` on atr72 (with €20k starting cash and ~€1–3k hop rewards, a second ATR takes sustained play — the intended MVP money sink), plus constants `FUEL_PRICE_PER_L = 0.8` €/L and `SELL_FRACTION = 0.5` +- **Refuel**: `POST /api/planes/:planeId/refuel` (no body) — grounded planes only (409 if flying); cost `(maxFuel − fuel) × FUEL_PRICE_PER_L`, cash-checked (409), fills to max; a full tank is a free no-op +- **Spawn coverage rule** (`engine.spawnCoverageContracts`, in-tick after top-up + finalization): in addition to topping the market up to 10 random open contracts, every airport that currently has a grounded 'available' plane must have ≥1 OPEN contract originating there — one generated per uncovered airport via `generateContract` with origin forced. Guarantees ongoing work wherever the fleet sits (including onboarding at EDDF), and because it runs after finalization a plane that just landed gets work spawned at its new location in the same tick; flying planes don't count +- **`generateContract` gains optional `originIcao`/`destIcao`** to force endpoints (used by the coverage rule + test hook); forced endpoints consume no RNG draws, so existing callers are byte-identical (a random origin landing on a forced destination is shifted, mirroring the unforced collision handling) +- **Snapshot gains `planes[].departureSimTime`** (null when grounded; sim-clock ms set by dispatch while flying) so clients can compute progress/ETA from the clock — documented in OpenAPI PlaneState +- **Test hook generalized**: new `POST /api/test/create-contract` body `{originIcao?, destIcao?, deadlineOffsetMs?}` creates an open contract on a known route (400 unknown ICAO/identical endpoints; default 6–72h window), sibling of `/api/test/expire-contract` under the same `FLIGHT_TEST_HOOKS=1` gate — lets e2e exercise the full loop on a short, deterministic route +- **OpenAPI**: all five action endpoints documented with request schemas (DispatchRequest/BuyPlaneRequest/SellPlaneRequest), path parameters, per-endpoint 4xx error shapes and the StateSnapshot success shape; `lib/openapi.test.ts` + `e2e/api.spec.ts` endpoint lists updated four → nine +- Tests: unit — new `lib/game/actions.test.ts` (full validation matrix: accept non-open/unknown; dispatch wrong-status ×3, unknown ids, flying plane, not-at-origin, insufficient fuel, success state incl. frozen departure fuel; buy unknown-model/poor/success with home-base delivery; sell flying/last-plane/unknown/success payout+delete; refuel cost math/full-tank no-op/flying/poor/unknown), `lib/sim/engine.test.ts` gains spawn-coverage tests (uncovered grounded airport ⇒ forced-origin contract, already-covered no-op, multi-airport dedupe, flying planes ignored, full tickOnce pipeline) and `lib/game/seed.test.ts` the forced-endpoint matrix; new colocated route tests for all five endpoints (HTTP status codes + snapshot response convention against temp DBs); e2e — new `e2e/actions.spec.ts`: THE full loop on the live origin (self-healing starter plane, hook-created EDDF→closest-airport contract, accept → dispatch with departure set and position moving across polls at scale 60 → arrival paying the EXACT reward + rep gain with analytic remaining fuel → refuel cost deduction → fly home to restore onboarding state) plus the dispatch-without-accept 409 / unknown-id 404 path +- Verified: unit suite 86 → 143 green; `tsc --noEmit` clean; e2e 16 → 18 green against the rebuilt compose instance (full loop measured at scale 60); live curl of dispatch + arrival shows the exact cash/rep delta + ## v0.7.0 — 2026-09-17 12:27 - **Server-authoritative sim engine** (PLAN.md "Simulation engine" increment 2) in new `lib/sim/` — no frontend/UI changes; dispatch of planes lands next increment, but all the clock/market/finalization machinery it builds on is live and tested now - **Tick-on-read game clock** (`lib/sim/engine.ts`, NO background timer): every read/action endpoint calls `tickNow()` first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advances `clockMs` by `(real ms since last tick) × timeScale`. New `sim_state.last_real_tick_ms` column stores the real-time anchor (seed sets it to the seed moment). **Clamp: `MAX_TICK_ELAPSED_MS = 2000`** — a single tick counts at most 2 s of real absence, chosen above a normal ~1 s poll interval (active play advances ~real time × scale) but small enough that one stale read after an absence moves game time by ≤ 2 s × scale (≤ 2 min at max scale 60), so the sim only meaningfully progresses while actively polled. While paused the clock is frozen but `lastRealTickMs` keeps tracking real time, so unpausing resumes with no catch-up jump. Ticks are serialized through a module-level promise lock (concurrent Next.js requests can't double-advance) and each tick persists via the existing sql.js atomic export diff --git a/app/api/contracts/[id]/accept/route.test.ts b/app/api/contracts/[id]/accept/route.test.ts new file mode 100644 index 0000000..32f894a --- /dev/null +++ b/app/api/contracts/[id]/accept/route.test.ts @@ -0,0 +1,65 @@ +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 }>; + }; + expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', '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 new file mode 100644 index 0000000..d1a0647 --- /dev/null +++ b/app/api/contracts/[id]/accept/route.ts @@ -0,0 +1,10 @@ +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/buy-plane/route.test.ts b/app/api/market/buy-plane/route.test.ts new file mode 100644 index 0000000..433dbf3 --- /dev/null +++ b/app/api/market/buy-plane/route.test.ts @@ -0,0 +1,66 @@ +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 { PLANE_TYPES } from '@/lib/game/catalog'; +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-buy-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) })); + +describe('POST /api/market/buy-plane', () => { + it('rejects a missing model, unknown fields and unknown models with 400', async () => { + for (const body of [{}, { model: 7 }, { bogus: 1 }, { model: 'boeing747' }]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + }); + + it('rejects the purchase with 409 when cash is below the price (seeded €20k < €200k)', async () => { + const res = await post({ model: 'atr72' }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); + + // Cash untouched by the failed attempt. + const account = (await (await getGameDb()).db.select().from(schema.account))[0]; + expect(account.cash).toBe(20_000); + }); + + it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => { + const { db } = await getGameDb(); + // Single-row account table — top up cash so the purchase can succeed. + await db.update(schema.account).set({ cash: 500_000 }); + + const res = await post({ model: 'atr72' }); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + account: { cash: number }; + planes: Array<{ id: string; model: string; status: string; fuel: number; locationIcao: string }>; + }; + expect(body.account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); + + const bought = body.planes.filter((p) => p.model === 'atr72'); + expect(bought.length).toBe(2); // starter + the new one + const fresh = bought.find((p) => p.id !== 'plane-starter')!; + expect(fresh.status).toBe('available'); + expect(fresh.fuel).toBe(PLANE_TYPES.atr72.maxFuel); + expect(fresh.locationIcao).toBe('EDDF'); // home base delivery + }); +}); diff --git a/app/api/market/buy-plane/route.ts b/app/api/market/buy-plane/route.ts new file mode 100644 index 0000000..0715d92 --- /dev/null +++ b/app/api/market/buy-plane/route.ts @@ -0,0 +1,31 @@ +import { buyPlane, performAction } from '@/lib/game/actions'; + +// POST /api/market/buy-plane (v0.8.0) — body { model: string }. Buys a catalog +// aircraft at full price; it arrives 'available' with a full tank at the home base +// (EDDF). 400 missing/non-string model or unknown catalog model; 409 insufficient +// funds. On success returns the full state snapshot (new plane + reduced cash). + +export async function POST(req: Request) { + 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 !== 'model') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + if (typeof input.model !== 'string' || input.model.length === 0) { + return Response.json({ error: 'model must be a non-empty string' }, { status: 400 }); + } + + const model = input.model; + return performAction((inst) => buyPlane(inst, model)); +} diff --git a/app/api/market/sell-plane/route.test.ts b/app/api/market/sell-plane/route.test.ts new file mode 100644 index 0000000..161539c --- /dev/null +++ b/app/api/market/sell-plane/route.test.ts @@ -0,0 +1,76 @@ +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 { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; +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-sell-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) })); + +describe('POST /api/market/sell-plane', () => { + it('rejects a missing planeId and unknown fields with 400', async () => { + for (const body of [{}, { planeId: 7 }, { bogus: 1 }]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + }); + + it('rejects an unknown id with 404', async () => { + const res = await post({ planeId: 'ghost' }); + expect(res.status).toBe(404); + }); + + it('rejects selling the last remaining plane with 409 (seeded DB has only the starter)', async () => { + const res = await post({ planeId: 'plane-starter' }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch(/last remaining/); + }); + + it('rejects selling a flying plane with 409', async () => { + const { db } = await getGameDb(); + // 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, + }); + await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra')); + + const res = await post({ planeId: 'plane-extra' }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch(/flying/); + }); + + it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => { + const { db } = await getGameDb(); + // The previous test left 'plane-extra' in the DB (now flying) — ground it. + await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra')); + const cashBefore = (await db.select().from(schema.account))[0].cash; + + const res = await post({ planeId: 'plane-extra' }); + expect(res.status).toBe(200); + + const body = (await res.json()) as { account: { cash: number }; planes: Array<{ id: string }> }; + const payout = Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100; + expect(body.account.cash).toBeCloseTo(cashBefore + payout, 6); + expect(body.planes.map((p) => p.id)).not.toContain('plane-extra'); // row deleted + expect(body.planes.map((p) => p.id)).toContain('plane-starter'); + }); +}); diff --git a/app/api/market/sell-plane/route.ts b/app/api/market/sell-plane/route.ts new file mode 100644 index 0000000..0ae1d06 --- /dev/null +++ b/app/api/market/sell-plane/route.ts @@ -0,0 +1,31 @@ +import { performAction, sellPlane } from '@/lib/game/actions'; + +// POST /api/market/sell-plane (v0.8.0) — body { planeId: string }. Sells a grounded +// plane for 50% of its catalog price and deletes the row. 400 missing/non-string +// planeId or unknown fields; 404 unknown id; 409 if the plane is flying or it is +// the last remaining plane. On success returns the full state snapshot. + +export async function POST(req: Request) { + 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) => sellPlane(inst, planeId)); +} diff --git a/app/api/planes/[planeId]/dispatch/route.test.ts b/app/api/planes/[planeId]/dispatch/route.test.ts new file mode 100644 index 0000000..3bbca71 --- /dev/null +++ b/app/api/planes/[planeId]/dispatch/route.test.ts @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..4343ef3 --- /dev/null +++ b/app/api/planes/[planeId]/dispatch/route.ts @@ -0,0 +1,35 @@ +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]/refuel/route.test.ts b/app/api/planes/[planeId]/refuel/route.test.ts new file mode 100644 index 0000000..ed6d071 --- /dev/null +++ b/app/api/planes/[planeId]/refuel/route.test.ts @@ -0,0 +1,77 @@ +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 { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog'; +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-refuel-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (planeId: string) => POST(new Request(`http://localhost/api/planes/${planeId}/refuel`), { params: Promise.resolve({ planeId }) }); + +describe('POST /api/planes/:planeId/refuel', () => { + it('rejects an unknown id with 404', async () => { + const res = await post('ghost'); + expect(res.status).toBe(404); + }); + + it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => { + const { db } = await getGameDb(); + await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); + const cashBefore = (await db.select().from(schema.account))[0].cash; + + const res = await post('plane-starter'); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + account: { cash: number }; + planes: Array<{ id: string; fuel: number; maxFuel: number }>; + }; + const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100; + expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6); + + const plane = body.planes.find((p) => p.id === 'plane-starter')!; + expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full + }); + + 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' }) + .where(eq(schema.planes.id, 'plane-starter')); + + const res = await post('plane-starter'); + 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, contractId: null }) + .where(eq(schema.planes.id, 'plane-starter')); + }); + + it('rejects the fill-up with 409 when cash is below the cost', async () => { + const { db } = await getGameDb(); + await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); + await db.update(schema.account).set({ cash: 10 }); // cost is ~4960 + + const res = await post('plane-starter'); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); + }); +}); diff --git a/app/api/planes/[planeId]/refuel/route.ts b/app/api/planes/[planeId]/refuel/route.ts new file mode 100644 index 0000000..c2bce45 --- /dev/null +++ b/app/api/planes/[planeId]/refuel/route.ts @@ -0,0 +1,11 @@ +import { performAction, refuelPlane } from '@/lib/game/actions'; + +// POST /api/planes/:planeId/refuel (v0.8.0) — no body. Fills a grounded plane to +// maxFuel at the fuel price per missing liter; deducts the cost from cash. 404 +// unknown id; 409 if the plane is flying or cash is insufficient. On success +// returns the full state snapshot (fuel back to max, cash reduced by the cost). + +export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { + const { planeId } = await ctx.params; + return performAction((inst) => refuelPlane(inst, planeId)); +} diff --git a/app/api/test/create-contract/route.ts b/app/api/test/create-contract/route.ts new file mode 100644 index 0000000..2a7152a --- /dev/null +++ b/app/api/test/create-contract/route.ts @@ -0,0 +1,89 @@ +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 { 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. +// +// POST /api/test/create-contract +// 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. +// - 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. + +export async function POST(req: Request) { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + let body: unknown = {}; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + for (const key of ['originIcao', 'destIcao'] as const) { + if (input[key] !== undefined && typeof input[key] !== 'string') { + return Response.json({ error: `${key} must be a string` }, { status: 400 }); + } + } + if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) { + return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 }); + } + + try { + const inst = await getGameDb(); + const sim = await tickNow(); // clock is current before "now" for the new contract + + const fc = await loadAirportsGeoJson(); + const airports = fc.features.map((f) => ({ + icao: f.properties.icao, + name: f.properties.name, + lat: f.properties.lat, + lon: f.properties.lon, + })); + const knownIcas = new Set(airports.map((a) => a.icao)); + for (const key of ['originIcao', 'destIcao'] as const) { + const v = input[key]; + if (typeof v === 'string' && !knownIcas.has(v)) { + return Response.json({ error: `unknown airport ${v}` }, { status: 400 }); + } + } + + const rng = mulberry32(randomInt(0, 2 ** 31)); + const created = generateContract({ + id: randomUUID(), + airports, + rng, + nowMs: sim.clockMs, + deadlineOffsetMs: input.deadlineOffsetMs as number | undefined, + originIcao: input.originIcao as string | undefined, + destIcao: input.destIcao as string | undefined, + }); + await inst.db.insert(schema.contracts).values(created); + await persist(inst); + return Response.json(created, { status: 201 }); + } catch (err) { + console.error('test hook failed', err); + return Response.json({ error: 'test hook failed' }, { status: 500 }); + } +} diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts new file mode 100644 index 0000000..a7373e6 --- /dev/null +++ b/e2e/actions.spec.ts @@ -0,0 +1,239 @@ +import { test, expect, type Page } 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. + +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 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; +} + +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))); +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.status()).toBe(200); + return (await res.json()) as StateBody; +} + +async function postSim(page: Page, body: unknown) { + return page.request.post('/api/sim', { data: body }); +} + +// 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', { + data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, + }); + expect(created.status()).toBe(201); + return (await created.json()) as ContractState; +} + +// Create + accept + dispatch a contract from → to on plane-starter, then poll at +// 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 } }>; + }; + 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 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; + } + + // 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); + + // 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 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); +}); + +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')!; + + // 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; + + 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/); + + const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } }); + expect(unknown.status()).toBe(404); +}); diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 9ad969c..253280a 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,12 +13,23 @@ 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 four endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all nine 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); - expect(Object.keys(spec.paths).sort()).toEqual(['/api/airports', '/api/sim', '/api/state', '/api/version']); + // v0.8.0 adds the five player-action endpoints to the four read-only ones. + 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/planes/{planeId}/refuel', + '/api/sim', + '/api/state', + '/api/version', + ]); }); diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts new file mode 100644 index 0000000..5d32a45 --- /dev/null +++ b/lib/game/actions.test.ts @@ -0,0 +1,290 @@ +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, vi } from 'vitest'; +import { openGameDb, type GameDb } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; +import { loadAirportsGeoJson } from '@/lib/airports'; +import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; +import { HUB_ICAO } from './seed'; +import { + acceptContract, + buyPlane, + dispatchPlane, + GameActionError, + refuelPlane, + sellPlane, +} from './actions'; + +// Action tests run against throwaway DB files (openGameDb) with fake timers so +// the seeded sim clock is a known constant. Actions are called directly (no tick — +// engine.runAction() wraps them in production; its lock/tick behavior is covered +// by the route tests and e2e). + +let dir: string; +const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open + +beforeAll(async () => { + vi.useFakeTimers(); + vi.setSystemTime(T0); + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-actions-')); +}); + +afterAll(async () => { + vi.useRealTimers(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function freshDb(name: string): Promise { + vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now" + return openGameDb(path.join(dir, name)); +} + +function expectActionError(promise: Promise, status: number, messagePart?: RegExp) { + return promise.then( + () => { + throw new Error(`expected GameActionError(${status}) but the action succeeded`); + }, + (err) => { + expect(err).toBeInstanceOf(GameActionError); + expect((err as GameActionError).status).toBe(status); + if (messagePart) expect((err as GameActionError).message).toMatch(messagePart); + }, + ); +} + +// 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({ + id, + originIcao: HUB_ICAO, + destIcao: 'LEMD', + reward: 5_000, + cargoType: 'Passengers', + cargoCount: 60, + deadlineSimTime: T0 + 24 * 3_600_000, + status: 'accepted', + planeId: null, + createdSimTime: T0, + }); +} + +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 + + const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0]; + expect(row.status).toBe('accepted'); + }); + + 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')); + + await expectActionError(acceptContract(inst, 'contract-1'), 409, new RegExp(status)); + }); + + it('rejects an unknown id with 404', async () => { + const inst = await freshDb('acc-unknown.db'); + await expectActionError(acceptContract(inst, 'nope'), 404); + }); +}); + +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); + + await dispatchPlane(inst, 'plane-starter', 'c-acc'); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('flying'); + expect(plane.contractId).toBe('c-acc'); + 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; + expect(plane.lat).toBe(origin.lat); + 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); + await inst.db.update(schema.planes) + .set({ status: 'flying', departureSimTime: T0, contractId: 'c-other' }) + .where(eq(schema.planes.id, 'plane-starter')); + + await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 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')); + + await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /not at|at LEMD/); + }); + + 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 + 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); + }); +}); + +describe('buyPlane', () => { + it('deducts the full price and delivers a full-tank plane at the home base', async () => { + const inst = await freshDb('buy-ok.db'); + await inst.db.update(schema.account).set({ cash: 500_000 }); + + const before = (await inst.db.select().from(schema.planes)).length; + const { planeId } = await buyPlane(inst, 'atr72'); + + const account = (await inst.db.select().from(schema.account))[0]; + expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); + + const planes = await inst.db.select().from(schema.planes); + expect(planes.length).toBe(before + 1); + const bought = planes.find((p) => p.id === planeId)!; + expect(bought.model).toBe('atr72'); + expect(bought.status).toBe('available'); + expect(bought.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // full tank on delivery + expect(bought.maxFuel).toBe(PLANE_TYPES.atr72.maxFuel); + expect(bought.locationIcao).toBe(HUB_ICAO); + const fc = await loadAirportsGeoJson(); + const hub = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; + expect(bought.lat).toBe(hub.lat); + expect(bought.lon).toBe(hub.lon); + }); + + it('rejects an unknown catalog model with 400', async () => { + const inst = await freshDb('buy-unknown.db'); + await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/); + }); + + it('rejects the purchase when cash is below the price with 409', async () => { + const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k + await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); + }); +}); + +describe('sellPlane', () => { + it('pays out SELL_FRACTION of the catalog price and deletes the row', async () => { + const inst = await freshDb('sell-ok.db'); + // 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, + }); + const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + + const { payout } = await sellPlane(inst, 'plane-extra'); + + expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); + const account = (await inst.db.select().from(schema.account))[0]; + expect(account.cash).toBeCloseTo(cashBefore + payout, 6); + const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); + expect(ids).not.toContain('plane-extra'); // row deleted + expect(ids).toContain('plane-starter'); + }); + + it('rejects selling a flying plane with 409', async () => { + 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, + }); + + await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); + }); + + it('rejects selling the last remaining plane with 409', async () => { + const inst = await freshDb('sell-last.db'); // only the starter exists + await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); + }); + + it('rejects an unknown id with 404', async () => { + const inst = await freshDb('sell-unknown.db'); + await expectActionError(sellPlane(inst, 'ghost'), 404); + }); +}); + +describe('refuelPlane', () => { + it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => { + const inst = await freshDb('refuel-ok.db'); + await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); + const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + + const { cost } = await refuelPlane(inst, 'plane-starter'); + + expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100); + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); + const account = (await inst.db.select().from(schema.account))[0]; + expect(account.cash).toBeCloseTo(cashBefore - cost, 6); + }); + + it('is a free no-op when the tank is already full', async () => { + const inst = await freshDb('refuel-full.db'); // seeded starter is full + const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + + const { cost } = await refuelPlane(inst, 'plane-starter'); + + expect(cost).toBe(0); + expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); + }); + + 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' }) + .where(eq(schema.planes.id, 'plane-starter')); + + await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/); + }); + + it('rejects the fill-up when cash is below the cost with 409', async () => { + const inst = await freshDb('refuel-poor.db'); + await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); + await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960 + + await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i); + }); + + it('rejects an unknown id with 404', async () => { + const inst = await freshDb('refuel-unknown.db'); + await expectActionError(refuelPlane(inst, 'ghost'), 404); + }); +}); diff --git a/lib/game/actions.ts b/lib/game/actions.ts new file mode 100644 index 0000000..6965718 --- /dev/null +++ b/lib/game/actions.ts @@ -0,0 +1,215 @@ +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import * as schema from '@/lib/db/schema'; +import { persist, type GameDb } from '@/lib/db/client'; +import { loadAirportsGeoJson } from '@/lib/airports'; +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 { 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: +// 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) + +export class GameActionError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'GameActionError'; + } +} + +async function airportByIcao(icao: string): Promise<{ icao: string; lat: number; lon: number }> { + const fc = await loadAirportsGeoJson(); + const f = fc.features.find((x) => x.properties.icao === icao); + if (!f) throw new Error(`airport ${icao} missing from dataset`); // → 500, cannot happen for seeded data + return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon }; +} + +// --- Accept (POST /api/contracts/:id/accept) --- + +// 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([ + 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 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`); + } + if (plane.locationIcao !== contract.originIcao) { + throw new GameActionError( + 409, + `plane is at ${plane.locationIcao} but the contract originates at ${contract.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 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`); + } + + // runAction() ticked first, so this is current game time. + const sim = (await inst.db.select().from(schema.simState))[0]; + if (!sim) throw new Error('sim_state row missing'); + + await inst.db.update(schema.planes) + .set({ + status: 'flying', + contractId, + 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); +} + +// --- Market (POST /api/market/buy-plane {model}) --- + +// Buy a catalog model at full price; it arrives 'available' with a full tank at +// the home base (EDDF). +export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> { + const type = PLANE_TYPES[model]; + if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); + + const account = (await inst.db.select().from(schema.account))[0]; + if (!account) throw new Error('account row missing'); + if (account.cash < type.price) { + throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`); + } + + const fc = await loadAirportsGeoJson(); + // Same hub fallback as the seed (HUB_ICAO, else first airport). + const home = fc.features.find((f) => f.properties.icao === HUB_ICAO)?.properties ?? fc.features[0].properties; + + const planeId = randomUUID(); + await inst.db.insert(schema.planes).values({ + id: planeId, + model, + status: 'available', + fuel: type.maxFuel, // full tank on delivery + maxFuel: type.maxFuel, + locationIcao: home.icao, + contractId: null, + lat: home.lat, + lon: home.lon, + departureSimTime: null, + }); + await inst.db.update(schema.account).set({ cash: account.cash - type.price }); + await persist(inst); + return { planeId }; +} + +// --- Market (POST /api/market/sell-plane {planeId}) --- + +// Sell a grounded plane for SELL_FRACTION of its catalog price. The last +// remaining plane can never be sold — the company needs at least one aircraft. +export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> { + const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); + const plane = rows[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 be sold`); + } + + const all = await inst.db.select().from(schema.planes); + if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); + + const type = PLANE_TYPES[plane.model]; + if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data + const payout = Math.round(type.price * SELL_FRACTION * 100) / 100; + + await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId)); + const account = (await inst.db.select().from(schema.account))[0]; + if (!account) throw new Error('account row missing'); + await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 }); + await persist(inst); + return { payout }; +} + +// --- Refuel (POST /api/planes/:planeId/refuel) --- + +// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter. +export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { + const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); + const plane = rows[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 be refueled`); + } + + const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100; + const account = (await inst.db.select().from(schema.account))[0]; + if (!account) throw new Error('account row missing'); + if (cost > 0 && account.cash < cost) { + throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); + } + + await inst.db.update(schema.planes).set({ fuel: plane.maxFuel }).where(eq(schema.planes.id, planeId)); + if (cost > 0) { + await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); + } + await persist(inst); + return { cost }; +} + +// --- Route helper (response convention, v0.8.0) --- + +// Run an action under the sim lock — tick FIRST so validation/mutation see current +// game time — then respond with the FULL current state snapshot (same shape as +// GET /api/state), so clients never need a follow-up poll. GameActionError maps to +// its own status + {error}; anything unexpected is a 500. +export async function performAction(fn: (inst: GameDb) => Promise): Promise { + try { + await runAction(fn); + return Response.json(await getStateSnapshot()); + } catch (err) { + if (err instanceof GameActionError) { + return Response.json({ error: err.message }, { status: err.status }); + } + console.error('action failed', err); + return Response.json({ error: 'action failed' }, { status: 500 }); + } +} diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index cd26780..40a02fa 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -10,6 +10,9 @@ export interface PlaneType { burnPerKm: number; // Cruise speed, km/h — used by the sim tick to advance planes along routes. speedKmh: number; + // Market price in € (v0.8.0): buying costs `price`, selling pays out + // SELL_FRACTION × price. + price: number; } export const PLANE_TYPES: Record = { @@ -19,8 +22,17 @@ export const PLANE_TYPES: Record = { maxFuel: 6300, burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L) speedKmh: 510, + // €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, }, }; // The plane the player starts with. export const STARTER_MODEL = 'atr72'; + +// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this. +export const FUEL_PRICE_PER_L = 0.8; + +// Fraction of the catalog price paid out when selling a plane (v0.8.0). +export const SELL_FRACTION = 0.5; diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index e05c581..e55e226 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -163,4 +163,52 @@ describe('generateContract (shared seed/spawn builder)', () => { 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(); }); + + 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' }); + expect(c.originIcao).toBe('EDDF'); + expect(c.destIcao).not.toBe('EDDF'); + + 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))); + }); + + 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' }); + 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' }); + expect(c.originIcao).toBe('EDDF'); + expect(c.destIcao).toBe('EHAM'); + + const o = AIRPORTS[0]; // EDDF + const d = AIRPORTS[1]; // EHAM + expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon))); + }); + + 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' }); + 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/); + }); + + 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 + // 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 }); + expect(a).toEqual(b); + }); + }); }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index 70d7136..a0d726b 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -98,19 +98,40 @@ export function rewardForDistanceKm(km: number): number { // 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 // 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 +// callers that omit both get byte-identical behavior to before. export function generateContract(input: { id: string; airports: SeedAirport[]; rng: Rng; nowMs: number; deadlineOffsetMs?: number; + originIcao?: string; + destIcao?: string; }): SeedContract { const { airports, rng, nowMs } = input; if (airports.length < 2) throw new Error('contract generation needs at least two airports'); - const oi = Math.floor(rng() * airports.length); - let di = Math.floor(rng() * airports.length); - if (di === oi) di = (di + 1) % airports.length; + let oi: number; + if (input.originIcao !== undefined) { + oi = airports.findIndex((a) => a.icao === input.originIcao); + if (oi < 0) throw new Error(`unknown origin airport ${input.originIcao}`); + } else { + oi = Math.floor(rng() * airports.length); + } + let di: number; + if (input.destIcao !== undefined) { + di = airports.findIndex((a) => a.icao === input.destIcao); + if (di < 0) throw new Error(`unknown destination airport ${input.destIcao}`); + // A RANDOM origin may have landed on the forced destination — shift it (no + // extra RNG draw), mirroring the unforced collision handling below. + if (oi === di && input.originIcao === undefined) oi = (oi + 1) % airports.length; + } else { + di = Math.floor(rng() * airports.length); + if (di === oi) di = (di + 1) % airports.length; + } + if (oi === di) throw new Error('origin and destination must differ'); // both forced identical const origin = airports[oi]; const dest = airports[di]; diff --git a/lib/game/state.ts b/lib/game/state.ts index 816a557..60fc9ce 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -16,6 +16,9 @@ export interface PlaneState { locationIcao: string; locationName: string; contractId: 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. + departureSimTime: number | null; lat: number; lon: number; } @@ -92,6 +95,7 @@ export async function getStateSnapshot(): Promise { locationIcao: p.locationIcao, locationName: nameOf(p.locationIcao), contractId: p.contractId, + departureSimTime: p.departureSimTime, lat: live?.lat ?? p.lat, lon: live?.lon ?? p.lon, }; diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 62f0389..bfaec65 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,9 +3,14 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the four public endpoints', () => { + it('documents exactly the nine public endpoints (4 read + 5 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/planes/{planeId}/refuel', '/api/sim', '/api/state', '/api/version', @@ -66,6 +71,7 @@ describe('OpenAPI spec', () => { const plane = openApiSpec.components.schemas.PlaneState.properties; expect(Object.keys(plane).sort()).toEqual([ 'contractId', + 'departureSimTime', 'fuel', 'id', 'lat', @@ -122,4 +128,64 @@ describe('OpenAPI spec', () => { const error = openApiSpec.components.schemas.Error.properties; expect(Object.keys(error)).toEqual(['error']); }); + + it('documents the five 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 { + responses: Record; + requestBody?: { content: Record }; + parameters?: Array<{ name: string }>; + }; + + // Every action returns the full StateSnapshot on success (response convention). + for (const p of [ + '/api/contracts/{id}/accept', + '/api/planes/{planeId}/dispatch', + '/api/market/buy-plane', + '/api/market/sell-plane', + '/api/planes/{planeId}/refuel', + ]) { + const ok = post(p).responses['200'] as { content: Record }; + expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot'); + } + + // 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/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', + ); + expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe( + '#/components/schemas/BuyPlaneRequest', + ); + 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/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/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 buy = openApiSpec.components.schemas.BuyPlaneRequest; + expect(buy.required).toEqual(['model']); + expect(buy.properties.model.type).toBe('string'); + const sell = openApiSpec.components.schemas.SellPlaneRequest; + expect(sell.required).toEqual(['planeId']); + expect(sell.properties.planeId.type).toBe('string'); + }); }); diff --git a/lib/openapi.ts b/lib/openapi.ts index 17cf376..dcea408 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, and app metadata.', + '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.', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, @@ -102,6 +102,158 @@ export const openApiSpec = { }, }, }, + '/api/contracts/{id}/accept': { + post: { + summary: 'Accept a contract', + 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', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + 200: { + description: 'Accepted — full state snapshot (contract now "accepted")', + content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, + }, + 404: { + description: 'Unknown contract id', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Contract is not "open" (accepted / completed / expired)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/planes/{planeId}/dispatch': { + post: { + summary: 'Dispatch a plane on an accepted contract', + 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', + parameters: [ + { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/DispatchRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Dispatched — full state snapshot (plane "flying", departure set)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, + }, + 400: { + description: 'Invalid body (not an object, unknown field, or missing/non-string contractId)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 404: { + description: 'Unknown plane id or contract 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', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/market/buy-plane': { + post: { + summary: 'Buy a plane from the market', + description: + 'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.', + operationId: 'buyPlane', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/BuyPlaneRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Purchased — full state snapshot (new plane, cash reduced)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, + }, + 400: { + description: 'Invalid body or unknown catalog model', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Insufficient funds', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/market/sell-plane': { + post: { + summary: 'Sell a plane on the market', + description: + 'Sell a grounded plane for 50% of its catalog price (v0.8.0). The row is deleted and the payout added to cash. A flying plane cannot be sold, nor can the last remaining plane.', + operationId: 'sellPlane', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SellPlaneRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Sold — full state snapshot (plane gone, cash increased by the payout)', + 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 plane id', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Plane is flying, or it is the last remaining plane', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/planes/{planeId}/refuel': { + post: { + summary: 'Refuel a grounded plane', + description: + 'Fill a grounded plane to maxFuel (v0.8.0). Cost = (maxFuel − fuel) × fuel price per liter, deducted from cash; no body.', + operationId: 'refuelPlane', + parameters: [ + { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + 200: { + description: 'Refueled — full state snapshot (fuel at max, cash reduced by the cost)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, + }, + 404: { + description: 'Unknown plane id', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Plane is flying, or cash is insufficient for the fill-up', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, }, components: { schemas: { @@ -202,6 +354,27 @@ export const openApiSpec = { paused: { type: 'boolean' }, }, }, + DispatchRequest: { + type: 'object', + properties: { + contractId: { type: 'string', description: 'An ACCEPTED contract whose origin the plane is at.' }, + }, + required: ['contractId'], + }, + BuyPlaneRequest: { + type: 'object', + properties: { + model: { type: 'string', description: 'Catalog model id (e.g. "atr72").' }, + }, + required: ['model'], + }, + SellPlaneRequest: { + type: 'object', + properties: { + planeId: { type: 'string', description: 'A grounded ("available") plane that is not the last remaining one.' }, + }, + required: ['planeId'], + }, Error: { type: 'object', properties: { @@ -220,6 +393,10 @@ export const openApiSpec = { locationIcao: { type: 'string' }, locationName: { type: 'string' }, contractId: { type: ['string', 'null'] }, + departureSimTime: { + type: ['integer', 'null'], + description: 'Sim-clock epoch ms of the current flight (set by dispatch); null when grounded.', + }, lat: { type: 'number' }, lon: { type: 'number' }, }, @@ -232,6 +409,7 @@ export const openApiSpec = { 'locationIcao', 'locationName', 'contractId', + 'departureSimTime', 'lat', 'lon', ], diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 462a6ce..10d003c 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -15,6 +15,7 @@ import { finalizeArrivals, MAX_TICK_ELAPSED_MS, reputationGainFor, + spawnCoverageContracts, spawnOpenContracts, tickOnce, } from './engine'; @@ -257,6 +258,89 @@ describe('spawnOpenContracts', () => { }); }); +describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => { + // The seeded starter plane is grounded at EDDF with no seeded contract 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 () => { + 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)); + } + + const n = await spawnCoverageContracts(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'); + 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 () => { + 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)); + } + // 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, + 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 + }); + + it('spawns one contract 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)); + } + // 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 }, + ]); + + const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7)); + expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD + + const origins = (await inst.db.select().from(schema.contracts)) + .filter((c) => c.status === 'open') + .map((c) => c.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)); + } + await inst.db.update(schema.planes) + .set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' }) + .where(eq(schema.planes.id, 'plane-starter')); + + expect(await spawnCoverageContracts(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 () => { + 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', + ); + expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline + }); +}); + // Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the // engine composes arrivalTimeFor; tests just need the duration for fixtures). function travelMs(km: number, speedKmh: number): number { diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index f5aecfe..6a47b22 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -75,6 +75,9 @@ export async function tickOnce(inst: GameDb): Promise { await expireContracts(inst, clockMs); await finalizeArrivals(inst, clockMs); await spawnOpenContracts(inst, clockMs); + // AFTER finalization on purpose: a plane that just landed gets work spawned + // at its new location within the same tick. + await spawnCoverageContracts(inst, clockMs); } // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. @@ -180,12 +183,74 @@ export async function spawnOpenContracts( 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 +// at its new location within the same tick. Returns the number created. +export async function spawnCoverageContracts( + 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')), + ]); + + // Distinct airports with at least one grounded plane (flying planes don't count — + // they can't take new work until they land). + const groundedIcas = [ + ...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)), + ]; + if (groundedIcas.length === 0) return 0; + + const covered = new Set(openRows.map((c) => c.originIcao)); + const missing = groundedIcas.filter((icao) => !covered.has(icao)); + if (missing.length === 0) return 0; + + const fc = await loadAirportsGeoJson(); + const airports: SeedAirport[] = fc.features.map((f) => ({ + icao: f.properties.icao, + name: f.properties.name, + lat: f.properties.lat, + lon: f.properties.lon, + })); + const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); + + 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 }), + ); + n++; + } + return n; +} + // --- Public API (singleton DB + lock) — call from every read/action endpoint --- export function tickNow(): Promise { return withLock(async () => tickOnce(await getGameDb())); } +// Run a game action atomically with respect to ticks (v0.8.0): the clock is +// advanced FIRST so validation and mutation see current game time, then fn runs — +// all under the same promise lock as tickNow(), so no concurrent request can +// interleave between the tick and the mutation. Action functions in +// lib/game/actions.ts take an explicit GameDb so unit tests can inject their own. +export function runAction(fn: (inst: GameDb) => Promise): Promise { + return withLock(async () => { + const inst = await getGameDb(); + await tickOnce(inst); + return fn(inst); + }); +} + // Tick first so the returned state reflects current time, then apply the new // controls. timeScale is clamped to [1, 60]; validation of raw input happens in // the route handler (400 on non-number / non-boolean). diff --git a/package.json b/package.json index e671d9c..10d9c1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.7.0", + "version": "0.8.0", "private": true, "scripts": { "dev": "next dev -p 3000",