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
+99 -45
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
@@ -7,17 +7,20 @@ import { haversineKm } from '@/lib/geo';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed';
// Player actions (v0.8.0) — the core loop, server-side: accept a contract,
// dispatch a plane on it, buy/sell planes, refuel. Each function validates and
// mutates ONE throwaway-safe GameDb instance (persisted at the end); routes wrap
// them in engine.runAction() so tick + validate + mutate are atomic under the sim
// lock. Validation failures throw GameActionError carrying the HTTP status:
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
// load a package onto a grounded plane at its origin, fly to any airport, deliver
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
// (persisted at the end); routes wrap them in engine.runAction() so tick +
// validate + mutate are atomic under the sim lock. Validation failures throw
// GameActionError carrying the HTTP status:
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong contract/plane
// status, plane not at origin, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown catalog model)
// 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error {
constructor(
@@ -36,54 +39,72 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
}
// --- Accept (POST /api/contracts/:id/accept) ---
// --- Load (POST /api/packages/:id/load {planeId}) ---
// open → accepted. Single-player, so no ownership tracking beyond the status flip;
// an accepted contract is claimed by whoever dispatched it first (dispatch checks
// 'accepted' and binds the plane).
export async function acceptContract(inst: GameDb, contractId: string): Promise<void> {
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
const contract = rows[0];
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
if (contract.status !== 'open') {
throw new GameActionError(409, `contract is ${contract.status}, only open contracts can be accepted`);
}
await inst.db.update(schema.contracts).set({ status: 'accepted' }).where(eq(schema.contracts.id, contractId));
await persist(inst);
}
// --- Dispatch (POST /api/planes/:planeId/dispatch {contractId}) ---
// Bind an accepted contract to a grounded plane at its origin. The v0.7.0
// finalize path then handles the flight: position/fuel are pure functions of the
// clock from departureSimTime, and arrival awards cash + reputation automatically.
export async function dispatchPlane(inst: GameDb, planeId: string, contractId: string): Promise<void> {
const [planeRows, contractRows] = await Promise.all([
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
// cargo keeps occupying its space until delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)),
]);
const contract = contractRows[0];
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
if (contract.status !== 'accepted') {
throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`);
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be loaded`);
}
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only available planes can be dispatched`);
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
}
if (plane.locationIcao !== contract.originIcao) {
if (plane.locationIcao !== pkg.originIcao) {
throw new GameActionError(
409,
`plane is at ${plane.locationIcao} but the contract originates at ${contract.originIcao}`,
`plane is at ${plane.locationIcao} but the package originates at ${pkg.originIcao}`,
);
}
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const origin = await airportByIcao(contract.originIcao);
const dest = await airportByIcao(contract.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const occupied = (
await inst.db.select().from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
)
).length;
if (occupied >= type.capacity) {
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
}
await inst.db.update(schema.packages)
.set({ status: 'loaded', planeId })
.where(eq(schema.packages.id, packageId));
await persist(inst);
}
// --- Fly (POST /api/planes/:id/fly {destIcao}) ---
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
// loaded packages' destinations or pure repositioning. On arrival the engine
// delivers whatever is bound for that airport. Validation: plane 'available',
// destination in the airport dataset, enough fuel for the leg.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
}
const fc = await loadAirportsGeoJson();
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
if (!destFeature) throw new GameActionError(400, `unknown airport ${destIcao}`);
const origin = await airportByIcao(plane.locationIcao);
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
const burnL = type.burnPerKm * distanceKm;
if (burnL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
@@ -96,16 +117,49 @@ export async function dispatchPlane(inst: GameDb, planeId: string, contractId: s
await inst.db.update(schema.planes)
.set({
status: 'flying',
contractId,
destIcao,
departureSimTime: sim.clockMs,
lat: origin.lat,
lon: origin.lon,
})
.where(eq(schema.planes.id, planeId));
await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId));
await persist(inst);
}
// --- Abandon (POST /api/packages/:id/abandon) ---
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
// scaling with time pressure (15% at creation → 60% at the deadline, see
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// goes below €0. The slot is freed immediately.
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'loaded' && pkg.status !== 'expired') {
throw new GameActionError(409, `package is ${pkg.status}, only loaded or expired packages can be abandoned`);
}
const plane = pkg.planeId
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
: undefined;
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
if (plane.status !== 'available') {
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
}
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst);
return { penalty };
}
// --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at
@@ -132,7 +186,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
fuel: type.maxFuel, // full tank on delivery
maxFuel: type.maxFuel,
locationIcao: home.icao,
contractId: null,
destIcao: null,
lat: home.lat,
lon: home.lon,
departureSimTime: null,