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; 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 }); } }