490 lines
25 KiB
TypeScript
490 lines
25 KiB
TypeScript
import { randomInt, randomUUID } from 'node:crypto';
|
||
import { eq, sql } 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, poolAirports, sseFrame } from '@/lib/game/state';
|
||
import { broadcast } from '@/lib/sse';
|
||
|
||
import {
|
||
JOB_LIFETIME_MS,
|
||
POOL_BANDS,
|
||
REFILL_DELAY_MS,
|
||
SMALL_REFILL_DELAY_MS,
|
||
generatePoolPackage,
|
||
mulberry32,
|
||
poolBandOf,
|
||
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) × scale, 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. Scale is the
|
||
// fixed WORLD_TIME_SCALE in production; only FLIGHT_TEST_HOOKS deployments honor sim.timeScale
|
||
// (the test-hook override) — legacy saves may hold stale values (#135).
|
||
|
||
// 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 — imported for tickOnce, re-exported so server code and tests
|
||
// have one familiar home.
|
||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||
export { WORLD_TIME_SCALE };
|
||
|
||
// 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/delivery/repair progress until a dispatch or
|
||
// repair starts again (the market top-up still runs on frozen ticks, #135 follow-up).
|
||
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/delivery/repair progress
|
||
// (the market top-up still runs on frozen ticks — see below). 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 })));
|
||
// #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale
|
||
// timeScale (e.g. 1000 from pre-v0.52.0 UI controls, or an old e2e run against the live instance).
|
||
// Only FLIGHT_TEST_HOOKS deployments honor sim.timeScale, which POST /api/test/set-timescale writes.
|
||
const scale = process.env.FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE;
|
||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
|
||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||
|
||
if (advanceMs > 0) {
|
||
await expireLoadedPackages(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 pool work too.
|
||
await finishRepairs(inst, clockMs);
|
||
}
|
||
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
|
||
// because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without
|
||
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
|
||
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
|
||
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
|
||
await expireOpenPackages(inst, nowMs);
|
||
// AFTER delivery on purpose: a plane that just landed gets work spawned at its new location within
|
||
// the same tick (its destination joins the pool while flying, and its arrival airport on landing).
|
||
await spawnPoolPackages(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;
|
||
}
|
||
|
||
// v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 —
|
||
// it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs
|
||
// expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and
|
||
// never flip. Both return the count flipped.
|
||
export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, '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;
|
||
}
|
||
|
||
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
|
||
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
|
||
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
|
||
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows
|
||
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
|
||
// lifetime, and no new ones can ever be created. Returns the count flipped.
|
||
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
|
||
// Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
|
||
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
|
||
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
|
||
let n = 0;
|
||
for (const p of rows) {
|
||
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
|
||
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;
|
||
}
|
||
|
||
// v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
|
||
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
|
||
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
|
||
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
|
||
// indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
|
||
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
|
||
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
|
||
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
|
||
export async function spawnPoolPackages(
|
||
inst: GameDb,
|
||
clockMs: number,
|
||
rng?: Rng,
|
||
nowMs?: number,
|
||
): Promise<number> {
|
||
const real = nowMs ?? Date.now();
|
||
const [planeRows, packageRows] = await Promise.all([
|
||
inst.db.select().from(schema.planes),
|
||
inst.db.select().from(schema.packages),
|
||
]);
|
||
|
||
const pool = poolAirports(planeRows);
|
||
if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues
|
||
|
||
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');
|
||
|
||
let n = 0;
|
||
for (const originIcao of pool) {
|
||
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
|
||
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
|
||
const openByBand = [0, 0, 0, 0];
|
||
let blockedUntil: number | null = null;
|
||
for (const p of packageRows) {
|
||
if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue;
|
||
const b = poolBandOf(p.weightKg);
|
||
if (p.status === 'open') openByBand[b]++;
|
||
else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) {
|
||
// ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job
|
||
// refills too. Per-band tracking is a trivial change if the board feels sluggish.
|
||
blockedUntil = p.slotFreeAtRealMs;
|
||
}
|
||
}
|
||
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
|
||
const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS;
|
||
while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) {
|
||
await inst.db.insert(schema.packages).values(
|
||
generatePoolPackage({
|
||
airports,
|
||
originIcao,
|
||
bandIdx,
|
||
rng: rand,
|
||
clockMs,
|
||
id: randomUUID(),
|
||
createdRealMs: real,
|
||
reputation: account.reputation,
|
||
}),
|
||
);
|
||
openByBand[bandIdx]++;
|
||
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;
|
||
});
|
||
}
|