Files
Flight-Ops/lib/sim/engine.ts
T

210 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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);
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<number> {
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<number> {
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<number> {
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<schema.SimStateRow> {
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<schema.SimStateRow> {
return withLock(async () => {
const inst = await getGameDb();
await tickOnce(inst);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
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));
}