import { randomInt, randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer'; 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, poolAirports, sseFrame } from '@/lib/game/state'; import { broadcast, subscribedPlayerIds } from '@/lib/sse'; import { JOB_LIFETIME_MS, POOL_BANDS, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, generatePoolPackage, mulberry32, poolBandOf, 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) × scale, clamped per // tick so long absences never fast-forward the game; while no plane is flying or in repair the // world clock stays FROZEN (#135 v0.62.0 — final ISS-117 semantics), and lastRealTickMs always // keeps tracking real time, so resuming activity never fast-forwards the frozen span. Scale is the // fixed WORLD_TIME_SCALE in production; only FLIGHT_TEST_HOOKS deployments honor sim.timeScale // (the test-hook override) — legacy saves may hold stale values (#135). // 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: the fixed world rate — imported for tickOnce, re-exported so server code and tests // have one familiar home. import { WORLD_TIME_SCALE } from '@/lib/worldClock'; export { WORLD_TIME_SCALE }; // 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) --- // #135: advance 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 freezes it entirely — no expiry/delivery/repair progress until a dispatch or // repair starts again (the market top-up still runs on frozen ticks, #135 follow-up). 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(); // #135: a fully grounded fleet freezes the world — no advance, no expiry/delivery/repair progress // (the market top-up still runs on frozen ticks — see below). lastRealTickMs always tracks real // time, so resuming activity never fast-forwards the frozen span. const planeRows = await inst.db.select().from(schema.planes); const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status }))); // #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale // timeScale (e.g. 1000 from pre-v0.52.0 UI controls, or an old e2e run against the live instance). // Only FLIGHT_TEST_HOOKS deployments honor sim.timeScale, which POST /api/test/set-timescale writes. const scale = process.env.FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE; const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0; const clockMs = Math.round(sim.clockMs + advanceMs); if (advanceMs > 0) { await expireLoadedPackages(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 pool work too. await finishRepairs(inst, clockMs); } // v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) — // because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without // this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the // WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board; // both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before. await expireOpenPackages(inst, nowMs); // AFTER delivery on purpose: a plane that just landed gets work spawned at its new location within // the same tick (its destination joins the pool while flying, and its arrival airport on landing). await spawnPoolPackages(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; } // v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 — // it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs // expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and // never flip. Both return the count flipped. export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise { const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, '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; } // v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire // regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs // every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no // slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows // (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its // lifetime, and no new ones can ever be created. Returns the count flipped. export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise { // Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a // legacy value in pre-v0.69.0 databases, so the typed column can't express the query. const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`); let n = 0; for (const p of rows) { if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) { 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)); // v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so // multiple deliveries in one tick all count. const fresh = await getPlayer(inst, p.ownerId); // 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 updatePlayerEconomy(inst, p.ownerId, { 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); // v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the // delivery awards above) so a top-up in this tick sees current cash. const fresh = await getPlayer(inst, p.ownerId); 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 updatePlayerEconomy(inst, p.ownerId, { 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; } // v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn // (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for // it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each // band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are // indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after // slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on // the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup. // RNG and wall-clock now are injectable for deterministic tests. Returns the number created. export async function spawnPoolPackages( inst: GameDb, clockMs: number, rng?: Rng, nowMs?: number, ): Promise { const real = nowMs ?? Date.now(); const [planeRows, packageRows] = await Promise.all([ inst.db.select().from(schema.planes), inst.db.select().from(schema.packages), ]); const pool = poolAirports(planeRows); if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues 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 a reputation-based job quality — read once per spawn batch. // v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the // agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed // player instead of the seeded local row. const reputation = await getSharedWorldReputation(inst); let n = 0; for (const originIcao of pool) { // Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter: // terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp. const openByBand = [0, 0, 0, 0]; let blockedUntil: number | null = null; for (const p of packageRows) { if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue; const b = poolBandOf(p.weightKg); if (p.status === 'open') openByBand[b]++; else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) { // ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job // refills too. Per-band tracking is a trivial change if the board feels sluggish. blockedUntil = p.slotFreeAtRealMs; } } for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) { const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS; while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) { await inst.db.insert(schema.packages).values( generatePoolPackage({ airports, originIcao, bandIdx, rng: rand, clockMs, id: randomUUID(), createdRealMs: real, reputation, }), ); openByBand[bandIdx]++; 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. // v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's // player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only, // so every client sees its own account row in every push. Same shared projection as GET /api/state // (getStateSnapshot + sseFrame), so pushed frames can never drift from polled ones. A session whose // players row vanished mid-stream is skipped per-tick with a log — the others keep flowing. async function broadcastState(): Promise { const frames = new Map(); for (const playerId of subscribedPlayerIds()) { try { frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId)))); } catch (err) { console.error('flight: state broadcast failed', err); } } if (frames.size > 0) broadcast(frames); } 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; }); }