Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s

The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
  lib/timeScale.ts, LeftPane pause button + scale slider machinery,
  ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
  rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
  (sibling of set-health); all e2e setScale helpers call it, afterEach restores
  the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
  activates the world via set-health + /repair for advance/expiry tests.
This commit is contained in:
2026-09-22 10:50:21 +00:00
parent d31bc5d60f
commit a0560ec767
46 changed files with 631 additions and 1502 deletions
+48 -61
View File
@@ -18,20 +18,20 @@ import {
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } 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.
// 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
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time.
// 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 (≈ 33 min even at max scale 1000). The sim therefore
// only meaningfully progresses while actively polled.
// 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;
export const MIN_TIME_SCALE = 1;
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0)
// 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
@@ -43,11 +43,6 @@ 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.
@@ -63,23 +58,36 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock
// advances only while something is happening: at least one plane flying OR in repair. A fully
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
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();
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
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);
// 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 advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
const clockMs = Math.round(sim.clockMs + advanceMs);
let landedThisTick = 0;
if (advanceMs > 0) {
await expirePackages(inst, clockMs);
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
landedThisTick = await deliverPackages(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);
@@ -89,25 +97,8 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await spawnCoveragePackages(inst, clockMs);
}
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
clockMs,
lastRealTickMs: nowMs,
};
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
if (landedThisTick > 0) {
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
}
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
await inst.db.update(schema.simState).set(sets);
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');
@@ -174,8 +165,7 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
// 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. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
// 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),
@@ -423,6 +413,17 @@ export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => tickOnce(await getGameDb()));
}
// 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
@@ -436,31 +437,17 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
});
}
// Tick first so the returned state reflects current time, then apply the new
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
// the last grounded plane takes off. A {paused}-only post never touches it, so the
// last manual intent wins without an extra flag.
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
// 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);
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
if (input.timeScale !== undefined) {
const scale = clampTimeScale(input.timeScale);
sets.timeScale = scale;
sets.lastSelectedTimeScale = scale;
}
if (input.paused !== undefined) sets.paused = input.paused;
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
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;
});
}
export function clampTimeScale(v: number): number {
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
}