feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
@@ -2,23 +2,24 @@ 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 { generatePackage, 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.
// 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-contract
// POST /api/test/create-package
// 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.
// 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-contract) — pass an explicit value to override.
// Returns 201 + the created contract row.
// hook deadline of expire-package) — pass an explicit value to override.
// Returns 201 + the created package row.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
@@ -52,7 +53,7 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before "now" for the new contract
const sim = await tickNow(); // clock is current before "now" for the new package
const fc = await loadAirportsGeoJson();
const airports = fc.features.map((f) => ({
@@ -70,7 +71,7 @@ export async function POST(req: Request) {
}
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generateContract({
const created = generatePackage({
id: randomUUID(),
airports,
rng,
@@ -79,7 +80,7 @@ export async function POST(req: Request) {
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
});
await inst.db.insert(schema.contracts).values(created);
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
} catch (err) {
@@ -3,18 +3,19 @@ import { eq } from 'drizzle-orm';
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 { generatePackage, mulberry32 } from '@/lib/game/seed';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.7.0) — lets the e2e suite observe contract expiry without
// waiting hours of game time. Disabled unless FLIGHT_TEST_HOOKS=1, which is set in
// docker-compose.yml for the e2e instance only; every other deployment 404s here.
// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite
// observe package expiry without waiting hours of game time. Disabled unless
// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance
// only; every other deployment 404s here.
//
// POST /api/test/expire-contract
// body { contractId?: string }
// - with contractId: moves that OPEN contract's deadline to now + HOOK_DEADLINE_MS
// POST /api/test/expire-package
// body { packageId?: string }
// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS
// (game time) so the next fast tick expires it; 404 if unknown/not open.
// - without: creates a fresh open contract with the same short deadline.
// - without: creates a fresh open package with the same short deadline.
const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (~0.2 s real at 60×)
@@ -33,9 +34,9 @@ export async function POST(req: Request) {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const contractId = (body as Record<string, unknown>).contractId;
if (contractId !== undefined && typeof contractId !== 'string') {
return Response.json({ error: 'contractId must be a string' }, { status: 400 });
const packageId = (body as Record<string, unknown>).packageId;
if (packageId !== undefined && typeof packageId !== 'string') {
return Response.json({ error: 'packageId must be a string' }, { status: 400 });
}
try {
@@ -43,15 +44,15 @@ export async function POST(req: Request) {
const sim = await tickNow(); // clock is current before we set the deadline
const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS);
if (typeof contractId === 'string') {
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
if (typeof packageId === 'string') {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const row = rows[0];
if (!row || row.status !== 'open') {
return Response.json({ error: 'contract not found or not open' }, { status: 404 });
return Response.json({ error: 'package not found or not open' }, { status: 404 });
}
await inst.db.update(schema.contracts).set({ deadlineSimTime }).where(eq(schema.contracts.id, contractId));
await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId));
await persist(inst);
const updated = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)))[0];
const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0];
return Response.json(updated ?? row);
}
@@ -63,8 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generateContract({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
await inst.db.insert(schema.contracts).values(created);
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
} catch (err) {