diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f62eba..8c855ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## v0.56.0 — 2026-09-22 18:28
+- **Idle fleet slows the world to real time instead of freezing it (ISS-128).** A fully grounded fleet no longer freezes the sim clock — `tickOnce` in `lib/sim/engine.ts` now advances by `min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE` when nothing is flying or in repair, where new constant `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24` (= exactly 1, i.e. real time) lives next to `WORLD_TIME_SCALE` in `lib/worldClock.ts`. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and `lastRealTickMs` still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump.
+- **Tests.** Unit: `lib/sim/engine.test.ts` — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1`. `lib/useSimClock.test.ts` — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: `e2e/sim.spec.ts`'s freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); `e2e/state.spec.ts`'s restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ `MAX_TICK_ELAPSED_MS` of clamped downtime + the live wall span; `e2e/right-pane.spec.ts`'s penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The `activateWorld`/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated.
+
## v0.55.0 — 2026-09-22 15:38
- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `` into the head; no layout/metadata changes needed.
- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset.
diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts
index 0f1b7e2..504a5da 100644
--- a/e2e/actions.spec.ts
+++ b/e2e/actions.spec.ts
@@ -98,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record {
await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
@@ -343,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy();
}
- // Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
- // put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
- // baseline below is captured AFTER deactivateWorld, not before it.
+ // A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
+ // repair for the expiry window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k
+ // repair cost is why the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page);
try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts
index e4b416b..859862d 100644
--- a/e2e/panes.spec.ts
+++ b/e2e/panes.spec.ts
@@ -39,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -151,7 +151,7 @@ test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, I
// 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
- // Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
+ // A grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in repair for full scale.
await activateWorld(page);
try {
await page.goto('/');
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts
index d78ea56..1390004 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -272,19 +272,28 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
- // The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
+ // The abandon button previews the penalty at its snapshot's clock (same formula as the server).
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
- // other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
- // snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
- const frozenClock = (await getState(page)).simState.clockMs;
+ // other planes), so the world runs at the idle real-time rate (ISS-128) — verify it actually did,
+ // then bound the UI's staleness instead of racing a frozen clock.
+ const c0 = (await getState(page)).simState.clockMs;
+ const t0 = Date.now();
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
- expect(
- (await getState(page)).simState.clockMs,
- 'world should be frozen while the fleet is idle (ISS-117)',
- ).toBe(frozenClock);
const state = await getState(page);
+ const idleDelta = state.simState.clockMs - c0;
+ expect(idleDelta, 'world should advance at the idle real-time rate while grounded (ISS-128)').toBeGreaterThan(0);
+ expect(idleDelta).toBeLessThan((Date.now() - t0) * WORLD); // clearly below the full world rate
+
+ // The UI renders the penalty from its last snapshot — up to one poll interval (~1 s, useGameState)
+ // older than `state`, i.e. ≤ ~2 game-seconds of clock drift at the idle real-time rate (ISS-128).
+ // Allow the formula's slope over that window instead of an exact string match.
+ const DRIFT_MS = 3_000; // a couple of poll intervals at the idle rate (= 1), margin for slow CI boxes
+ const span = pkg.deadlineSimTime - pkg.createdSimTime;
+ const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
+ expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
- await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
+ const slopePerGameMs = (pkg.reward * 0.45) / span; // abandonPenaltyPct's SPAN across the package lifetime
+ expect(Math.abs(Number(penaltyText) - expectedPenalty)).toBeLessThanOrEqual(slopePerGameMs * DRIFT_MS + 0.01);
// Fly to the destination through the UI — the button is the destination's name.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts
index b0b89ca..ca39127 100644
--- a/e2e/sim.spec.ts
+++ b/e2e/sim.spec.ts
@@ -1,13 +1,15 @@
import { test, expect, type Page } from '@playwright/test';
-// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
-// fleet, background-tick advance at a raised rate while something is active, package expiry via
-// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
+// v0.52.0 (ISS-117) fixed world clock against the live origin: a fully grounded fleet slowing to
+// real time instead of freezing (ISS-128), background-tick advance at a raised rate while
+// something is active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml),
+// and market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
+const IDLE = 1; // the idle real-time rate (ISS-128) — must match IDLE_WORLD_TIME_SCALE in lib/worldClock.ts (= WORLD / 24)
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
@@ -46,9 +48,10 @@ async function setHealth(page: Page, planeId: string, fields: Record {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -70,14 +73,25 @@ test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
-test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
+test('a fully grounded fleet slows the clock to real time instead of freezing it (ISS-128)', async ({ page }) => {
const s = await getState(page);
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
- const frozen = s.simState.clockMs;
- for (let i = 0; i < 3; i++) {
- await sleep(500);
- expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
- }
+
+ const t0 = Date.now();
+ const c0 = s.simState.clockMs;
+ await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
+ const after = await getState(page);
+ const elapsedReal = Date.now() - t0;
+
+ const delta = after.simState.clockMs - c0;
+ expect(delta, 'the idle clock must advance (no longer frozen)').toBeGreaterThan(0);
+ // ≈ real time: each tick counts min(gap, MAX_TICK_ELAPSED_MS) × IDLE rate, so the total can only fall
+ // short of the wall span when a tick gap was clamped — worst case is one clamped idle-rate tick.
+ expect(delta).toBeGreaterThanOrEqual(Math.min(elapsedReal * IDLE * 0.5, MAX_TICK_ELAPSED_MS * IDLE));
+ // …and never more than the wall span itself (plus one clamp of pre-read downtime for slow boxes)…
+ expect(delta).toBeLessThanOrEqual(elapsedReal + MAX_TICK_ELAPSED_MS);
+ // …which is clearly below the full world rate: at ×24 this window would be ≥ ~60 game seconds.
+ expect(delta).toBeLessThan(elapsedReal * WORLD);
});
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts
index d4b80aa..c19b308 100644
--- a/e2e/state.spec.ts
+++ b/e2e/state.spec.ts
@@ -8,6 +8,9 @@ import { test, expect } from '@playwright/test';
const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
+// Must match lib/sim/engine.ts — kept in sync manually (same pattern as e2e/sim.spec.ts).
+const MAX_TICK_ELAPSED_MS = 2_000;
+
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
@@ -42,8 +45,9 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000);
- // Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
- // post-restart snapshot must match byte-for-byte instead of within one tick bound.
+ // Self-heal to a fully grounded fleet first — while idle the clock advances at real time
+ // (ISS-128), so the post-restart snapshot is bounded by one clamped tick of downtime plus live
+ // ticks, not byte-for-byte identical.
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
@@ -56,6 +60,7 @@ test('state survives a container restart (sim clock + account persisted)', async
const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody;
+ const tBefore = Date.now();
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
@@ -75,11 +80,17 @@ test('state survives a container restart (sim clock + account persisted)', async
expect(after).toBeDefined();
const a = after!;
+ const tAfter = Date.now();
- // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
- // persisted across sessions"): account and entity ids are byte-identical. With the fleet
- // grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
- expect(a.simState.clockMs).toBe(before.simState.clockMs);
+ // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
+ // sessions"): account and entity ids are byte-identical. With the fleet grounded above, ISS-128
+ // advances the clock at real time while idle — bounded tolerance instead of exact equality: no
+ // ticks run while the container is down, so the first post-restart tick counts the downtime
+ // clamped to MAX_TICK_ELAPSED_MS × IDLE rate (= 1), and every other counted gap lies inside the
+ // live wall span [tBefore, tAfter] at that same rate. A re-seed would reset clockMs backwards.
+ const delta = a.simState.clockMs - before.simState.clockMs;
+ expect(delta).toBeGreaterThanOrEqual(0); // never goes backwards — not re-seeded
+ expect(delta).toBeLessThanOrEqual(MAX_TICK_ELAPSED_MS + (tAfter - tBefore));
expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts
index 948a044..7150504 100644
--- a/lib/sim/engine.test.ts
+++ b/lib/sim/engine.test.ts
@@ -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>) {
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 () => {
diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts
index 9e4a26a..6f0d637 100644
--- a/lib/sim/engine.ts
+++ b/lib/sim/engine.ts
@@ -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(fn: () => Promise): Promise {
// --- 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 {
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) {
diff --git a/lib/useSimClock.test.ts b/lib/useSimClock.test.ts
index 1613ddd..1b39c28 100644
--- a/lib/useSimClock.test.ts
+++ b/lib/useSimClock.test.ts
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
-import { WORLD_TIME_SCALE } from './worldClock';
+import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
-// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
+// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, and slows to
+// real-time × IDLE_WORLD_TIME_SCALE while idle (ISS-128).
const anchor = (over: Partial = {}): SimClockAnchor => ({
clockMs: 1_750_000_000_000,
@@ -24,10 +25,10 @@ describe('advanceSimClock', () => {
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
});
- it('is frozen while idle regardless of elapsed time', () => {
+ it('advances at the idle real-time rate (IDLE_WORLD_TIME_SCALE) while grounded, not frozen (ISS-128)', () => {
const a = anchor({ advancing: false });
- expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
- expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
+ expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — still the anchor value
+ expect(advanceSimClock(a, 1_001_000)).toBe(1_750_000_000_000 + 1_000 * IDLE_WORLD_TIME_SCALE); // a full second later — real time, not frozen
});
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
diff --git a/lib/useSimClock.ts b/lib/useSimClock.ts
index 40a7742..6a782bb 100644
--- a/lib/useSimClock.ts
+++ b/lib/useSimClock.ts
@@ -2,15 +2,16 @@
import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state';
-import { WORLD_TIME_SCALE } from './worldClock';
+import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
-// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
-// server's idle-pause instead of a player-controlled pause.
+// fixed WORLD_TIME_SCALE; ISS-128 (v0.56.0): while nothing is happening it slows to
+// IDLE_WORLD_TIME_SCALE (real time) instead of freezing — mirroring the server's idle slowdown,
+// so polls re-anchor without a visible jump.
export interface SimClockAnchor {
// Server truth at anchor time — the snapshot's simState.clockMs.
@@ -22,11 +23,11 @@ export interface SimClockAnchor {
}
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
-// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
-// the anchor value instead of going backwards. Unit-testable in node.
+// real-time × WORLD_TIME_SCALE; while idle it slows to real-time × IDLE_WORLD_TIME_SCALE (ISS-128).
+// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
- if (!anchor.advancing) return anchor.clockMs;
- return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
+ const scale = anchor.advancing ? WORLD_TIME_SCALE : IDLE_WORLD_TIME_SCALE;
+ return anchor.clockMs + Math.max(0, nowMs - anchor.at) * scale;
}
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
@@ -41,8 +42,8 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
// (useGameState), so this runs once per poll cycle as well.
useEffect(() => {
if (!snapshot) return;
- // Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
- // flying or in repair. One line on purpose — keep it in sync with that function.
+ // Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
+ // is flying or in repair, idle real-time otherwise. One line on purpose — keep it in sync.
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
diff --git a/lib/worldClock.ts b/lib/worldClock.ts
index c12ce05..ed105b4 100644
--- a/lib/worldClock.ts
+++ b/lib/worldClock.ts
@@ -2,3 +2,8 @@
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
export const WORLD_TIME_SCALE = 24;
+
+// ISS-128 (v0.56.0): a fully grounded fleet no longer freezes the world — it slows to real time
+// (one game-second per real second) so packages keep spawning/expiring and the market stays fresh.
+// Derived from the existing constant: WORLD_TIME_SCALE / 24 = 1, i.e. exactly "real-time".
+export const IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24;
diff --git a/package.json b/package.json
index 40299ee..251c02d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.55.0",
+ "version": "0.56.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",