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 { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { SPECIALS } from '@/lib/game/specials'; import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state'; import { broadcast } from '@/lib/sse'; import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock'; import { generatePackage, MARKET_TARGET_OPEN_PACKAGES, mulberry32, pickDestWithinCap, type Rng, type SeedAirport, } from '@/lib/game/seed'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; // Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background // tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the // same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped // per tick so long absences never fast-forward the game; while no plane is flying or in repair the // world slows to real time instead of freezing (IDLE_WORLD_TIME_SCALE, ISS-128 v0.56.0), and // lastRealTickMs always keeps tracking real time. // Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above // both the client poll interval (~1 s) and the background-tick interval, 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. export const MAX_TICK_ELAPSED_MS = 2_000; // ISS-117 / ISS-128: the fixed world rates — re-exported here so server code and tests have one // familiar home (full rate while something is active, real-time rate while fully grounded). export { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from '@/lib/worldClock'; // 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; } // 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. // ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get // separate module instances from Next.js, so a plain `let` would give each layer its own lock and let // their ticks interleave against the shared DB below. const g = globalThis as unknown as { __flightTickChain?: Promise }; function withLock(fn: () => Promise): Promise { const run = (g.__flightTickChain ??= Promise.resolve()).then(fn); g.__flightTickChain = run.then( () => undefined, () => undefined, ); return run; } // --- Tick core (takes a GameDb so unit tests can inject their own instance) --- // ISS-117 / ISS-128: idle decision — pure, so unit tests feed it synthetic fleets. The world clock // runs at full scale only while something is happening (at least one plane flying OR in repair); a // fully grounded fleet slows it to IDLE_WORLD_TIME_SCALE (real time) instead of freezing it, so // expiries/spawns keep running until a dispatch or repair starts. export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } { let flying = 0; let repairing = 0; for (const p of planes) { if (p.status === 'flying') flying++; else if (p.status === 'repairing') repairing++; } return { advance: flying > 0 || repairing > 0, flying, repairing }; } export async function tickOnce(inst: GameDb): Promise { 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(); // ISS-128: a fully grounded fleet slows the world to real time (IDLE_WORLD_TIME_SCALE) instead of // freezing it — packages keep spawning/expiring while idle. An active fleet keeps its full scale, // including any FLIGHT_TEST_HOOKS override in sim.timeScale. lastRealTickMs always tracks real // time, so resuming activity never fast-forwards the idle duration. const planeRows = await inst.db.select().from(schema.planes); const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status }))); const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); const scale = advance ? sim.timeScale : IDLE_WORLD_TIME_SCALE; const advanceMs = Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale; const clockMs = Math.round(sim.clockMs + advanceMs); if (advanceMs > 0) { await expirePackages(inst, clockMs); 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); } // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs }); 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 { 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 heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along // round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started — // derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips // back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as // expirePackages: one guarded pass over the rows. Returns the count flipped. export async function finishRepairs(inst: GameDb, clockMs: number): Promise { 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) continue; // defensive — repairing always carries a gate // ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a // plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the // start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive, // as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane // is still 'repairing' and grounded until the gate passes. const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS)); if (p.repairUntilSimTime <= clockMs) { await inst.db.update(schema.planes) .set({ status: 'available', health: 100, repairUntilSimTime: null }) .where(eq(schema.planes.id, p.id)); n++; } else { const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100))); if (newHealth !== p.health) { await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id)); } } } 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, fuel covers the first one and the plane is not // broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival // airport; with a short tank, ISS-082 (v0.42.0) tops // up to 150% of the requirement (capped at tank capacity) when cash allows and // chains anyway — only an unaffordable top-up grounds the plane there with its // remaining fuel and the queue kept intact for a later 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. export async function deliverPackages(inst: GameDb, clockMs: number): Promise { 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. 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)), 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). ISS-082 (v0.42.0): manual refuel is gone, // so a short tank no longer strands the chain — when cash allows, top up to 150% of the // requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the // queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0 // (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with // the queue intact until repaired, exactly like flyPlane's broken guard. const queue = parseIcaoQueue(p.queue); let chainedTo: string | null = null; let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened if (queue.length > 0 && newHealth > 0) { const nextDest = queue[0]; // ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a // zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this // since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg). if (nextDest === to.icao) { queue.shift(); } else { 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). const wNext = weatherFor(to.icao); const nextRequiredL = requiredFuelFor( type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)), type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon), ); // v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue // intact, mirroring flyPlane's 409 without throwing in the engine. if (nextRequiredL > type.maxFuel) { // no chain — the grounded update below keeps the queue for a later dispatch } else if (nextRequiredL <= landedFuel) { queue.shift(); chainedTo = nextDest; } else { // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const liters = Math.max(0, target - landedFuel); // Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. const fresh = (await inst.db.select().from(schema.account))[0]; if (!fresh) throw new Error('account row missing'); const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; if (cost === 0 || fresh.cash >= cost) { queue.shift(); chainedTo = nextDest; topUpFuel = target; if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); } } } } } await inst.db.update(schema.planes) .set( chainedTo ? { status: 'flying', locationIcao: to.icao, lat: to.lat, lon: to.lon, fuel: topUpFuel ?? landedFuel, // ISS-082: the post-top-up level when a top-up funded the chain 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 { 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 { 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) { const oi = airports.findIndex((a) => a.icao === icao); if (oi < 0) continue; // defensive — the dataset is the source of all ICACs // v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs — // one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged. const di = pickDestWithinCap(airports, oi, rand); await inst.db.insert(schema.packages).values( generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao, ...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior reputation: account.reputation, }), ); n++; } return n; } // --- Public API (singleton DB + lock) — call from every read/action endpoint --- // ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE // subscribers (/api/events). ISS-133 (v0.59.0): frames carry the sseFrame projection — terminal // package history is dropped, everything else matches GET /api/state exactly. Failures are logged, // not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for // other clients too. async function broadcastState(): Promise { try { broadcast(JSON.stringify(sseFrame(await getStateSnapshot()))); } catch (err) { console.error('flight: state broadcast failed', err); } } export function tickNow(): Promise { return withLock(async () => { const row = await tickOnce(await getGameDb()); await broadcastState(); return row; }); } // ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked // tick path as reads/actions. Started once at server boot from instrumentation.ts (Next ≥15); the // guard makes double-starts harmless (dev HMR reloads). let backgroundTimer: ReturnType | null = null; export function startBackgroundTick(intervalMs = 1_000): void { if (backgroundTimer !== null) return; backgroundTimer = setInterval(() => { tickNow().catch((err) => console.error('flight: background tick failed', err)); }, intervalMs); } // 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(fn: (inst: GameDb) => Promise): Promise { return withLock(async () => { const inst = await getGameDb(); await tickOnce(inst); return fn(inst); }); } // Test-hook backing (FLIGHT_TEST_HOOKS=1 only — see app/api/test/set-timescale/route.ts): set the // effective time scale under the same lock as ticks so a background tick can never interleave // between read and write. Input validation happens in the route; this trusts its number. export function setTestTimeScale(scale: number): Promise { return withLock(async () => { const inst = await getGameDb(); await tickOnce(inst); await inst.db.update(schema.simState).set({ timeScale: scale }); await persist(inst); const updated = (await inst.db.select().from(schema.simState))[0]; if (!updated) throw new Error('sim_state row missing'); return updated; }); }