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:
+99
-45
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user