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:
2026-09-17 13:54:13 +00:00
parent 96835e935d
commit aa70eb5286
25 changed files with 1843 additions and 8 deletions
+89
View File
@@ -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 });
}
}