feat: v0.8.0 — player-actions API: accept/dispatch/buy/sell/refuel endpoints (tick-first, full-snapshot responses), spawn coverage rule, test create-contract hook
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'planeId') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof input.planeId !== 'string' || input.planeId.length === 0) {
|
||||
return Response.json({ error: 'planeId must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const planeId = input.planeId;
|
||||
return performAction((inst) => sellPlane(inst, planeId));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'contractId') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof input.contractId !== 'string' || input.contractId.length === 0) {
|
||||
return Response.json({ error: 'contractId must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const contractId = input.contractId;
|
||||
return performAction((inst) => dispatchPlane(inst, planeId, contractId));
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// 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<ContractState> {
|
||||
const created = await page.request.post('/api/test/create-contract', {
|
||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as ContractState;
|
||||
}
|
||||
|
||||
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
||||
// 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<ContractState> {
|
||||
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);
|
||||
});
|
||||
+13
-2
@@ -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<string, unknown> };
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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<GameDb> {
|
||||
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
|
||||
return openGameDb(path.join(dir, name));
|
||||
}
|
||||
|
||||
function expectActionError(promise: Promise<unknown>, 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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<unknown>): Promise<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, PlaneType> = {
|
||||
@@ -19,8 +22,17 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+23
-2
@@ -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);
|
||||
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];
|
||||
|
||||
|
||||
@@ -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<StateSnapshot> {
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
departureSimTime: p.departureSimTime,
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
};
|
||||
|
||||
+67
-1
@@ -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<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||
responses: Record<string, unknown>;
|
||||
requestBody?: { content: Record<string, { schema: { $ref: string } }> };
|
||||
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<string, { schema: { $ref: string } }> };
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
+179
-1
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -75,6 +75,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
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<number> {
|
||||
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<schema.SimStateRow> {
|
||||
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<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||||
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).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user