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.tstickOnce, 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Root cause
v0.62.0's frozen clock (
#135) gated every package-creation path intickOncebehindadvanceMs > 0, while removal paths were not gated:ignorePackageruns 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 belowMARKET_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.tstickOnce, only the two spawn calls moved OUT of theif (advanceMs > 0)block:spawnOpenPackages+spawnCoveragePackagesnow run on every tick, including frozen ones, passing the current (possibly-frozen)clockMs.expirePackages,deliverPackagesandfinishRepairsstay 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
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 BOTHsim.clockMsunchanged (freeze preserved) AND open count back at ≥ target with ≥1 open package originating at the grounded starter's airport (EDDF coverage).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 callingPOST /api/packages/[id]/ignorefor 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), thenexpect.polls that the open count from/api/stateis above 0 within ~5 s of real time while assertingsimState.clockMsstays 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 onadvanceMs > 0, and ignore requires a user action), and every/api/stateread 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 advanceclockMs; they only spawn, which is a no-op at capacity.e2e/right-pane.spec.ts:279-287(exact penalty preview under frozen clock) andlib/useSimClock.test.ts:28-31: unaffected — the server clock still never moves while grounded, and the client extrapolation is a pure function of theadvancingflag.Files
lib/sim/engine.tslib/sim/engine.test.tse2e/sim.spec.tspackage.jsonCHANGELOG.mdLocal verification:
npx tsc --noEmitclean,npx vitest run387/387 passed (33 files),npx playwright test --listcompiles and lists all 63 tests in 16 files.