478 lines
23 KiB
TypeScript
478 lines
23 KiB
TypeScript
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, 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, sseFrame } from '@/lib/game/state';
|
||
import { broadcast } from '@/lib/sse';
|
||
|
||
import {
|
||
generatePackage,
|
||
MARKET_TARGET_OPEN_PACKAGES,
|
||
mulberry32,
|
||
pickDestWithinCap,
|
||
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) × timeScale, 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.
|
||
|
||
// 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 — re-exported here so server code and tests have one familiar home.
|
||
export { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||
|
||
// 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<unknown> };
|
||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||
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/spawn/repair progress until a dispatch or repair
|
||
// starts again.
|
||
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<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();
|
||
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress.
|
||
// An active fleet keeps its full scale, including any FLIGHT_TEST_HOOKS override in sim.timeScale.
|
||
// 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 })));
|
||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
|
||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||
|
||
if (advanceMs > 0) {
|
||
await expirePackages(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 coverage work too.
|
||
await finishRepairs(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<number> {
|
||
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;
|
||
}
|
||
|
||
// 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<number> {
|
||
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<number> {
|
||
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));
|
||
// 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');
|
||
// 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 inst.db.update(schema.account).set({
|
||
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);
|
||
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
|
||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||
if (!fresh) throw new Error('account row missing');
|
||
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 inst.db.update(schema.account).set({ 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;
|
||
}
|
||
|
||
// 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<number> {
|
||
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));
|
||
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
if (!account) throw new Error('account row missing');
|
||
|
||
for (let i = 0; i < needed; i++) {
|
||
await inst.db.insert(schema.packages).values(
|
||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
|
||
);
|
||
}
|
||
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<number> {
|
||
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));
|
||
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
|
||
const account = (await inst.db.select().from(schema.account))[0];
|
||
if (!account) throw new Error('account row missing');
|
||
|
||
let n = 0;
|
||
for (const icao of missing) {
|
||
const oi = airports.findIndex((a) => a.icao === icao);
|
||
if (oi < 0) continue; // defensive — the dataset is the source of all ICACs
|
||
// v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs —
|
||
// one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged.
|
||
const di = pickDestWithinCap(airports, oi, rand);
|
||
await inst.db.insert(schema.packages).values(
|
||
generatePackage({
|
||
id: randomUUID(),
|
||
airports,
|
||
rng: rand,
|
||
nowMs: clockMs,
|
||
originIcao: icao,
|
||
...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior
|
||
reputation: account.reputation,
|
||
}),
|
||
);
|
||
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.
|
||
async function broadcastState(): Promise<void> {
|
||
try {
|
||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot())));
|
||
} catch (err) {
|
||
console.error('flight: state broadcast failed', err);
|
||
}
|
||
}
|
||
|
||
export function tickNow(): Promise<schema.SimStateRow> {
|
||
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<typeof setInterval> | 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<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||
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<schema.SimStateRow> {
|
||
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;
|
||
});
|
||
}
|