110 lines
5.2 KiB
TypeScript
110 lines
5.2 KiB
TypeScript
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 { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||
import { tickNow } from '@/lib/sim/engine';
|
||
|
||
// TEST-ONLY hook (v0.8.0, renamed create-contract → create-package in v0.12.0) —
|
||
// lets the e2e suite create an open package with a specified origin and/or
|
||
// destination (and optional deadline offset), so the full load → fly loop can be
|
||
// exercised on a known, short route without waiting for random spawns. Disabled
|
||
// unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance
|
||
// only); every other deployment 404s here. Sibling of /api/test/expire-package —
|
||
// same gate, same conventions.
|
||
//
|
||
// POST /api/test/create-package
|
||
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
|
||
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
|
||
// omitted endpoints are picked at random by generatePackage.
|
||
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
|
||
// hook deadline of expire-package) — pass an explicit value to override.
|
||
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
|
||
// flows that load several packages can stay under the plane's payload deterministically.
|
||
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
|
||
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
|
||
// e2e specs that assert exact reward/rep values stay deterministic.
|
||
// Returns 201 + the created package 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' && key !== 'weightKg' && key !== 'special') {
|
||
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 });
|
||
}
|
||
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
|
||
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
|
||
}
|
||
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
|
||
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
const inst = await getGameDb();
|
||
const sim = await tickNow(); // clock is current before "now" for the new package
|
||
|
||
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));
|
||
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
|
||
// as engine spawns; never force a fixed value here.
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
if (!account) throw new Error('account row missing');
|
||
const created = generatePackage({
|
||
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,
|
||
weightKg: input.weightKg as number | undefined,
|
||
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||
special: input.special === undefined ? null : (input.special as string | null),
|
||
reputation: account.reputation,
|
||
});
|
||
await inst.db.insert(schema.packages).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 });
|
||
}
|
||
}
|