Compare commits

..
1 Commits
Author SHA1 Message Date
kamilkrzywda 71dea3ffb8 v0.62.0: fixed 24x world clock — frozen when fully grounded (#135)
CI / build + unit + e2e (pull_request) Successful in 16m31s
2026-09-23 08:02:15 +00:00
4 changed files with 42 additions and 14 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# 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` (or a FLIGHT_TEST_HOOKS override) 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). `tickOnce` in `lib/sim/engine.ts` now computes `advanceMs = advance ? min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale : 0`: 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`. Unchanged on purpose: the ~1 s background tick (started at boot), the test hook (`POST /api/test/set-timescale`, range [1, 100_000] — already honored by the background tick via `sim.timeScale`), and the inert legacy columns `paused`/`lastSelectedTimeScale` (nothing reads them; no migration). 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` — 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.
- **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.59.0 — 2026-09-23 05:48
- **SSE frames no longer carry package history (ISS-133).** Measured on the live instance first: each `/api/events` frame was compact JSON at ~597 KB, pushed once per real second regardless of time scale (the background tick is a fixed 1 s interval; ×1000 just advances the clock further per frame) — and 99.8 % of it was `packages`: 1,521 rows because terminal packages are never deleted from the DB (expired 564, delivered 528, ignored 386, abandoned 31), while only 12 were `open`. No component ever reads a package with a terminal status — the market list renders `open`, and on-board cargo is `loaded`/`expired` (`LeftPane`, `cargoWeightFor`) — so the history was pure re-sent weight. New `sseFrame(snapshot)` in `lib/game/state.ts` projects a snapshot down to the client-rendered statuses (`SSE_PACKAGE_STATUSES = open|loaded|expired`); both SSE paths use it — the broadcast in `lib/sim/engine.ts` and the initial frame in `app/api/events/route.ts`, so a connect never costs one full-history frame. Live payload drops ~597 KB → ~10 KB per frame (≈ 60×), bounded forever by fleet/market size instead of growing with play time. `/api/state` and action responses keep the FULL package set unchanged, so every e2e oracle that asserts `delivered`/`abandoned` transitions via state lookups still works; the polling fallback stays a strict superset of what SSE pushes. Deliberately NOT done: hash-skip broadcasts (the snapshot's `clockMs` advances on every tick even while idle — `advanceMs = min(elapsed, 2000 ms) × scale ≥ 1 s` — so no two frames are ever equal), throttling (already exactly 1 Hz), and a diff/patch engine.
+4 -5
View File
@@ -8,11 +8,10 @@ services:
# 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).
- "./db:/app/db"
environment:
# e2e-only affordance (v0.7.0): enables POST /api/test/create-package and
# /api/test/expire-package so the suites can force known routes/expiries;
# 404 without this var.
FLIGHT_TEST_HOOKS: "1"
# No environment on purpose (#135, v0.62.0): FLIGHT_TEST_HOOKS must NOT be set in production —
# it enables the /api/test/* hooks AND makes tickOnce honor legacy stored timeScale values
# (pre-v0.52.0 saves hold 1000). The e2e suite runs only in CI against docker-compose.ci.yml,
# which sets the var itself.
networks:
- dev_net
labels:
+22
View File
@@ -45,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 });
});
@@ -71,6 +75,24 @@ describe('tickOnce — clock advance', () => {
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
});
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
+14 -7
View File
@@ -22,10 +22,12 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
// same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
// per tick so long absences never fast-forward the game; while no plane is flying or in repair the
// same locked tickOnce path. Each tick advances by (real ms since last tick) × scale, clamped per
// tick so long absences never fast-forward the game; while no plane is flying or in repair the
// world clock stays FROZEN (#135 v0.62.0 — final ISS-117 semantics), and lastRealTickMs always
// keeps tracking real time, so resuming activity never fast-forwards the frozen span.
// keeps tracking real time, so resuming activity never fast-forwards the frozen span. Scale is the
// fixed WORLD_TIME_SCALE in production; only FLIGHT_TEST_HOOKS deployments honor sim.timeScale
// (the test-hook override) — legacy saves may hold stale values (#135).
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
// both the client poll interval (~1 s) and the background-tick interval, so active play advances
@@ -33,8 +35,10 @@ 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: the fixed world rate — imported for tickOnce, re-exported so server code and tests
// have one familiar home.
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
export { WORLD_TIME_SCALE };
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
@@ -84,12 +88,15 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const nowMs = Date.now();
// #135: a fully grounded fleet freezes the world — no advance, no expiry/spawn/repair progress.
// 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 frozen span.
const planeRows = await inst.db.select().from(schema.planes);
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
// #135 (v0.62.0): production ALWAYS runs at the fixed world rate — legacy saves may hold a stale
// timeScale (e.g. 1000 from pre-v0.52.0 UI controls, or an old e2e run against the live instance).
// Only FLIGHT_TEST_HOOKS deployments honor sim.timeScale, which POST /api/test/set-timescale writes.
const scale = process.env.FLIGHT_TEST_HOOKS === '1' ? sim.timeScale : WORLD_TIME_SCALE;
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale : 0;
const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) {