feat: v0.8.0 — player-actions API: accept/dispatch/buy/sell/refuel endpoints (tick-first, full-snapshot responses), spawn coverage rule, test create-contract hook
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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,77 @@
|
||||
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 { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
||||
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-refuel-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (planeId: string) => POST(new Request(`http://localhost/api/planes/${planeId}/refuel`), { params: Promise.resolve({ planeId }) });
|
||||
|
||||
describe('POST /api/planes/:planeId/refuel', () => {
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const res = await post('ghost');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post('plane-starter');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
account: { cash: number };
|
||||
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
|
||||
};
|
||||
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
|
||||
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
|
||||
});
|
||||
|
||||
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' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const res = await post('plane-starter');
|
||||
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, contractId: null })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
});
|
||||
|
||||
it('rejects the fill-up with 409 when cash is below the cost', async () => {
|
||||
const { db } = await getGameDb();
|
||||
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
||||
|
||||
const res = await post('plane-starter');
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { performAction, refuelPlane } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/planes/:planeId/refuel (v0.8.0) — no body. Fills a grounded plane to
|
||||
// maxFuel at the fuel price per missing liter; deducts the cost from cash. 404
|
||||
// unknown id; 409 if the plane is flying or cash is insufficient. On success
|
||||
// returns the full state snapshot (fuel back to max, cash reduced by the cost).
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||
const { planeId } = await ctx.params;
|
||||
return performAction((inst) => refuelPlane(inst, planeId));
|
||||
}
|
||||
Reference in New Issue
Block a user