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
+194 -69
View File
@@ -9,10 +9,11 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed';
import {
acceptContract,
abandonPackage,
buyPlane,
dispatchPlane,
flyPlane,
GameActionError,
loadPackage,
refuelPlane,
sellPlane,
} from './actions';
@@ -54,54 +55,107 @@ function expectActionError(promise: Promise<unknown>, status: number, messagePar
);
}
// Insert an accepted contract EDDF → LEMD bound to nothing, for dispatch tests.
async function insertAcceptedContract(inst: GameDb, id = 'c-acc'): Promise<void> {
await inst.db.insert(schema.contracts).values({
// Insert a known OPEN package EDDF → LEMD for load tests.
async function insertOpenPackage(inst: GameDb, id = 'p-open', over: Partial<schema.PackageRow> = {}): Promise<void> {
await inst.db.insert(schema.packages).values({
id,
originIcao: HUB_ICAO,
destIcao: 'LEMD',
reward: 5_000,
cargoType: 'Passengers',
cargoCount: 60,
reward: 1_000,
cargoType: 'Mail',
cargoCount: 4,
deadlineSimTime: T0 + 24 * 3_600_000,
status: 'accepted',
status: 'open',
planeId: null,
createdSimTime: T0,
...over,
});
}
describe('acceptContract', () => {
it('flips an open contract to accepted and persists it', async () => {
const inst = await freshDb('acc-open.db');
await acceptContract(inst, 'contract-1'); // seeded ids are contract-N
describe('loadPackage', () => {
it('binds an open package to a grounded plane at its origin', async () => {
const inst = await freshDb('load-ok.db');
await insertOpenPackage(inst);
const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0];
expect(row.status).toBe('accepted');
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
expect(row.status).toBe('loaded');
expect(row.planeId).toBe('plane-starter');
});
it.each(['accepted', 'completed', 'expired'] as const)('rejects a %s contract with 409', async (status) => {
const inst = await freshDb(`acc-${status}.db`);
await inst.db.update(schema.contracts).set({ status }).where(eq(schema.contracts.id, 'contract-1'));
it.each(['loaded', 'delivered', 'expired', 'abandoned'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`load-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(acceptContract(inst, 'contract-1'), 409, new RegExp(status));
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
},
);
it('rejects an unknown package id with 404', async () => {
const inst = await freshDb('load-punknown.db');
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('acc-unknown.db');
await expectActionError(acceptContract(inst, 'nope'), 404);
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('load-ghost.db');
await insertOpenPackage(inst);
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('load-flying.db');
await insertOpenPackage(inst);
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
});
it('rejects a plane that is not at the origin with 409', async () => {
const inst = await freshDb('load-notatorigin.db');
await insertOpenPackage(inst); // originates at EDDF
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
});
it('rejects loading when the plane has no free slot (capacity from catalog)', async () => {
const inst = await freshDb('load-full.db');
await insertOpenPackage(inst, 'p-new');
// Fill all atr72 slots with loaded cargo…
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
}
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
});
it('counts expired packages as occupying their slot too', async () => {
const inst = await freshDb('load-expiredslot.db');
await insertOpenPackage(inst, 'p-new');
// capacity − 1 loaded + 1 expired = full.
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
}
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
});
});
describe('dispatchPlane', () => {
it('binds the plane: flying + departure at current clock + origin coords, contract bound to plane', async () => {
const inst = await freshDb('disp-ok.db');
await insertAcceptedContract(inst);
describe('flyPlane', () => {
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
const inst = await freshDb('fly-ok.db');
await dispatchPlane(inst, 'plane-starter', 'c-acc');
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.contractId).toBe('c-acc');
expect(plane.destIcao).toBe('LEMD');
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
const fc = await loadAirportsGeoJson();
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
@@ -109,58 +163,129 @@ describe('dispatchPlane', () => {
expect(plane.lon).toBe(origin.lon);
// Fuel is frozen at the departure value while flying (live fuel is analytic).
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
const contract = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'c-acc')))[0];
expect(contract.planeId).toBe('plane-starter');
});
it.each(['open', 'completed', 'expired'] as const)('rejects a %s contract with 409 (must be accepted)', async (status) => {
const inst = await freshDb(`disp-c${status}.db`);
await inst.db.insert(schema.contracts).values({
id: 'c-x', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status, planeId: null, createdSimTime: T0,
});
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-x'), 409, new RegExp(status));
});
it('rejects an unknown contract id with 404', async () => {
const inst = await freshDb('disp-cunknown.db');
await expectActionError(dispatchPlane(inst, 'plane-starter', 'nope'), 404);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('disp-punknown.db');
await insertAcceptedContract(inst);
await expectActionError(dispatchPlane(inst, 'ghost', 'c-acc'), 404);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('disp-flying.db');
await insertAcceptedContract(inst);
const inst = await freshDb('fly-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-other' })
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409);
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
});
it('rejects a plane that is not at the origin with 409', async () => {
const inst = await freshDb('disp-notatorigin.db');
await insertAcceptedContract(inst); // originates at EDDF
await inst.db.update(schema.planes)
.set({ locationIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('fly-ghost.db');
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
});
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /not at|at LEMD/);
it('rejects an ICAO not in the airport dataset with 400', async () => {
const inst = await freshDb('fly-unknownicao.db');
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
const inst = await freshDb('disp-nofuel.db');
await insertAcceptedContract(inst); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /insufficient fuel/i);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
});
});
describe('abandonPackage', () => {
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
const REWARD = 1_000;
const WINDOW_MS = 10 * 3_600_000;
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
const inst = await freshDb(name);
// Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
await loadPackage(inst, 'p-ab', 'plane-starter');
// …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') {
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
}
return inst;
}
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
const inst = await setupLoaded('ab-start.db');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned');
expect(row.planeId).toBeNull(); // slot freed
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
});
it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
});
it('caps at exactly 60% at and past the deadline', async () => {
for (const [name, clock] of [
['ab-deadline.db', T0 + WINDOW_MS],
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
] as const) {
const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock });
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
}
});
it('is free for an already-expired package', async () => {
const inst = await setupLoaded('ab-expired.db', 'expired');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(0);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned');
});
it('floors cash at €0 — no debt', async () => {
const inst = await setupLoaded('ab-floor.db');
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(150);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
});
it('rejects abandoning while the plane is in flight with 409', async () => {
const inst = await setupLoaded('ab-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
});
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
const inst = await freshDb(`ab-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ab-unknown.db');
await expectActionError(abandonPackage(inst, 'nope'), 404);
});
});
@@ -206,7 +331,7 @@ describe('sellPlane', () => {
// A second plane so the starter is not "the last remaining one".
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: null,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
@@ -224,7 +349,7 @@ describe('sellPlane', () => {
const inst = await freshDb('sell-flying.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: T0,
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
});
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
@@ -269,7 +394,7 @@ describe('refuelPlane', () => {
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('refuel-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-x' })
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);