push updated state to clients over SSE (ISS-129)
CI / build + unit + e2e (pull_request) Successful in 11m7s

This commit is contained in:
2026-09-22 20:44:13 +00:00
parent 9b2938a5b0
commit 1983d17099
13 changed files with 354 additions and 21 deletions
+27 -8
View File
@@ -7,7 +7,8 @@ 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 { parseIcaoQueue } from '@/lib/game/state';
import { getStateSnapshot, parseIcaoQueue } from '@/lib/game/state';
import { broadcast } from '@/lib/sse';
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
import {
generatePackage,
@@ -46,13 +47,15 @@ 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.
let chain: Promise<unknown> = Promise.resolve();
// 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 = chain.then(fn);
chain = run.then(
const run = (g.__flightTickChain ??= Promise.resolve()).then(fn);
g.__flightTickChain = run.then(
() => undefined,
() => undefined,
);
@@ -416,8 +419,24 @@ export async function spawnCoveragePackages(
// --- 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) — the exact same body GET /api/state returns (getStateSnapshot), so
// pushed and polled states can never diverge in shape. 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(await getStateSnapshot()));
} catch (err) {
console.error('flight: state broadcast failed', err);
}
}
export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => tickOnce(await getGameDb()));
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