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
+40 -20
View File
@@ -16,7 +16,6 @@ import {
deliverPackages,
expirePackages,
finishRepairs,
IDLE_WORLD_TIME_SCALE,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
shouldAdvanceTime,
@@ -31,9 +30,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 / 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.
// #135: fresh DBs seed a GROUNDED starter, so any test that needs clock advance must put a plane in
// flight first (a fully grounded fleet freezes the world) — 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 })
@@ -46,11 +45,15 @@ const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
beforeAll(async () => {
vi.useFakeTimers();
vi.setSystemTime(T0);
// #135 (v0.62.0): this file exercises the FLIGHT_TEST_HOOKS branch of tickOnce — stored
// sim.timeScale overrides are only honored when the env var is set, exactly like CI.
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
});
afterAll(async () => {
vi.useRealTimers();
vi.unstubAllEnvs();
await fs.rm(dir, { recursive: true, force: true });
});
@@ -62,7 +65,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 slow the world to the idle real-time rate (ISS-128)
await makeWorldActive(inst); // a fully grounded fleet freezes the clock (#135)
vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 });
@@ -72,30 +75,48 @@ describe('tickOnce — clock advance', () => {
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
});
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
it('production (no FLIGHT_TEST_HOOKS): a legacy stored timeScale is ignored — always WORLD_TIME_SCALE (#135)', async () => {
const inst = await freshDb('prod-rate.db');
await makeWorldActive(inst);
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // what a pre-v0.52.0 save holds
vi.setSystemTime(T0 + 1_000);
const saved = process.env.FLIGHT_TEST_HOOKS;
delete process.env.FLIGHT_TEST_HOOKS;
let sim: schema.SimStateRow;
try {
sim = await tickOnce(inst);
} finally {
if (saved !== undefined) process.env.FLIGHT_TEST_HOOKS = saved; // restore the stubbed value
}
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // ×24, NOT ×1000
});
it('a fully grounded fleet freezes the clock, then resumes with no catch-up (#135)', async () => {
const inst = await freshDb('idle.db'); // seeded starter is on the ground → frozen
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
let sim = await tickOnce(inst);
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.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no fast-forward, no idle creep
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.
// A plane takes off: resumes from where it stopped, with NO catch-up for the frozen time.
await inst.db.update(schema.simState).set({ timeScale: 60 });
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE + 500 * 60); // only the 500 ms since activity × 60
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
});
it('a grounded fleet ignores any raised timeScale — the idle rate wins (ISS-128)', async () => {
it('a grounded fleet ignores any raised timeScale — frozen regardless (#135)', 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
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen, NOT ×1000
});
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
@@ -586,13 +607,12 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
});
});
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
describe('grounded fleet freezes the world clock (#135)', () => {
it('pins WORLD_TIME_SCALE = 24 — the single fixed world rate (ISS-117)', () => {
expect(WORLD_TIME_SCALE).toBe(24);
});
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
it('shouldAdvanceTime: grounded/empty fleets are frozen; 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 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
@@ -600,13 +620,13 @@ describe('idle slowdown — a grounded fleet slows the world to real time (ISS-1
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
});
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
it('a fully grounded fleet stays frozen but keeps tracking real time', async () => {
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → frozen
vi.setSystemTime(T0 + 3_600_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE); // real-time rate, clamped — not frozen
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no idle creep
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
});