v0.62.0: fixed 24x world clock — frozen when fully grounded (#135)
CI / build + unit + e2e (pull_request) Successful in 14m3s
CI / build + unit + e2e (pull_request) Successful in 14m3s
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.62.0 — 2026-09-23 07:36
|
||||||
|
- **Fixed 24x world clock (#135).** Finalized the closed issue #117 spec: the world clock runs at the fixed `WORLD_TIME_SCALE = 24` while any plane is flying or in repair, and stays FROZEN when the whole fleet is grounded — replacing v0.56.0's idle slowdown (`IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24`, ISS-128). **Production rate is now exactly 24x regardless of legacy stored values:** `tickOnce` in `lib/sim/engine.ts` computes `advanceMs = advance ? min(elapsed, MAX_TICK_ELAPSED_MS) × scale : 0` where `scale = FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE` — pre-v0.52.0 saves hold `timeScale: 1000` (the old UI ladder), which is why the live instance still ran at ×1000; only deployments with the env var set honor the stored override written by `POST /api/test/set-timescale` (range [1, 100_000], kept for tests). A frozen tick skips expiry/delivery/repair/spawn entirely and only updates `lastRealTickMs`, so resuming activity never fast-forwards the frozen span. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) holds its anchor value while grounded, so polls re-anchor without any jump. Removed: `IDLE_WORLD_TIME_SCALE` from `lib/worldClock.ts`, and — **separate finding** — `FLIGHT_TEST_HOOKS: "1"` from the production `docker-compose.yml`: it enabled the `/api/test/*` hooks in prod AND (pre-gate) made the live instance honor its stale stored 1000; e2e runs only in CI against `docker-compose.ci.yml`, which sets the var itself. Unchanged on purpose: the ~1 s background tick (started at boot), the test hook endpoint, and the inert legacy columns `paused`/`lastSelectedTimeScale` (nothing reads them; no migration — per #117 saves reset when multiplayer ships). The OpenAPI description of `/api/state` ("while no plane is flying or in repair the clock stays frozen") now matches the code.
|
||||||
|
- **Tests.** Unit: `lib/sim/engine.test.ts` — both rate branches pinned: with `FLIGHT_TEST_HOOKS=1` stubbed (the file's default, like CI) stored overrides are honored; a new production test unsets the var and proves a legacy stored `timeScale: 1000` still advances at exactly ×24. Grounded-fleet tests pin a FROZEN clock (no idle creep, resume-without-catch-up after takeoff, raised timeScale ignored while grounded); describe renamed to "grounded fleet freezes the world clock (#135)" and pins `WORLD_TIME_SCALE = 24`. `lib/useSimClock.test.ts` — the grounded case asserts staying exactly at the anchor value. E2E: `e2e/sim.spec.ts`'s idle test becomes an exact freeze (delta === 0 after ~2.5 s of idleness); `e2e/state.spec.ts`'s restart test tightens from bounded delta to EXACT clock equality across `docker compose restart`; `e2e/right-pane.spec.ts` verifies the frozen clock, then asserts the abandon-penalty preview matches the server exactly (zero drift under a frozen clock — replaces the DRIFT_MS slope tolerance); `e2e/events.spec.ts` relaxes strict clock advance between SSE frames to non-decreasing (a grounded fleet pushes identical-clockMs frames; advance itself is pinned in sim.spec.ts). The activateWorld/deactivateWorld wrappers are unchanged — a repairing plane still means full scale.
|
||||||
|
|
||||||
## v0.61.0 — 2026-09-23 07:12
|
## v0.61.0 — 2026-09-23 07:12
|
||||||
- **Special remote airports (ISS-097).** The world dataset was curated to `large_airport` only, so genuinely empty regions — Antarctica, the high Arctic, Alaska, northern Canada and far islands — had no markers at all. New pure function `isRemote(airports, icao)` in `lib/airports.ts` (reusing the existing `haversineKm` from `lib/geo.ts`): an airport is remote when no other member of the given list lies within `REMOTE_RADIUS_KM = 1000`. The curation in `scripts/build-world-airports.mjs` now runs over ALL fixed-wing rows: every large_airport (unchanged clustering) plus every non-large row that is remote against the kept set; same-station twins (two strips <40 km apart at one base) collapse via the same greedy 40 km clustering. `data/airports_world.csv` grows from 1119 → **1483 rows** (+364 remote airports), including Rothera (EGAR), Halley (EGAH) and Showa skiway (AT25) in Antarctica; Grise Fiord (CYGZ), Alert (CYLT), Ny-Ålesund (ENAS — Svalbard's only marker within 1000 km of a large airport) and Wrangel Island (UHMZ) in the Arctic; Bethel (PABR) and other Alaska strips; Canadian-Arctic smalls; and far islands such as Easter (SCIP), Canton (PCIS), Henderson/Pago Pago (PMDY) and Ascension (FHAW). The only change to a pre-existing row is an upstream `home_link` refresh on CYLW. New airports become contract origins/destinations and map markers unchanged — the most isolated airport in the new set (Easter Island) has its nearest neighbour 2599 km away, well under the 9000 km job cap, so every origin stays flyable; `filterWorldAirports` already accepts small/medium/seaplane types, so no runtime change was needed.
|
- **Special remote airports (ISS-097).** The world dataset was curated to `large_airport` only, so genuinely empty regions — Antarctica, the high Arctic, Alaska, northern Canada and far islands — had no markers at all. New pure function `isRemote(airports, icao)` in `lib/airports.ts` (reusing the existing `haversineKm` from `lib/geo.ts`): an airport is remote when no other member of the given list lies within `REMOTE_RADIUS_KM = 1000`. The curation in `scripts/build-world-airports.mjs` now runs over ALL fixed-wing rows: every large_airport (unchanged clustering) plus every non-large row that is remote against the kept set; same-station twins (two strips <40 km apart at one base) collapse via the same greedy 40 km clustering. `data/airports_world.csv` grows from 1119 → **1483 rows** (+364 remote airports), including Rothera (EGAR), Halley (EGAH) and Showa skiway (AT25) in Antarctica; Grise Fiord (CYGZ), Alert (CYLT), Ny-Ålesund (ENAS — Svalbard's only marker within 1000 km of a large airport) and Wrangel Island (UHMZ) in the Arctic; Bethel (PABR) and other Alaska strips; Canadian-Arctic smalls; and far islands such as Easter (SCIP), Canton (PCIS), Henderson/Pago Pago (PMDY) and Ascension (FHAW). The only change to a pre-existing row is an upstream `home_link` refresh on CYLW. New airports become contract origins/destinations and map markers unchanged — the most isolated airport in the new set (Easter Island) has its nearest neighbour 2599 km away, well under the 9000 km job cap, so every origin stays flyable; `filterWorldAirports` already accepts small/medium/seaplane types, so no runtime change was needed.
|
||||||
- **Tests.** Unit +4 in `lib/airports.test.ts`: a synthetic radius boundary (equator pair at ~945 km vs ~1052 km), real coordinates (Frankfurt↔Amsterdam is not remote; McMurdo vs Christchurch is), an unknown ICAO returning false, and the committed-dataset curation invariant — every non-large row has no large airport within 1000 km and at least one non-large row exists.
|
- **Tests.** Unit +4 in `lib/airports.test.ts`: a synthetic radius boundary (equator pair at ~945 km vs ~1052 km), real coordinates (Frankfurt↔Amsterdam is not remote; McMurdo vs Christchurch is), an unknown ICAO returning false, and the committed-dataset curation invariant — every non-large row has no large airport within 1000 km and at least one non-large row exists.
|
||||||
|
|||||||
+4
-5
@@ -8,11 +8,10 @@ services:
|
|||||||
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
# Game state SQLite (v0.6.0): seeded on first run, must survive rebuilds.
|
||||||
# Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
|
# Dedicated dir — do NOT mount over /app/data (would mask airports_world.csv).
|
||||||
- "./db:/app/db"
|
- "./db:/app/db"
|
||||||
environment:
|
# No environment on purpose (#135, v0.62.0): FLIGHT_TEST_HOOKS must NOT be set in production —
|
||||||
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
|
# it enables the /api/test/* hooks AND makes tickOnce honor legacy stored timeScale values
|
||||||
# /api/test/expire-package so the suites can force known routes/expiries;
|
# (pre-v0.52.0 saves hold 1000). The e2e suite runs only in CI against docker-compose.ci.yml,
|
||||||
# 404 without this var.
|
# which sets the var itself.
|
||||||
FLIGHT_TEST_HOOKS: "1"
|
|
||||||
networks:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+7
-7
@@ -98,10 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is why the expiry
|
||||||
// is why the expiry test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld
|
// test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health
|
||||||
// restores full health and no gate afterwards.
|
// and no gate afterwards.
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, 'plane-starter', { health: 50 });
|
await setHealth(page, 'plane-starter', { health: 50 });
|
||||||
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
||||||
@@ -344,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||||
// repair for the expiry window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k
|
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||||
// repair cost is why the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||||
await activateWorld(page);
|
await activateWorld(page);
|
||||||
try {
|
try {
|
||||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||||
|
|||||||
+3
-1
@@ -63,8 +63,10 @@ test('GET /api/events streams state snapshots over SSE', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
|
||||||
|
// A grounded fleet freezes the clock (#135), so consecutive frames may carry the SAME clockMs —
|
||||||
|
// non-decreasing is what proves real pushes, not replays (advance itself is pinned in sim.spec.ts).
|
||||||
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
expect(clocks.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(clocks[1]).toBeGreaterThan(clocks[0]); // clock advanced between pushes — real updates, not replays
|
expect(clocks[1]).toBeGreaterThanOrEqual(clocks[0]);
|
||||||
|
|
||||||
// ISS-133: no terminal package history in any frame (the DB keeps it forever; frames must not),
|
// ISS-133: no terminal package history in any frame (the DB keeps it forever; frames must not),
|
||||||
// and a bounded frame size even on an instance with long-running history (~597 KB pre-v0.59.0).
|
// and a bounded frame size even on an instance with long-running history (~597 KB pre-v0.59.0).
|
||||||
|
|||||||
+4
-4
@@ -39,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
// is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, STARTER, { health: 50 });
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
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.
|
// 409s on non-available planes — it must be grounded before activateWorld.
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
|
||||||
// A grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in repair for full scale.
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for full scale.
|
||||||
await activateWorld(page);
|
await activateWorld(page);
|
||||||
try {
|
try {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
+4
-13
@@ -274,26 +274,17 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
|
|
||||||
// The abandon button previews the penalty at its 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
|
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||||
// other planes), so the world runs at the idle real-time rate (ISS-128) — verify it actually did,
|
// other planes), so the world clock is FROZEN (#135) — verify it actually is, then the preview must
|
||||||
// then bound the UI's staleness instead of racing a frozen clock.
|
// match the server exactly: a frozen clock means zero drift between any two snapshots.
|
||||||
const c0 = (await getState(page)).simState.clockMs;
|
const c0 = (await getState(page)).simState.clockMs;
|
||||||
const t0 = Date.now();
|
|
||||||
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||||||
const state = await getState(page);
|
const state = await getState(page);
|
||||||
const idleDelta = state.simState.clockMs - c0;
|
expect(state.simState.clockMs - c0, 'the grounded clock must stay frozen (#135)').toBe(0);
|
||||||
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, '');
|
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
|
||||||
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
|
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
|
||||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||||
const slopePerGameMs = (pkg.reward * 0.45) / span; // abandonPenaltyPct's SPAN across the package lifetime
|
expect(Number(penaltyText), 'frozen clock → the preview matches the server exactly').toBe(expectedPenalty);
|
||||||
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.
|
// Fly to the destination through the UI — the button is the destination's name.
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
|||||||
+9
-21
@@ -1,15 +1,14 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// v0.52.0 (ISS-117) fixed world clock against the live origin: a fully grounded fleet slowing to
|
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
|
||||||
// real time instead of freezing (ISS-128), background-tick advance at a raised rate while
|
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
|
||||||
// something is active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml),
|
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
|
||||||
// and market top-up.
|
// market top-up.
|
||||||
|
|
||||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
// 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 MAX_TICK_ELAPSED_MS = 2_000;
|
||||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
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
|
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.
|
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
||||||
const MARKET_TARGET = 10;
|
const MARKET_TARGET = 10;
|
||||||
@@ -48,10 +47,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair
|
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
|
||||||
// cost is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health
|
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||||
// and no gate afterwards.
|
|
||||||
async function activateWorld(page: Page): Promise<void> {
|
async function activateWorld(page: Page): Promise<void> {
|
||||||
await setHealth(page, STARTER, { health: 50 });
|
await setHealth(page, STARTER, { health: 50 });
|
||||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||||
@@ -73,25 +71,15 @@ test.afterEach(async ({ page }) => {
|
|||||||
await setScale(page, WORLD);
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a fully grounded fleet slows the clock to real time instead of freezing it (ISS-128)', async ({ page }) => {
|
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
|
||||||
const s = await getState(page);
|
const s = await getState(page);
|
||||||
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
const c0 = s.simState.clockMs;
|
const c0 = s.simState.clockMs;
|
||||||
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
|
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
|
||||||
const after = await getState(page);
|
const after = await getState(page);
|
||||||
const elapsedReal = Date.now() - t0;
|
|
||||||
|
|
||||||
const delta = after.simState.clockMs - c0;
|
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
|
||||||
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 }) => {
|
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
|
||||||
|
|||||||
+6
-16
@@ -8,9 +8,6 @@ import { test, expect } from '@playwright/test';
|
|||||||
const repoRoot = path.join(__dirname, '..');
|
const repoRoot = path.join(__dirname, '..');
|
||||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
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 {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
@@ -45,9 +42,8 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
// Self-heal to a fully grounded fleet first — while idle the clock advances at real time
|
// Self-heal to a fully grounded fleet first — a grounded fleet freezes the world clock (#135),
|
||||||
// (ISS-128), so the post-restart snapshot is bounded by one clamped tick of downtime plus live
|
// so no tick advances it across the restart and the post-restart clock is exactly unchanged.
|
||||||
// ticks, not byte-for-byte identical.
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const res = await page.request.get('/api/state');
|
const res = await page.request.get('/api/state');
|
||||||
@@ -60,7 +56,6 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
const resBefore = await page.request.get('/api/state');
|
const resBefore = await page.request.get('/api/state');
|
||||||
expect(resBefore.status()).toBe(200);
|
expect(resBefore.status()).toBe(200);
|
||||||
const before = (await resBefore.json()) as StateBody;
|
const before = (await resBefore.json()) as StateBody;
|
||||||
const tBefore = Date.now();
|
|
||||||
|
|
||||||
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
@@ -80,17 +75,12 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
|
|
||||||
expect(after).toBeDefined();
|
expect(after).toBeDefined();
|
||||||
const a = after!;
|
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
|
// 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
|
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, the world
|
||||||
// advances the clock at real time while idle — bounded tolerance instead of exact equality: no
|
// clock is FROZEN (#135) — no tick advances it while the container is down or after restart, so
|
||||||
// ticks run while the container is down, so the first post-restart tick counts the downtime
|
// the post-restart clock is EXACTLY unchanged; a re-seed would reset clockMs backwards instead.
|
||||||
// clamped to MAX_TICK_ELAPSED_MS × IDLE rate (= 1), and every other counted gap lies inside the
|
expect(a.simState.clockMs - before.simState.clockMs).toBe(0);
|
||||||
// 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.cash).toBe(before.account.cash);
|
||||||
expect(a.account.reputation).toBe(before.account.reputation);
|
expect(a.account.reputation).toBe(before.account.reputation);
|
||||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||||
|
|||||||
+40
-20
@@ -16,7 +16,6 @@ import {
|
|||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expirePackages,
|
||||||
finishRepairs,
|
finishRepairs,
|
||||||
IDLE_WORLD_TIME_SCALE,
|
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
reputationGainFor,
|
reputationGainFor,
|
||||||
shouldAdvanceTime,
|
shouldAdvanceTime,
|
||||||
@@ -31,9 +30,9 @@ import {
|
|||||||
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
// 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).
|
// (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
|
// #135: fresh DBs seed a GROUNDED starter, so any test that needs clock advance must put a plane in
|
||||||
// must put a plane in flight first (a grounded fleet only advances at the idle real-time rate) —
|
// flight first (a fully grounded fleet freezes the world) — departureSimTime at the game-clock
|
||||||
// departureSimTime at the game-clock origin keeps its arrival hours away.
|
// origin keeps its arrival hours away.
|
||||||
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
.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 () => {
|
beforeAll(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(T0);
|
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-'));
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ async function freshDb(name: string) {
|
|||||||
describe('tickOnce — clock advance', () => {
|
describe('tickOnce — clock advance', () => {
|
||||||
it('advances the clock by elapsed real ms × timeScale', async () => {
|
it('advances the clock by elapsed real ms × timeScale', async () => {
|
||||||
const inst = await freshDb('scale.db');
|
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
|
vi.setSystemTime(T0 + 1_000); // one real second later
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
||||||
|
|
||||||
@@ -72,30 +75,48 @@ describe('tickOnce — clock advance', () => {
|
|||||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a fully grounded fleet advances at the idle real-time rate, not frozen (ISS-128)', async () => {
|
it('production (no FLIGHT_TEST_HOOKS): a legacy stored timeScale is ignored — always WORLD_TIME_SCALE (#135)', async () => {
|
||||||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle
|
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
|
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
||||||
let sim = await tickOnce(inst);
|
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);
|
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 inst.db.update(schema.simState).set({ timeScale: 60 });
|
||||||
await makeWorldActive(inst);
|
await makeWorldActive(inst);
|
||||||
vi.setSystemTime(T0 + 3_600_500);
|
vi.setSystemTime(T0 + 3_600_500);
|
||||||
sim = await tickOnce(inst);
|
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');
|
const inst = await freshDb('idle-scale.db');
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
|
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 1_000);
|
vi.setSystemTime(T0 + 1_000);
|
||||||
const sim = await tickOnce(inst);
|
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 () => {
|
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)', () => {
|
describe('grounded fleet freezes the world clock (#135)', () => {
|
||||||
it('pins IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24, i.e. exactly real time', () => {
|
it('pins WORLD_TIME_SCALE = 24 — the single fixed world rate (ISS-117)', () => {
|
||||||
expect(IDLE_WORLD_TIME_SCALE).toBe(WORLD_TIME_SCALE / 24);
|
expect(WORLD_TIME_SCALE).toBe(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', () => {
|
it('shouldAdvanceTime: grounded/empty fleets are frozen; any flying or repairing plane advances', () => {
|
||||||
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
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: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
|
||||||
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, 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 });
|
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 () => {
|
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 → idle
|
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → frozen
|
||||||
vi.setSystemTime(T0 + 3_600_000);
|
vi.setSystemTime(T0 + 3_600_000);
|
||||||
|
|
||||||
const sim = await tickOnce(inst);
|
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
|
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+22
-18
@@ -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 { SPECIALS } from '@/lib/game/specials';
|
||||||
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
|
import { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state';
|
||||||
import { broadcast } from '@/lib/sse';
|
import { broadcast } from '@/lib/sse';
|
||||||
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
|
|
||||||
import {
|
import {
|
||||||
generatePackage,
|
generatePackage,
|
||||||
MARKET_TARGET_OPEN_PACKAGES,
|
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
|
// 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
|
// 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
|
// same locked tickOnce path. Each tick advances by (real ms since last tick) × scale, clamped per
|
||||||
// per tick so long absences never fast-forward the game; while no plane is flying or in repair the
|
// 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
|
// world clock stays FROZEN (#135 v0.62.0 — final ISS-117 semantics), and lastRealTickMs always
|
||||||
// lastRealTickMs always keeps tracking real time.
|
// 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
|
// 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
|
// 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.
|
// 2 s × scale.
|
||||||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
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
|
// ISS-117: the fixed world rate — imported for tickOnce, re-exported so server code and tests
|
||||||
// familiar home (full rate while something is active, real-time rate while fully grounded).
|
// have one familiar home.
|
||||||
export { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
|
export { WORLD_TIME_SCALE };
|
||||||
|
|
||||||
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
// 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
|
// 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) ---
|
// --- 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
|
// #135: advance decision — pure, so unit tests feed it synthetic fleets. The world clock runs at
|
||||||
// runs at full scale only while something is happening (at least one plane flying OR in repair); a
|
// full scale only while something is happening (at least one plane flying OR in repair); a fully
|
||||||
// fully grounded fleet slows it to IDLE_WORLD_TIME_SCALE (real time) instead of freezing it, so
|
// grounded fleet freezes it entirely — no expiry/spawn/repair progress until a dispatch or repair
|
||||||
// expiries/spawns keep running until a dispatch or repair starts.
|
// starts again.
|
||||||
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
||||||
let flying = 0;
|
let flying = 0;
|
||||||
let repairing = 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');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
// ISS-128: a fully grounded fleet slows the world to real time (IDLE_WORLD_TIME_SCALE) instead of
|
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress.
|
||||||
// freezing it — packages keep spawning/expiring while idle. An active fleet keeps its full scale,
|
// lastRealTickMs always tracks real time, so resuming activity never fast-forwards the frozen span.
|
||||||
// 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 planeRows = await inst.db.select().from(schema.planes);
|
||||||
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
|
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 elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||||||
const scale = advance ? sim.timeScale : IDLE_WORLD_TIME_SCALE;
|
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
|
||||||
const advanceMs = Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale;
|
|
||||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||||
|
|
||||||
if (advanceMs > 0) {
|
if (advanceMs > 0) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
|
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
|
||||||
import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
|
import { WORLD_TIME_SCALE } from './worldClock';
|
||||||
|
|
||||||
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
|
// 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");
|
// 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, and slows to
|
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE while something is
|
||||||
// real-time × IDLE_WORLD_TIME_SCALE while idle (ISS-128).
|
// flying or in repair, and stays frozen at the anchor value while fully grounded (#135).
|
||||||
|
|
||||||
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
||||||
clockMs: 1_750_000_000_000,
|
clockMs: 1_750_000_000_000,
|
||||||
@@ -25,10 +25,10 @@ describe('advanceSimClock', () => {
|
|||||||
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
|
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advances at the idle real-time rate (IDLE_WORLD_TIME_SCALE) while grounded, not frozen (ISS-128)', () => {
|
it('stays exactly at the anchor value while grounded (frozen, #135)', () => {
|
||||||
const a = anchor({ advancing: false });
|
const a = anchor({ advancing: false });
|
||||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — still the anchor value
|
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — 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
|
expect(advanceSimClock(a, 1_001_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
||||||
|
|||||||
+7
-8
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
|
import { WORLD_TIME_SCALE } from './worldClock';
|
||||||
|
|
||||||
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
|
// 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
|
// 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
|
// (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
|
// 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
|
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
||||||
// fixed WORLD_TIME_SCALE; ISS-128 (v0.56.0): while nothing is happening it slows to
|
// fixed WORLD_TIME_SCALE; #135 (v0.62.0): while nothing is happening it stays FROZEN at the anchor
|
||||||
// IDLE_WORLD_TIME_SCALE (real time) instead of freezing — mirroring the server's idle slowdown,
|
// value — mirroring the server's freeze, so polls re-anchor without any jump.
|
||||||
// so polls re-anchor without a visible jump.
|
|
||||||
|
|
||||||
export interface SimClockAnchor {
|
export interface SimClockAnchor {
|
||||||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||||||
@@ -23,11 +22,11 @@ export interface SimClockAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||||||
// real-time × WORLD_TIME_SCALE; while idle it slows to real-time × IDLE_WORLD_TIME_SCALE (ISS-128).
|
// real-time × WORLD_TIME_SCALE; while fully grounded it stays frozen at the anchor value (#135).
|
||||||
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
||||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||||
const scale = anchor.advancing ? WORLD_TIME_SCALE : IDLE_WORLD_TIME_SCALE;
|
if (!anchor.advancing) return anchor.clockMs; // frozen while everything is grounded (#135)
|
||||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * scale;
|
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
|
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
|
||||||
@@ -43,7 +42,7 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
|
// 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.
|
// is flying or in repair, frozen otherwise (#135). One line on purpose — keep it in sync.
|
||||||
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||||||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||||
|
|||||||
+5
-4
@@ -3,7 +3,8 @@
|
|||||||
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
|
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
|
||||||
export const WORLD_TIME_SCALE = 24;
|
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
|
// #135 (v0.62.0): a fully grounded fleet FREEZES the world clock — shouldAdvanceTime
|
||||||
// (one game-second per real second) so packages keep spawning/expiring and the market stays fresh.
|
// (lib/sim/engine.ts) gates every tick and advanceSimClock (lib/useSimClock.ts) holds its anchor
|
||||||
// Derived from the existing constant: WORLD_TIME_SCALE / 24 = 1, i.e. exactly "real-time".
|
// value, so polls re-anchor without any jump. This replaces the v0.56.0 idle slowdown
|
||||||
export const IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24;
|
// (IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24): final semantics are ISS-117's — frozen when
|
||||||
|
// everything is grounded, full rate otherwise.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.61.0",
|
"version": "0.62.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user