idle fleet slows world clock to real time instead of freezing (ISS-128)
CI / build + unit + e2e (pull_request) Successful in 10m32s

This commit is contained in:
2026-09-22 18:37:06 +00:00
parent 5268a39fe9
commit 0a40143497
12 changed files with 142 additions and 72 deletions
+28 -11
View File
@@ -16,6 +16,7 @@ import {
deliverPackages,
expirePackages,
finishRepairs,
IDLE_WORLD_TIME_SCALE,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
shouldAdvanceTime,
@@ -30,8 +31,9 @@ import {
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
// ISS-117 / ISS-128: fresh DBs seed a GROUNDED starter, so any test that needs FULL-scale advance
// must put a plane in flight first (a grounded fleet only advances at the idle real-time rate) —
// departureSimTime at the game-clock origin keeps its arrival hours away.
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
@@ -60,7 +62,7 @@ async function freshDb(name: string) {
describe('tickOnce — clock advance', () => {
it('advances the clock by elapsed real ms × timeScale', async () => {
const inst = await freshDb('scale.db');
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117)
await makeWorldActive(inst); // a grounded fleet would slow the world to the idle real-time rate (ISS-128)
vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 });
@@ -70,12 +72,12 @@ describe('tickOnce — clock advance', () => {
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
});
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
it('a fully grounded fleet advances at the idle real-time rate, not frozen (ISS-128)', async () => {
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
let sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE); // clamped to one idle-rate tick — no fast-forward, no freeze
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
// A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
@@ -83,7 +85,17 @@ describe('tickOnce — clock advance', () => {
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE + 500 * 60); // only the 500 ms since activity × 60
});
it('a grounded fleet ignores any raised timeScale — the idle rate wins (ISS-128)', async () => {
const inst = await freshDb('idle-scale.db');
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * IDLE_WORLD_TIME_SCALE); // real time, NOT ×1000
});
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
@@ -574,7 +586,12 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
});
});
describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
describe('idle slowdown — a grounded fleet slows the world to real time (ISS-128)', () => {
it('pins IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24, i.e. exactly real time', () => {
expect(IDLE_WORLD_TIME_SCALE).toBe(WORLD_TIME_SCALE / 24);
expect(IDLE_WORLD_TIME_SCALE).toBe(1); // one game-second per real second
});
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
@@ -583,14 +600,14 @@ describe('idle-pause — the world freezes when nothing is happening (ISS-117)',
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
});
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
it('a fully grounded fleet advances at the idle rate but keeps tracking real time', async () => {
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
vi.setSystemTime(T0 + 3_600_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE); // real-time rate, clamped — not frozen
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
});
it('a repairing plane keeps the world advancing', async () => {
+16 -9
View File
@@ -8,6 +8,7 @@ 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 { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
import {
generatePackage,
MARKET_TARGET_OPEN_PACKAGES,
@@ -22,7 +23,8 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// 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.
// world slows to real time instead of freezing (IDLE_WORLD_TIME_SCALE, ISS-128 v0.56.0), and
// lastRealTickMs always keeps tracking real time.
// 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
@@ -30,8 +32,9 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// 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';
// 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';
// 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
@@ -58,9 +61,10 @@ 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.
// 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.
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0;
let repairing = 0;
@@ -77,12 +81,15 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
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.
// 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.
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 scale = advance ? sim.timeScale : IDLE_WORLD_TIME_SCALE;
const advanceMs = Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale;
const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) {