v0.64.0 — Market keeps topping up while the fleet is grounded (#135 follow-up) #139

Merged
kamilkrzywda merged 1 commits from fix/frozen-market-topup into master 2026-09-23 12:22:06 +02:00
Owner

Root cause

v0.62.0's frozen clock (#135) gated every package-creation path in tickOnce behind advanceMs > 0, while removal paths were not gated: ignorePackage runs on any request regardless of advance. With a fully grounded fleet the clock is frozen, so no tick ever spawned replacements — repeated ignores could drain the market below MARKET_TARGET_OPEN_PACKAGES = 10 (down to zero) and no code path ever created new jobs again. A player with a grounded fleet and an empty market was soft-locked forever.

Fix

In lib/sim/engine.ts tickOnce, only the two spawn calls moved OUT of the if (advanceMs > 0) block: spawnOpenPackages + spawnCoveragePackages now run on every tick, including frozen ones, passing the current (possibly-frozen) clockMs. expirePackages, deliverPackages and finishRepairs stay inside the gate. Both spawns were already idempotent no-ops when satisfied (count-gated at target / set-gated by uncovered grounded airports), so advancing ticks behave exactly as before, and the clock stays exactly frozen while grounded — only new packages appear, created at game time. No schema change, no migration, no other engine behavior touched.

Tests

  • Unit (lib/sim/engine.test.ts): "a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)" — fully grounded fleet (advance=false), open market drained to zero via direct DB writes, one tick → asserts BOTH sim.clockMs unchanged (freeze preserved) AND open count back at ≥ target with ≥1 open package originating at the grounded starter's airport (EDDF coverage).
  • E2E (e2e/sim.spec.ts): "a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)" — self-heals to a fully grounded fleet, drains by calling POST /api/packages/[id]/ignore for every open package in repeated passes (pre-fix this reached zero and stayed there; post-fix each request's tick re-tops-up before its removal, so it can't), then expect.polls that the open count from /api/state is above 0 within ~5 s of real time while asserting simState.clockMs stays EXACTLY equal to its pre-drain value and no plane took off — pinning both starvation-fixed and clock-still-frozen.

No regression in the exact-freeze pins (verified by trace)

  • e2e/state.spec.ts:42-88 (EXACT clock equality across container restart + byte-identical package ids): while frozen nothing removes packages (expiry/delivery are gated on advanceMs > 0, and ignore requires a user action), and every /api/state read ticks first — topping the market back to target before snapshotting — so both snapshots see the same package set.
  • e2e/sim.spec.ts:74-83 (delta === 0): unaffected — frozen ticks still never advance clockMs; they only spawn, which is a no-op at capacity.
  • e2e/right-pane.spec.ts:279-287 (exact penalty preview under frozen clock) and lib/useSimClock.test.ts:28-31: unaffected — the server clock still never moves while grounded, and the client extrapolation is a pure function of the advancing flag.

Files

File Change
lib/sim/engine.ts moved 2 spawn calls out of the advance gate (+ comment updates)
lib/sim/engine.test.ts +1 unit test (frozen tick tops up market, clock frozen)
e2e/sim.spec.ts +1 e2e test (ignore-drain can't empty market while grounded/frozen)
package.json 0.63.0 → 0.64.0
CHANGELOG.md v0.64.0 entry at top

Local verification: npx tsc --noEmit clean, npx vitest run 387/387 passed (33 files), npx playwright test --list compiles and lists all 63 tests in 16 files.

## Root cause v0.62.0's frozen clock (`#135`) gated **every** package-creation path in `tickOnce` behind `advanceMs > 0`, while removal paths were not gated: `ignorePackage` runs on any request regardless of advance. With a fully grounded fleet the clock is frozen, so no tick ever spawned replacements — repeated ignores could drain the market below `MARKET_TARGET_OPEN_PACKAGES = 10` (down to zero) and **no code path ever created new jobs again**. A player with a grounded fleet and an empty market was soft-locked forever. ## Fix In `lib/sim/engine.ts` `tickOnce`, only the two spawn calls moved OUT of the `if (advanceMs > 0)` block: `spawnOpenPackages` + `spawnCoveragePackages` now run on **every** tick, including frozen ones, passing the current (possibly-frozen) `clockMs`. `expirePackages`, `deliverPackages` and `finishRepairs` stay inside the gate. Both spawns were already idempotent no-ops when satisfied (count-gated at target / set-gated by uncovered grounded airports), so advancing ticks behave exactly as before, and the clock stays **exactly** frozen while grounded — only new packages appear, created at game time. No schema change, no migration, no other engine behavior touched. ## Tests - **Unit** (`lib/sim/engine.test.ts`): "a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)" — fully grounded fleet (advance=false), open market drained to zero via direct DB writes, one tick → asserts BOTH `sim.clockMs` unchanged (freeze preserved) AND open count back at ≥ target with ≥1 open package originating at the grounded starter's airport (EDDF coverage). - **E2E** (`e2e/sim.spec.ts`): "a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)" — self-heals to a fully grounded fleet, drains by calling `POST /api/packages/[id]/ignore` for every open package in repeated passes (pre-fix this reached zero and stayed there; post-fix each request's tick re-tops-up before its removal, so it can't), then `expect.poll`s that the open count from `/api/state` is above 0 within ~5 s of real time while asserting `simState.clockMs` stays EXACTLY equal to its pre-drain value and no plane took off — pinning both starvation-fixed and clock-still-frozen. ## No regression in the exact-freeze pins (verified by trace) - `e2e/state.spec.ts:42-88` (EXACT clock equality across container restart + byte-identical package ids): while frozen nothing removes packages (expiry/delivery are gated on `advanceMs > 0`, and ignore requires a user action), and every `/api/state` read ticks first — topping the market back to target before snapshotting — so both snapshots see the same package set. - `e2e/sim.spec.ts:74-83` (delta === 0): unaffected — frozen ticks still never advance `clockMs`; they only spawn, which is a no-op at capacity. - `e2e/right-pane.spec.ts:279-287` (exact penalty preview under frozen clock) and `lib/useSimClock.test.ts:28-31`: unaffected — the server clock still never moves while grounded, and the client extrapolation is a pure function of the `advancing` flag. ## Files | File | Change | |---|---| | `lib/sim/engine.ts` | moved 2 spawn calls out of the advance gate (+ comment updates) | | `lib/sim/engine.test.ts` | +1 unit test (frozen tick tops up market, clock frozen) | | `e2e/sim.spec.ts` | +1 e2e test (ignore-drain can't empty market while grounded/frozen) | | `package.json` | 0.63.0 → 0.64.0 | | `CHANGELOG.md` | v0.64.0 entry at top | Local verification: `npx tsc --noEmit` clean, `npx vitest run` 387/387 passed (33 files), `npx playwright test --list` compiles and lists all 63 tests in 16 files.
kamilkrzywda added 1 commit 2026-09-23 12:07:53 +02:00
kamilkrzywda scheduled this pull request to auto merge when all checks succeed 2026-09-23 12:09:34 +02:00
kamilkrzywda merged commit 9c2c64fe89 into master 2026-09-23 12:22:06 +02:00
Sign in to join this conversation.