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/);
+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,
+5
View File
@@ -13,6 +13,8 @@ export interface PlaneType {
// Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price.
price: number;
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane.
capacity: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
@@ -25,6 +27,7 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
// a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000,
capacity: 6,
},
};
@@ -47,6 +50,7 @@ export interface CatalogEntry {
maxFuel: number;
burnPerKm: number;
speedKmh: number;
capacity: number;
}
// The catalog as a flat array, one entry per plane type — the single source of
@@ -59,5 +63,6 @@ export function catalogEntries(): CatalogEntry[] {
maxFuel: t.maxFuel,
burnPerKm: t.burnPerKm,
speedKmh: t.speedKmh,
capacity: t.capacity,
}));
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { abandonPenalty, abandonPenaltyPct, ABANDON_PENALTY_MIN, ABANDON_PENALTY_SPAN, elapsedFractionFor } from './penalty';
// The v0.12.0 abandon penalty: 15% of the payout right after creation, scaling
// linearly to 60% at the deadline; expired packages cost nothing. Pure math —
// the server action and the client preview both call these same functions.
const T0 = 1_750_000_000_000;
const WINDOW_MS = 10 * 3_600_000; // created → deadline span
const REWARD = 1_000;
const pkg = (status: string, over: Partial<{ reward: number; createdSimTime: number; deadlineSimTime: number }> = {}) => ({
status,
reward: REWARD,
createdSimTime: T0,
deadlineSimTime: T0 + WINDOW_MS,
...over,
});
describe('abandonPenaltyPct', () => {
it('is exactly 15% at the start of the window (fraction 0)', () => {
expect(abandonPenaltyPct(0)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
});
it('is exactly 60% at the deadline (fraction 1)', () => {
expect(abandonPenaltyPct(1)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
});
it('clamps out-of-range fractions to [0, 1]', () => {
expect(abandonPenaltyPct(-5)).toBeCloseTo(ABANDON_PENALTY_MIN, 9);
expect(abandonPenaltyPct(2)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9);
});
it('is monotonic non-decreasing across the window', () => {
let prev = -1;
for (let i = 0; i <= 100; i++) {
const pct = abandonPenaltyPct(i / 100);
expect(pct).toBeGreaterThanOrEqual(prev);
prev = pct;
}
});
});
describe('elapsedFractionFor', () => {
it('is 0 at creation, 0.5 at the midpoint, 1 at and past the deadline', () => {
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0)).toBe(0);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS / 2)).toBeCloseTo(0.5, 9);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS)).toBe(1);
expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS * 3)).toBe(1); // clamped
});
it('treats a zero/negative span as fully elapsed (max penalty)', () => {
expect(elapsedFractionFor(T0, T0, T0)).toBe(1);
expect(elapsedFractionFor(T0 + 5, T0, T0 + 5)).toBe(1);
});
});
describe('abandonPenalty', () => {
it('charges exactly 15% of the payout right after creation', () => {
expect(abandonPenalty(pkg('loaded'), T0)).toBe(Math.round(REWARD * 0.15 * 100) / 100);
});
it('scales to exactly 37.5% at the midpoint and 60% at/past the deadline', () => {
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS / 2)).toBe(Math.round(REWARD * 0.375 * 100) / 100);
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS)).toBe(Math.round(REWARD * 0.6 * 100) / 100);
expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS * 2)).toBe(Math.round(REWARD * 0.6 * 100) / 100); // clamped
});
it('is free for an already-expired package regardless of the clock', () => {
expect(abandonPenalty(pkg('expired'), T0)).toBe(0);
expect(abandonPenalty(pkg('expired'), T0 + WINDOW_MS * 5)).toBe(0);
});
it('rounds to cents and scales with the reward, not a fixed amount', () => {
// €999.99 × 15% = 149.9985 → 150.00; €333 × 60% = 199.8 exactly.
expect(abandonPenalty(pkg('loaded', { reward: 999.99 }), T0)).toBe(150);
expect(abandonPenalty(pkg('loaded', { reward: 333 }), T0 + WINDOW_MS)).toBe(199.8);
});
});
+32
View File
@@ -0,0 +1,32 @@
// Abandon penalty (v0.12.0) — dropping a loaded package costs a fraction of its
// payout that grows with time pressure: 15% right after creation, up to 60% at
// the deadline. Pure function shared by the server action (lib/game/actions.ts)
// and the client-side preview in components/RightPane.tsx so both always agree.
export const ABANDON_PENALTY_MIN = 0.15; // fraction of the payout, at creation time
export const ABANDON_PENALTY_SPAN = 0.45; // added across the package's lifetime → max 60% at the deadline
// The penalty fraction for a given elapsedFraction ∈ [0, 1] (clamped).
export function abandonPenaltyPct(elapsedFraction: number): number {
const f = Math.min(1, Math.max(0, elapsedFraction));
return ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN * f;
}
// How much of the package's lifetime has elapsed at clockMs (clamped to [0, 1]).
export function elapsedFractionFor(createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
const span = deadlineSimTime - createdSimTime;
if (span <= 0) return 1; // degenerate — treat as fully elapsed (max penalty)
return Math.min(1, Math.max(0, (clockMs - createdSimTime) / span));
}
// The exact € penalty for abandoning `pkg` at game time clockMs. An already-
// EXPIRED package abandons for free; cash is floored at 0 by the caller
// (account.cash = max(0, cash − penalty)). Rounded to cents like all money math.
export function abandonPenalty(
pkg: { status: string; reward: number; createdSimTime: number; deadlineSimTime: number },
clockMs: number,
): number {
if (pkg.status === 'expired') return 0;
const pct = abandonPenaltyPct(elapsedFractionFor(pkg.createdSimTime, pkg.deadlineSimTime, clockMs));
return Math.round(pkg.reward * pct * 100) / 100;
}
+37 -37
View File
@@ -4,9 +4,9 @@ import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import {
BASE_REWARD,
buildSeed,
CONTRACT_COUNT_MAX,
CONTRACT_COUNT_MIN,
generateContract,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
HUB_ICAO,
mulberry32,
REWARD_PER_KM,
@@ -41,7 +41,7 @@ describe('buildSeed', () => {
fuel: type.maxFuel,
maxFuel: type.maxFuel,
locationIcao: HUB_ICAO,
contractId: null,
destIcao: null,
lat: 50.0333,
lon: 8.5706,
});
@@ -52,13 +52,13 @@ describe('buildSeed', () => {
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open contracts between distinct fixture airports', () => {
it('seeds 8–12 open packages between distinct fixture airports', () => {
const seed = seedWith(42);
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
for (const c of seed.contracts) {
for (const c of seed.packages) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
@@ -66,12 +66,12 @@ describe('buildSeed', () => {
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
}
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
@@ -80,7 +80,7 @@ describe('buildSeed', () => {
it('sets deadlines inside the 6–72h game-time window after now', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
}
@@ -97,8 +97,8 @@ describe('buildSeed', () => {
expect(seedWith(42)).toEqual(seedWith(42));
});
it('produces different contracts for a different RNG seed', () => {
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
it('produces different packages for a different RNG seed', () => {
expect(seedWith(1).packages).not.toEqual(seedWith(2).packages);
});
it('rejects an airport list too small to form a route', () => {
@@ -111,9 +111,9 @@ describe('buildSeed', () => {
});
});
describe('generateContract (shared seed/spawn builder)', () => {
it('builds an open contract between distinct fixture airports with the reward formula', () => {
const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
describe('generatePackage (shared seed/spawn builder)', () => {
it('builds an open package between distinct fixture airports with the reward formula', () => {
const c = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.id).toBe('c-x');
expect(c.status).toBe('open');
@@ -134,39 +134,39 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
expect(a).toEqual(b);
const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
expect(c.id).toBe('c-2');
expect(c.createdSimTime).toBe(NOW + 1000);
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different package
});
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
const c = generatePackage({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = [];
for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
});
it('rejects an airport list too small to form a route', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
it('originIcao forces the origin; dest stays random but distinct', () => {
const c = generateContract({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
const c = generatePackage({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).not.toBe('EDDF');
@@ -176,13 +176,13 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('destIcao forces the destination; origin stays random but distinct', () => {
const c = generateContract({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
const c = generatePackage({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
expect(c.destIcao).toBe('LEBC');
expect(c.originIcao).not.toBe('LEBC');
});
it('both forced → the exact requested route with the distance-based reward', () => {
const c = generateContract({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
const c = generatePackage({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).toBe('EHAM');
@@ -192,22 +192,22 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
expect(a).toEqual(b);
});
it('throws on an unknown forced ICAO or identical endpoints', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
});
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
// Same seed as a historical unforced call → same contract, proving forced
// Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
expect(a).toEqual(b);
});
});
+21 -21
View File
@@ -23,7 +23,7 @@ export function mulberry32(seed: number): Rng {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
t = (t + Math.imul(t ^ (a >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
@@ -35,12 +35,12 @@ export interface SeedPlane {
fuel: number;
maxFuel: number;
locationIcao: string;
contractId: null;
destIcao: null;
lat: number;
lon: number;
}
export interface SeedContract {
export interface SeedPackage {
id: string;
originIcao: string;
destIcao: string;
@@ -55,7 +55,7 @@ export interface SeedContract {
export interface SeedData {
plane: SeedPlane;
contracts: SeedContract[];
packages: SeedPackage[];
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
}
@@ -70,19 +70,19 @@ export const STARTING_REPUTATION = 10;
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const CONTRACT_COUNT_MIN = 8;
export const CONTRACT_COUNT_MAX = 12; // inclusive
export const PACKAGE_COUNT_MIN = 8;
export const PACKAGE_COUNT_MAX = 12; // inclusive
// Market target (v0.7.0): the sim engine tops open contracts back up to this
// Market target (v0.7.0): the sim engine tops open packages back up to this
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_CONTRACTS = 10;
export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
// Contract deadlines: now + a plausible game-time window of 6–72 hours.
// Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72;
@@ -92,16 +92,16 @@ export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
}
// Shared contract generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random
// Shared package generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
// distinct airports from the dataset, distance-based reward, cargo flavor,
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
// the e2e test hook to force a near-term expiry). The RNG call order per contract
// the e2e test hook to force a near-term expiry). The RNG call order per package
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-contract hook); a forced endpoint consumes no RNG draws, so
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
export function generateContract(input: {
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
rng: Rng;
@@ -109,9 +109,9 @@ export function generateContract(input: {
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
}): SeedContract {
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
if (airports.length < 2) throw new Error('package generation needs at least two airports');
let oi: number;
if (input.originIcao !== undefined) {
@@ -179,22 +179,22 @@ export function buildSeed(input: {
fuel: type.maxFuel, // starts full
maxFuel: type.maxFuel,
locationIcao: hub.icao,
contractId: null,
destIcao: null,
lat: hub.lat,
lon: hub.lon,
};
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
// Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these.
const contracts: SeedContract[] = [];
const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) {
contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs }));
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
}
return {
plane,
contracts,
packages,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
// by ~0 instead of treating "time since install" as elapsed play time.
+29 -27
View File
@@ -15,15 +15,18 @@ export interface PlaneState {
maxFuel: number;
locationIcao: string;
locationName: string;
contractId: string | null;
// Current flight destination (v0.12.0) — set by fly, null when grounded. The
// origin of the current leg is always locationIcao while flying.
destIcao: string | null;
destName: string | null;
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
// set by dispatch while flying so clients can compute progress/ETA from the clock.
// set by fly while flying so clients can compute progress/ETA from the clock.
departureSimTime: number | null;
lat: number;
lon: number;
}
export interface ContractState {
export interface PackageState {
id: string;
originIcao: string;
originName: string;
@@ -33,7 +36,7 @@ export interface ContractState {
cargoType: string;
cargoCount: number;
deadlineSimTime: number;
status: 'open' | 'accepted' | 'completed' | 'expired';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
}
@@ -42,7 +45,7 @@ export interface StateSnapshot {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint.
catalog: CatalogEntry[];
@@ -55,11 +58,11 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
// 0.45.x the sql-js driver drops the result mapper for relational queries and
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
const [accountRows, simRows, planeRows, contractRows] = await Promise.all([
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
db.select().from(schema.account),
db.select().from(schema.simState),
db.select().from(schema.planes),
db.select().from(schema.contracts),
db.select().from(schema.packages),
]);
const accountRow = accountRows[0];
const simRow = simRows[0];
@@ -72,14 +75,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock.
const contractById = new Map(contractRows.map((c) => [c.id, c]));
const livePlane = (p: schema.PlaneRow) => {
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null;
const contract = contractById.get(p.contractId);
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) return null;
const type = PLANE_TYPES[p.model];
const from = contract ? coordsByIcao.get(contract.originIcao) : undefined;
const to = contract ? coordsByIcao.get(contract.destIcao) : undefined;
if (!contract || !type || !from || !to) return null;
const from = coordsByIcao.get(p.locationIcao);
const to = coordsByIcao.get(p.destIcao);
if (!type || !from || !to) return null;
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
@@ -102,25 +103,26 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
maxFuel: p.maxFuel,
locationIcao: p.locationIcao,
locationName: nameOf(p.locationIcao),
contractId: p.contractId,
destIcao: p.destIcao,
destName: p.destIcao ? nameOf(p.destIcao) : null,
departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon,
};
}),
contracts: contractRows.map((c) => ({
id: c.id,
originIcao: c.originIcao,
originName: nameOf(c.originIcao),
destIcao: c.destIcao,
destName: nameOf(c.destIcao),
reward: c.reward,
cargoType: c.cargoType,
cargoCount: c.cargoCount,
deadlineSimTime: c.deadlineSimTime,
status: c.status,
planeId: c.planeId,
createdSimTime: c.createdSimTime,
packages: packageRows.map((p) => ({
id: p.id,
originIcao: p.originIcao,
originName: nameOf(p.originIcao),
destIcao: p.destIcao,
destName: nameOf(p.destIcao),
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
createdSimTime: p.createdSimTime,
})),
};
}