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
+121
View File
@@ -0,0 +1,121 @@
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-load-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (id: string, body: unknown) =>
POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ id }),
});
describe('POST /api/packages/:id/load', () => {
it('rejects a missing/non-string planeId and unknown fields with 400', async () => {
for (const body of [{}, { planeId: 42 }, { bogus: 1 }]) {
const res = await post('p-x', body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('rejects an unknown package id with 404', async () => {
const res = await post('nope', { planeId: 'plane-starter' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
});
it('rejects an unknown plane id with 404', async () => {
const { db } = await getGameDb();
await db.insert(schema.packages).values({
id: 'p-ghost-plane', 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('p-ghost-plane', { planeId: 'ghost' });
expect(res.status).toBe(404);
});
it('rejects loading a package whose origin the plane is not at with 409', async () => {
const { db } = await getGameDb();
// The seeded starter sits at EDDF — a package from LEMD cannot be loaded there.
await db.insert(schema.packages).values({
id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
});
const res = await post('p-elsewhere', { planeId: 'plane-starter' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
});
it('rejects loading a non-open package with 409', async () => {
const { db } = await getGameDb();
await db.insert(schema.packages).values({
id: 'p-busy', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
});
const res = await post('p-busy', { planeId: 'plane-starter' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/loaded/);
});
it('loads an open package at the plane\'s airport and returns the full snapshot', async () => {
const { db } = await getGameDb();
// A known open package originating where the starter actually sits (EDDF).
await db.insert(schema.packages).values({
id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
});
const res = await post('p-home', { planeId: 'plane-starter' });
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<Record<string, unknown>>;
packages: Array<{ id: string; status: string; planeId: string | null }>;
};
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
]);
const loaded = body.packages.find((p) => p.id === 'p-home')!;
expect(loaded.status).toBe('loaded');
expect(loaded.planeId).toBe('plane-starter');
// Persisted, not just in the response.
const row = (await db.select().from(schema.packages).where(eq(schema.packages.id, 'p-home')))[0];
expect(row.status).toBe('loaded');
expect(row.planeId).toBe('plane-starter');
// A second load of the same package is rejected — it is no longer open.
const again = await post('p-home', { planeId: 'plane-starter' });
expect(again.status).toBe(409);
});
});