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 } from '@/lib/geo'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { generatePackage, MARKET_TARGET_OPEN_PACKAGES, mulberry32, type Rng, type SeedAirport, } from '@/lib/game/seed'; import { arrivalTimeFor, fuelAt } 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 = Promise.resolve(); function withLock(fn: () => Promise): Promise { 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 { 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); if (advanceMs > 0) { 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 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; } // 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 { 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 burn. await inst.db.update(schema.planes) .set({ status: 'available', locationIcao: to.icao, lat: to.lat, lon: to.lon, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1), departureSimTime: null, destIcao: null, }) .where(eq(schema.planes.id, p.id)); // 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_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)); for (let i = 0; i < needed; i++) { 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 // 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)); 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 }), ); n++; } return n; } // --- Public API (singleton DB + lock) — call from every read/action endpoint --- export function tickNow(): Promise { 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(fn: (inst: GameDb) => Promise): Promise { 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). export function applyTimeControls(input: SimControlsInput): Promise { return withLock(async () => { const inst = await getGameDb(); await tickOnce(inst); const sets: Partial> = {}; if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale); 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)); }