v0.62.0: fixed 24x world clock — frozen when fully grounded (#135)
CI / build + unit + e2e (pull_request) Successful in 14m3s

This commit is contained in:
2026-09-23 08:09:29 +00:00
parent e970f95036
commit bb4e3b96f3
14 changed files with 122 additions and 124 deletions
+22 -18
View File
@@ -9,7 +9,7 @@ import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES
import { SPECIALS } from '@/lib/game/specials';
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
import { broadcast } from '@/lib/sse';
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
import {
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
@@ -22,10 +22,12 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// 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 slows to real time instead of freezing (IDLE_WORLD_TIME_SCALE, ISS-128 v0.56.0), and
// lastRealTickMs always keeps tracking real time.
// 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
@@ -33,9 +35,10 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// 2 s × scale.
export const MAX_TICK_ELAPSED_MS = 2_000;
// ISS-117 / ISS-128: the fixed world rates — re-exported here so server code and tests have one
// familiar home (full rate while something is active, real-time rate while fully grounded).
export { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from '@/lib/worldClock';
// 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
@@ -64,10 +67,10 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// ISS-117 / ISS-128: idle 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 slows it to IDLE_WORLD_TIME_SCALE (real time) instead of freezing it, so
// expiries/spawns keep running until a dispatch or repair starts.
// #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;
@@ -84,15 +87,16 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now();
// ISS-128: a fully grounded fleet slows the world to real time (IDLE_WORLD_TIME_SCALE) instead of
// freezing it — packages keep spawning/expiring while idle. 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 idle duration.
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress.
// 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 scale = advance ? sim.timeScale : IDLE_WORLD_TIME_SCALE;
const advanceMs = Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale;
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) {