412 lines
19 KiB
TypeScript
412 lines
19 KiB
TypeScript
import { randomInt, randomUUID } from 'node:crypto';
|
||
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, initialBearingDeg } from '@/lib/geo';
|
||
import { weatherFor } from '@/lib/weather';
|
||
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||
import { SPECIALS } from '@/lib/game/specials';
|
||
import { parseIcaoQueue } from '@/lib/game/state';
|
||
import {
|
||
generatePackage,
|
||
MARKET_TARGET_OPEN_PACKAGES,
|
||
mulberry32,
|
||
type Rng,
|
||
type SeedAirport,
|
||
} from '@/lib/game/seed';
|
||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||
|
||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||
// (real ms since last tick) × timeScale, clamped per tick so long absences never
|
||
// fast-forward the game.
|
||
|
||
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms —
|
||
// comfortably above a normal client poll interval (~1 s), so active play advances
|
||
// ~real time × scale; small enough that one stale read after an absence moves game
|
||
// time by at most 2 s × scale (≈ 33 min even at max scale 1000). The sim therefore
|
||
// only meaningfully progresses while actively polled.
|
||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
||
|
||
export const MIN_TIME_SCALE = 1;
|
||
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0)
|
||
|
||
// 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;
|
||
export const REP_PER_KM = 0.0005;
|
||
|
||
export function reputationGainFor(km: number): number {
|
||
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
|
||
}
|
||
|
||
export interface SimControlsInput {
|
||
timeScale?: number;
|
||
paused?: boolean;
|
||
}
|
||
|
||
// Serialize ticks across concurrent requests (Next.js route handlers run in the
|
||
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
||
// no advance is ever applied twice.
|
||
let chain: Promise<unknown> = Promise.resolve();
|
||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||
const run = chain.then(fn);
|
||
chain = run.then(
|
||
() => undefined,
|
||
() => undefined,
|
||
);
|
||
return run;
|
||
}
|
||
|
||
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
|
||
|
||
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||
const rows = await inst.db.select().from(schema.simState);
|
||
const sim = rows[0];
|
||
if (!sim) throw new Error('sim_state row missing');
|
||
|
||
const nowMs = Date.now();
|
||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||
// While paused the clock is frozen; lastRealTickMs still tracks real time so
|
||
// unpausing resumes cleanly with no catch-up jump for the pause duration.
|
||
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
|
||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||
|
||
let landedThisTick = 0;
|
||
if (advanceMs > 0) {
|
||
await expirePackages(inst, clockMs);
|
||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||
landedThisTick = await deliverPackages(inst, clockMs);
|
||
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||
await finishRepairs(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 spawnCoveragePackages(inst, clockMs);
|
||
}
|
||
|
||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
|
||
clockMs,
|
||
lastRealTickMs: nowMs,
|
||
};
|
||
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
|
||
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
|
||
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
|
||
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
|
||
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
|
||
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
|
||
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
|
||
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
|
||
if (landedThisTick > 0) {
|
||
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
|
||
}
|
||
|
||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||
await inst.db.update(schema.simState).set(sets);
|
||
await persist(inst);
|
||
const updated = (await inst.db.select().from(schema.simState))[0];
|
||
if (!updated) throw new Error('sim_state row missing after tick');
|
||
return updated;
|
||
}
|
||
|
||
// 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 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;
|
||
}
|
||
|
||
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
|
||
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
|
||
// `<=` inclusive at the clock. Returns the count flipped.
|
||
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||
let n = 0;
|
||
for (const p of rows) {
|
||
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'available', repairUntilSimTime: null })
|
||
.where(eq(schema.planes.id, p.id));
|
||
n++;
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||
// for every package bound to the plane and destined for its dest_icao — '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. Then (v0.25.0, ISS-043)
|
||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
||
// immediately from the arrival airport; otherwise ground the plane there with
|
||
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
||
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
|
||
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.packages),
|
||
]);
|
||
|
||
const fc = await loadAirportsGeoJson();
|
||
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||
|
||
let n = 0;
|
||
for (const p of planeRows) {
|
||
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) continue;
|
||
const type = PLANE_TYPES[p.model];
|
||
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;
|
||
|
||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
||
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
|
||
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
|
||
const w = weatherFor(p.locationIcao);
|
||
const landedFuel = fuelAtWeighted(
|
||
p.fuel,
|
||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||
type.emptyWeightKg,
|
||
type.maxFuel,
|
||
distanceKm,
|
||
1,
|
||
);
|
||
|
||
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
|
||
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
|
||
|
||
// Deliver every bound package whose destination is the airport just reached —
|
||
// runs whether or not a queued leg chains next (ISS-043).
|
||
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');
|
||
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
|
||
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
|
||
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
||
await inst.db.update(schema.account).set({
|
||
cash: fresh.cash + pk.reward,
|
||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 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));
|
||
}
|
||
}
|
||
|
||
// Auto-chain (ISS-043): with a non-empty queue and enough fuel for the first
|
||
// queued leg, start it immediately from `to` — same feasibility rule as flyPlane
|
||
// (requiredFuelFor is independent of current load). Otherwise ground here; an
|
||
// unknown ICAO in the queue simply cannot chain. The remaining queue is kept
|
||
// intact either way so a grounded plane can refuel and re-dispatch.
|
||
const queue = parseIcaoQueue(p.queue);
|
||
let chainedTo: string | null = null;
|
||
if (queue.length > 0) {
|
||
const nextDest = queue[0];
|
||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||
if (next) {
|
||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069):
|
||
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what
|
||
// livePlane()/the next landing will compute for that flight.
|
||
const wNext = weatherFor(to.icao);
|
||
const nextRequiredL = requiredFuelFor(
|
||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
||
type.emptyWeightKg,
|
||
type.maxFuel,
|
||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||
);
|
||
if (nextRequiredL <= landedFuel) {
|
||
queue.shift();
|
||
chainedTo = nextDest;
|
||
}
|
||
}
|
||
}
|
||
await inst.db.update(schema.planes)
|
||
.set(
|
||
chainedTo
|
||
? {
|
||
status: 'flying',
|
||
locationIcao: to.icao,
|
||
lat: to.lat,
|
||
lon: to.lon,
|
||
fuel: landedFuel,
|
||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||
destIcao: chainedTo,
|
||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||
queue: JSON.stringify(queue),
|
||
}
|
||
: {
|
||
status: 'available',
|
||
locationIcao: to.icao,
|
||
lat: to.lat,
|
||
lon: to.lon,
|
||
fuel: landedFuel,
|
||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||
departureSimTime: null,
|
||
destIcao: null,
|
||
queue: JSON.stringify(queue),
|
||
},
|
||
)
|
||
.where(eq(schema.planes.id, p.id));
|
||
n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// 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 spawnOpenPackages(
|
||
inst: GameDb,
|
||
clockMs: number,
|
||
rng?: Rng,
|
||
): Promise<number> {
|
||
const openCount = (
|
||
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
|
||
).length;
|
||
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
|
||
if (needed <= 0) return 0;
|
||
|
||
const fc = await loadAirportsGeoJson();
|
||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||
icao: f.properties.icao,
|
||
name: f.properties.name,
|
||
lat: f.properties.lat,
|
||
lon: f.properties.lon,
|
||
}));
|
||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
if (!account) throw new Error('account row missing');
|
||
|
||
for (let i = 0; i < needed; i++) {
|
||
await inst.db.insert(schema.packages).values(
|
||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
|
||
);
|
||
}
|
||
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
|
||
// 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 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.packages).where(eq(schema.packages.status, 'open')),
|
||
]);
|
||
|
||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
||
// they can't take new work until they land).
|
||
const groundedIcas = [
|
||
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
|
||
];
|
||
if (groundedIcas.length === 0) return 0;
|
||
|
||
const covered = new Set(openRows.map((c) => c.originIcao));
|
||
const missing = groundedIcas.filter((icao) => !covered.has(icao));
|
||
if (missing.length === 0) return 0;
|
||
|
||
const fc = await loadAirportsGeoJson();
|
||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||
icao: f.properties.icao,
|
||
name: f.properties.name,
|
||
lat: f.properties.lat,
|
||
lon: f.properties.lon,
|
||
}));
|
||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
if (!account) throw new Error('account row missing');
|
||
|
||
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.packages).values(
|
||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao, reputation: account.reputation }),
|
||
);
|
||
n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||
|
||
export function tickNow(): Promise<schema.SimStateRow> {
|
||
return withLock(async () => tickOnce(await getGameDb()));
|
||
}
|
||
|
||
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
|
||
// advanced FIRST so validation and mutation see current game time, then fn runs —
|
||
// all under the same promise lock as tickNow(), so no concurrent request can
|
||
// interleave between the tick and the mutation. Action functions in
|
||
// lib/game/actions.ts take an explicit GameDb so unit tests can inject their own.
|
||
export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||
return withLock(async () => {
|
||
const inst = await getGameDb();
|
||
await tickOnce(inst);
|
||
return fn(inst);
|
||
});
|
||
}
|
||
|
||
// Tick first so the returned state reflects current time, then apply the new
|
||
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
|
||
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
|
||
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
|
||
// the last grounded plane takes off. A {paused}-only post never touches it, so the
|
||
// last manual intent wins without an extra flag.
|
||
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
|
||
return withLock(async () => {
|
||
const inst = await getGameDb();
|
||
await tickOnce(inst);
|
||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
|
||
if (input.timeScale !== undefined) {
|
||
const scale = clampTimeScale(input.timeScale);
|
||
sets.timeScale = scale;
|
||
sets.lastSelectedTimeScale = scale;
|
||
}
|
||
if (input.paused !== undefined) sets.paused = input.paused;
|
||
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
|
||
await persist(inst);
|
||
const updated = (await inst.db.select().from(schema.simState))[0];
|
||
if (!updated) throw new Error('sim_state row missing');
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
export function clampTimeScale(v: number): number {
|
||
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
|
||
}
|