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
+66 -54
View File
@@ -1,13 +1,13 @@
import { randomInt, randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import {
generateContract,
MARKET_TARGET_OPEN_CONTRACTS,
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
mulberry32,
type Rng,
type SeedAirport,
@@ -29,7 +29,7 @@ export const MAX_TICK_ELAPSED_MS = 2_000;
export const MIN_TIME_SCALE = 1;
export const MAX_TIME_SCALE = 64; // top of the power-of-two ladder (v0.9.2)
// Reputation gain per completed contract: BASE + PER_KM × great-circle km, rounded
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10.
export const REP_BASE = 1;
@@ -72,12 +72,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) {
await expireContracts(inst, clockMs);
await finalizeArrivals(inst, clockMs);
await spawnOpenContracts(inst, clockMs);
// AFTER finalization on purpose: a plane that just landed gets work spawned
await expirePackages(inst, clockMs);
await deliverPackages(inst, clockMs);
await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned
// at its new location within the same tick.
await spawnCoverageContracts(inst, clockMs);
await spawnCoveragePackages(inst, clockMs);
}
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
@@ -88,42 +88,45 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
return updated;
}
// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight
// contracts never expire — they complete. Returns the number flipped.
export async function expireContracts(inst: GameDb, clockMs: number): Promise<number> {
const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'));
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
// occupying their slot, deliver no reward on arrival, and abandon for free).
// Delivered/abandoned packages are terminal and never flip. Returns the count.
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
let n = 0;
for (const c of open) {
if (c.deadlineSimTime <= clockMs) {
await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id));
for (const p of rows) {
if (p.deadlineSimTime <= clockMs) {
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
n++;
}
}
return n;
}
// Finalize in-flight planes whose arrival time has passed: award the contract
// reward (cash) + reputation gain exactly once, mark the contract completed, and
// ground the plane at the destination with its remaining fuel. Idempotent — a
// finalized plane leaves the 'flying' state, so it can never be awarded twice.
export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, contractRows] = await Promise.all([
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
// ground the plane at its dest_icao with its remaining fuel, then for every
// package bound to it and destined for that airport — 'loaded' ones still inside
// their deadline are delivered (cash += reward + reputation gain per package),
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
// on the plane for a later leg. Idempotent — a finalized plane leaves the
// 'flying' state, so it can never be awarded twice. Returns planes landed.
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
const [planeRows, packageRows] = await Promise.all([
inst.db.select().from(schema.planes),
inst.db.select().from(schema.contracts),
inst.db.select().from(schema.packages),
]);
const fc = await loadAirportsGeoJson();
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
const contractById = new Map(contractRows.map((c) => [c.id, c]));
let n = 0;
for (const p of planeRows) {
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue;
const contract = contractById.get(p.contractId);
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) continue;
const type = PLANE_TYPES[p.model];
const from = contract ? byIcao.get(contract.originIcao) : undefined;
const to = contract ? byIcao.get(contract.destIcao) : undefined;
if (!contract || !type || !from || !to) continue; // no route info — leave alone
const from = byIcao.get(p.locationIcao);
const to = byIcao.get(p.destIcao);
if (!type || !from || !to) continue; // no route info — leave alone
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
@@ -137,35 +140,44 @@ export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<n
lon: to.lon,
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
departureSimTime: null,
destIcao: null,
})
.where(eq(schema.planes.id, p.id));
await inst.db.update(schema.contracts)
.set({ status: 'completed' })
.where(eq(schema.contracts.id, contract.id));
// Fresh account read per award so multiple arrivals in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
await inst.db.update(schema.account).set({
cash: fresh.cash + contract.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
});
// Deliver every bound package whose destination is the airport just reached.
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
for (const pk of onBoard) {
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
// Fresh account read per award so multiple deliveries in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
await inst.db.update(schema.account).set({
cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
});
} else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot.
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
}
}
n++;
}
return n;
}
// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the
// SAME builder as the first-run seed (generateContract), so spawned contracts are
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
// SAME builder as the first-run seed (generatePackage), so spawned packages are
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
export async function spawnOpenContracts(
export async function spawnOpenPackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
): Promise<number> {
const openCount = (
await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'))
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
).length;
const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount;
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
if (needed <= 0) return 0;
const fc = await loadAirportsGeoJson();
@@ -178,27 +190,27 @@ export async function spawnOpenContracts(
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
for (let i = 0; i < needed; i++) {
await inst.db.insert(schema.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
}
return needed;
}
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
// contract originating there — so work is always available where the fleet sits
// (including onboarding at EDDF). One contract per uncovered airport, generated
// with generateContract and origin forced to that airport. Runs after
// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage
// too; and after finalizeArrivals, so a plane that just landed gets work spawned
// package originating there — so work is always available where the fleet sits
// (including onboarding at EDDF). One package per uncovered airport, generated
// with generatePackage and origin forced to that airport. Runs after
// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage
// too; and after deliverPackages, so a plane that just landed gets work spawned
// at its new location within the same tick. Returns the number created.
export async function spawnCoverageContracts(
export async function spawnCoveragePackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
): Promise<number> {
const [planeRows, openRows] = await Promise.all([
inst.db.select().from(schema.planes),
inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')),
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')),
]);
// Distinct airports with at least one grounded plane (flying planes don't count —
@@ -224,8 +236,8 @@ export async function spawnCoverageContracts(
let n = 0;
for (const icao of missing) {
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
await inst.db.insert(schema.contracts).values(
generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
);
n++;
}