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
@@ -1,97 +0,0 @@
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);
});
});
@@ -1,35 +0,0 @@
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));
}
+110
View File
@@ -0,0 +1,110 @@
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-fly-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.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}/fly`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ planeId }),
});
describe('POST /api/planes/:planeId/fly', () => {
it('rejects a missing/non-string destIcao and unknown fields with 400', async () => {
for (const body of [{}, { destIcao: 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 an ICAO not in the airport dataset with 400', async () => {
const res = await post('plane-starter', { destIcao: 'ZZZZ' });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
});
it('rejects an unknown plane id with 404', async () => {
const res = await post('ghost-plane', { destIcao: 'LEMD' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
});
it('rejects a flying plane with 409', async () => {
const { db } = await getGameDb();
await db.update(schema.planes)
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter', { destIcao: 'EDDM' });
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, destIcao: null })
.where(eq(schema.planes.id, 'plane-starter'));
});
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
const { db } = await getGameDb();
// EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L — far more than 1 L.
await db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter', { destIcao: 'LEMD' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Refill for later tests.
await db.update(schema.planes).set({ fuel: 6300 }).where(eq(schema.planes.id, 'plane-starter'));
});
it('flies the plane and returns the full snapshot with departure + destination set', async () => {
const res = await post('plane-starter', { destIcao: 'LEMD' });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
// Response convention (v0.8.0): 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<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
};
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
]);
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD'); // destination set at fly time (v0.12.0)
expect(typeof plane.departureSimTime).toBe('number'); // departure set at fly time
expect(plane.locationIcao).toBe('EDDF'); // origin unchanged until arrival
// A second flight of the same (now flying) plane is rejected — it is no longer available.
const again = await post('plane-starter', { destIcao: 'EDDM' });
expect(again.status).toBe(409);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { flyPlane, performAction } from '@/lib/game/actions';
// POST /api/planes/:planeId/fly (v0.12.0) — body { destIcao: string }. Flies a
// GROUNDED plane to any known airport (its loaded packages' destinations or pure
// repositioning); the engine delivers bound cargo on arrival. 400 missing/
// non-string destIcao, unknown fields, or an ICAO not in the airport dataset;
// 404 unknown plane id; 409 plane flying or insufficient fuel for the leg. On
// success returns the full state snapshot (plane "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 !== 'destIcao') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.destIcao !== 'string' || input.destIcao.length === 0) {
return Response.json({ error: 'destIcao must be a non-empty string' }, { status: 400 });
}
const destIcao = input.destIcao;
return performAction((inst) => flyPlane(inst, planeId, destIcao));
}
@@ -52,7 +52,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
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' })
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter');
@@ -61,7 +61,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
// Ground it again for later tests.
await db.update(schema.planes)
.set({ status: 'available', departureSimTime: null, contractId: null })
.set({ status: 'available', departureSimTime: null, destIcao: null })
.where(eq(schema.planes.id, 'plane-starter'));
});