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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user