import { randomInt, randomUUID } from 'node:crypto'; import { eq } 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, 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 (≤ 2 min even at max scale 60). 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 = 60; // Reputation gain per completed contract: 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 expireContracts(inst, clockMs); await finalizeArrivals(inst, clockMs); await spawnOpenContracts(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; } // 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 { const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')); 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)); 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 { const [planeRows, contractRows] = await Promise.all([ inst.db.select().from(schema.planes), inst.db.select().from(schema.contracts), ]); 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); 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 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, }) .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, }); 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 // indistinguishable from seeded ones. RNG is injectable for deterministic tests. export async function spawnOpenContracts( inst: GameDb, clockMs: number, rng?: Rng, ): Promise { const openCount = ( await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')) ).length; const needed = MARKET_TARGET_OPEN_CONTRACTS - 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.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs })); } return needed; } // --- Public API (singleton DB + lock) — call from every read/action endpoint --- export function tickNow(): Promise { return withLock(async () => tickOnce(await getGameDb())); } // Tick first so the returned state reflects current time, then apply the new // controls. timeScale is clamped to [1, 60]; 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)); }