275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
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);
|
||
// AFTER finalization on purpose: a plane that just landed gets work spawned
|
||
// at its new location within the same tick.
|
||
await spawnCoverageContracts(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;
|
||
}
|
||
|
||
// 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
|
||
// contract originating there — so work is always available where the fleet sits
|
||
// (including onboarding at EDDF). One contract per uncovered airport, generated
|
||
// with generateContract and origin forced to that airport. Runs after
|
||
// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage
|
||
// too; and after finalizeArrivals, so a plane that just landed gets work spawned
|
||
// at its new location within the same tick. Returns the number created.
|
||
export async function spawnCoverageContracts(
|
||
inst: GameDb,
|
||
clockMs: number,
|
||
rng?: Rng,
|
||
): Promise<number> {
|
||
const [planeRows, openRows] = await Promise.all([
|
||
inst.db.select().from(schema.planes),
|
||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.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.contracts).values(
|
||
generateContract({ 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<schema.SimStateRow> {
|
||
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<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||
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, 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));
|
||
}
|