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:
@@ -1,74 +0,0 @@
|
||||
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-accept-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'accept.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (id: string) => POST(new Request(`http://localhost/api/contracts/${id}/accept`), { params: Promise.resolve({ id }) });
|
||||
|
||||
describe('POST /api/contracts/:id/accept', () => {
|
||||
it('returns the full state snapshot with the contract accepted', async () => {
|
||||
const { db } = await getGameDb();
|
||||
const open = (await db.select().from(schema.contracts)).find((c) => c.status === 'open')!;
|
||||
|
||||
const res = await post(open.id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
// Response convention: 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>>;
|
||||
contracts: Array<{ id: string; status: string }>;
|
||||
};
|
||||
// v0.10.0: the snapshot also carries the static market reference data.
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
'account',
|
||||
'catalog',
|
||||
'contracts',
|
||||
'fuelPricePerL',
|
||||
'planes',
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
expect(body.contracts.find((c) => c.id === open.id)?.status).toBe('accepted');
|
||||
|
||||
// Persisted, not just in the response.
|
||||
const row = (await db.select().from(schema.contracts).where(eq(schema.contracts.id, open.id)))[0];
|
||||
expect(row.status).toBe('accepted');
|
||||
});
|
||||
|
||||
it('rejects a non-open contract with 409', async () => {
|
||||
const { db } = await getGameDb();
|
||||
// Deterministic regardless of which contract the first test accepted.
|
||||
await db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, 'contract-2'));
|
||||
|
||||
const res = await post('contract-2');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/completed/);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const res = await post('nope');
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { acceptContract, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/contracts/:id/accept (v0.8.0) — claim an open contract: status flips
|
||||
// open → accepted. 404 unknown id; 409 if the contract is not 'open' (accepted /
|
||||
// completed / expired). No body. On success returns the full state snapshot.
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
return performAction((inst) => acceptContract(inst, id));
|
||||
}
|
||||
@@ -49,7 +49,7 @@ describe('POST /api/market/sell-plane', () => {
|
||||
// A second, grounded plane so "flying" is the reason for rejection.
|
||||
await db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||
});
|
||||
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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-abandon-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
|
||||
// Insert a LOADED package bound to the starter plane with a long deadline. Returns
|
||||
// the exact created/deadline times so tests can compute the penalty from the
|
||||
// snapshot's clock (robust against however much real time the test itself takes).
|
||||
async function insertLoadedPackage(id: string, reward = 1_000) {
|
||||
const { db } = await getGameDb();
|
||||
const sim = (await db.select().from(schema.simState))[0];
|
||||
const createdSimTime = sim.clockMs;
|
||||
const deadlineSimTime = createdSimTime + 24 * 3_600_000;
|
||||
await db.insert(schema.packages).values({
|
||||
id, originIcao: 'EDDF', destIcao: 'LEMD', reward, cargoType: 'Mail', cargoCount: 4,
|
||||
deadlineSimTime, status: 'loaded', planeId: 'plane-starter', createdSimTime,
|
||||
});
|
||||
return { createdSimTime, deadlineSimTime };
|
||||
}
|
||||
|
||||
// Mirrors lib/game/penalty.ts (same formula → identical floats).
|
||||
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||
const frac = Math.min(1, Math.max(0, (clockMs - createdSimTime) / (deadlineSimTime - createdSimTime)));
|
||||
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||
}
|
||||
|
||||
describe('POST /api/packages/:id/abandon', () => {
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const res = await post('nope');
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it('rejects an open package with 409 (nothing loaded to abandon)', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.insert(schema.packages).values({
|
||||
id: 'p-open', 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-open');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/open/);
|
||||
});
|
||||
|
||||
it('rejects abandoning while the plane is in flight with 409', async () => {
|
||||
await insertLoadedPackage('p-flying');
|
||||
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('p-flying');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/in flight/i);
|
||||
|
||||
// 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('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => {
|
||||
const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor
|
||||
const { db } = await getGameDb();
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post('p-ab');
|
||||
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 };
|
||||
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||
};
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
'account',
|
||||
'catalog',
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
|
||||
// Cash reduced by exactly the penalty at the snapshot's clock (15% floor + a
|
||||
// sliver of elapsed time — computed with the same formula as the server).
|
||||
const expectedPenalty = penaltyFor(1_000, times.createdSimTime, times.deadlineSimTime, body.simState.clockMs);
|
||||
expect(expectedPenalty).toBeGreaterThanOrEqual(150); // at/above the floor…
|
||||
expect(body.account.cash).toBeCloseTo(cashBefore - expectedPenalty, 6); // …and exact
|
||||
|
||||
const abandoned = body.packages.find((p) => p.id === 'p-ab')!;
|
||||
expect(abandoned.status).toBe('abandoned');
|
||||
expect(abandoned.planeId).toBeNull(); // slot freed in the response too
|
||||
|
||||
// Persisted, not just in the response.
|
||||
const row = (await db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||
expect(row.status).toBe('abandoned');
|
||||
expect(row.planeId).toBeNull(); // slot freed
|
||||
|
||||
// Abandoning it again is rejected — the package is no longer loaded/expired.
|
||||
const again = await post('p-ab');
|
||||
expect(again.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { abandonPackage, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/packages/:id/abandon (v0.12.0) — no body. Drops a LOADED (or already
|
||||
// EXPIRED) package while its plane is GROUNDED: cash -= penalty (15% of the payout
|
||||
// at creation scaling to 60% at the deadline; expired packages cost nothing, cash
|
||||
// floors at €0), the package becomes "abandoned" and frees its slot. 404 unknown
|
||||
// id; 409 wrong status or the plane is in flight. On success returns the full
|
||||
// state snapshot.
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
return performAction((inst) => abandonPackage(inst, id));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { loadPackage, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
|
||||
// package onto a GROUNDED plane at the package's origin with a free slot. 400
|
||||
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409
|
||||
// wrong package status, plane flying, plane not at the origin, no free slots. On
|
||||
// success returns the full state snapshot (package now "loaded" on that plane).
|
||||
|
||||
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = 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 !== 'planeId') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof input.planeId !== 'string' || input.planeId.length === 0) {
|
||||
return Response.json({ error: 'planeId must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const planeId = input.planeId;
|
||||
return performAction((inst) => loadPackage(inst, id, planeId));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ describe('GET /api/state', () => {
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
'account',
|
||||
'catalog',
|
||||
'contracts',
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'sellFraction',
|
||||
'simState',
|
||||
@@ -48,15 +48,18 @@ describe('GET /api/state', () => {
|
||||
|
||||
const planes = body.planes as Array<Record<string, unknown>>;
|
||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'lat', 'lon']) {
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
expect(typeof planes[0].locationName).toBe('string');
|
||||
// The seeded starter is grounded — no flight destination yet.
|
||||
expect(planes[0].destIcao).toBeNull();
|
||||
expect(planes[0].destName).toBeNull();
|
||||
|
||||
const contracts = body.contracts as Array<Record<string, unknown>>;
|
||||
expect(contracts.length).toBeGreaterThan(0);
|
||||
const packages = body.packages as Array<Record<string, unknown>>;
|
||||
expect(packages.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
||||
expect(contracts[0], `contract.${key}`).toHaveProperty(key);
|
||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,6 +86,7 @@ describe('GET /api/state', () => {
|
||||
expect(entry.maxFuel).toBe(type.maxFuel);
|
||||
expect(entry.burnPerKm).toBe(type.burnPerKm);
|
||||
expect(entry.speedKmh).toBe(type.speedKmh);
|
||||
expect(entry.capacity).toBe(type.capacity); // v0.12.0 slot capacity (ATR 72-600: 6)
|
||||
}
|
||||
|
||||
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
||||
|
||||
@@ -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) {
|
||||
Reference in New Issue
Block a user