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,66 @@
|
||||
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 { 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-buy-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
|
||||
|
||||
describe('POST /api/market/buy-plane', () => {
|
||||
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
|
||||
for (const body of [{}, { model: 7 }, { bogus: 1 }, { model: 'boeing747' }]) {
|
||||
const res = await post(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects the purchase with 409 when cash is below the price (seeded €20k < €200k)', async () => {
|
||||
const res = await post({ model: 'atr72' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
|
||||
|
||||
// Cash untouched by the failed attempt.
|
||||
const account = (await (await getGameDb()).db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBe(20_000);
|
||||
});
|
||||
|
||||
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
|
||||
const { db } = await getGameDb();
|
||||
// Single-row account table — top up cash so the purchase can succeed.
|
||||
await db.update(schema.account).set({ cash: 500_000 });
|
||||
|
||||
const res = await post({ model: 'atr72' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
account: { cash: number };
|
||||
planes: Array<{ id: string; model: string; status: string; fuel: number; locationIcao: string }>;
|
||||
};
|
||||
expect(body.account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||
|
||||
const bought = body.planes.filter((p) => p.model === 'atr72');
|
||||
expect(bought.length).toBe(2); // starter + the new one
|
||||
const fresh = bought.find((p) => p.id !== 'plane-starter')!;
|
||||
expect(fresh.status).toBe('available');
|
||||
expect(fresh.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
expect(fresh.locationIcao).toBe('EDDF'); // home base delivery
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { buyPlane, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/market/buy-plane (v0.8.0) — body { model: string }. Buys a catalog
|
||||
// aircraft at full price; it arrives 'available' with a full tank at the home base
|
||||
// (EDDF). 400 missing/non-string model or unknown catalog model; 409 insufficient
|
||||
// funds. On success returns the full state snapshot (new plane + reduced cash).
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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 !== 'model') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof input.model !== 'string' || input.model.length === 0) {
|
||||
return Response.json({ error: 'model must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
const model = input.model;
|
||||
return performAction((inst) => buyPlane(inst, model));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { PLANE_TYPES, SELL_FRACTION } 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-sell-api-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
|
||||
|
||||
describe('POST /api/market/sell-plane', () => {
|
||||
it('rejects a missing planeId and unknown fields with 400', async () => {
|
||||
for (const body of [{}, { planeId: 7 }, { bogus: 1 }]) {
|
||||
const res = await post(body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const res = await post({ planeId: 'ghost' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects selling the last remaining plane with 409 (seeded DB has only the starter)', async () => {
|
||||
const res = await post({ planeId: 'plane-starter' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/last remaining/);
|
||||
});
|
||||
|
||||
it('rejects selling a flying plane with 409', async () => {
|
||||
const { db } = await getGameDb();
|
||||
// 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,
|
||||
});
|
||||
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||
|
||||
const res = await post({ planeId: 'plane-extra' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
|
||||
});
|
||||
|
||||
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
|
||||
const { db } = await getGameDb();
|
||||
// The previous test left 'plane-extra' in the DB (now flying) — ground it.
|
||||
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||
const cashBefore = (await db.select().from(schema.account))[0].cash;
|
||||
|
||||
const res = await post({ planeId: 'plane-extra' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as { account: { cash: number }; planes: Array<{ id: string }> };
|
||||
const payout = Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100;
|
||||
expect(body.account.cash).toBeCloseTo(cashBefore + payout, 6);
|
||||
expect(body.planes.map((p) => p.id)).not.toContain('plane-extra'); // row deleted
|
||||
expect(body.planes.map((p) => p.id)).toContain('plane-starter');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { performAction, sellPlane } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/market/sell-plane (v0.8.0) — body { planeId: string }. Sells a grounded
|
||||
// plane for 50% of its catalog price and deletes the row. 400 missing/non-string
|
||||
// planeId or unknown fields; 404 unknown id; 409 if the plane is flying or it is
|
||||
// the last remaining plane. On success returns the full state snapshot.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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) => sellPlane(inst, planeId));
|
||||
}
|
||||
Reference in New Issue
Block a user