From 12d900b22aa926c4559a87aa251dde741d5b4917 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 13:09:49 +0000 Subject: [PATCH 1/3] =?UTF-8?q?v0.65.0:=20per-airport=20job=20pools=20with?= =?UTF-8?q?=20real-time=20refresh=20=E2=80=94=20open=20jobs=20spawn=20per?= =?UTF-8?q?=20pool=20airport=20(grounded/flying/queued=20locations)=20in?= =?UTF-8?q?=20a=20fixed=20POOL=5FBANDS=20composition,=20expire=20on=20the?= =?UTF-8?q?=20wall=20clock=20after=20JOB=5FLIFETIME=5FMS=20even=20while=20?= =?UTF-8?q?the=20world=20is=20frozen,=20and=20refill=20taken=20slots=20aft?= =?UTF-8?q?er=20a=20per-band=20cooldown;=20/api/state=20+=20SSE=20filter?= =?UTF-8?q?=20open=20jobs=20to=20pool=20origins=20and=20expose=20expiresRe?= =?UTF-8?q?alMs=20for=20the=20right-pane=20countdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 + app/api/packages/[id]/abandon/route.test.ts | 2 +- app/api/packages/[id]/load/route.test.ts | 6 +- app/api/test/expire-package/route.ts | 39 ++-- components/RightPane.tsx | 4 +- components/right-pane.test.ts | 1 + docker-compose.ci.yml | 5 + e2e/expiry.spec.ts | 20 ++ e2e/hover.spec.ts | 15 +- e2e/right-pane.spec.ts | 16 +- e2e/sim.spec.ts | 112 +++++----- lib/db/client.ts | 13 +- lib/db/schema.ts | 8 + lib/deriveEvents.test.ts | 1 + lib/game/actions.ts | 10 +- lib/game/seed.test.ts | 59 +++--- lib/game/seed.ts | 107 ++++++++-- lib/game/state.test.ts | 1 + lib/game/state.ts | 58 +++-- lib/openapi.test.ts | 1 + lib/openapi.ts | 2 + lib/sim/engine.test.ts | 221 +++++++++++--------- lib/sim/engine.ts | 190 ++++++++--------- package.json | 2 +- 24 files changed, 556 insertions(+), 344 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e4b64..56ca245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.65.0 — 2026-09-23 12:58 +- **Per-airport job pools with real-time refresh.** The market was a global bag of jobs (top-up to `MARKET_TARGET_OPEN_PACKAGES = 10` + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' `locationIcao`, flying planes' `destIcao` and queued-leg ICAOs (repairing planes excluded; an all-repairing fleet has no pool, and orphaned pools simply expire out). Each pool airport holds exactly `POOL_TARGET_PER_AIRPORT = 10` open jobs in a fixed weight composition (`POOL_BANDS`: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. `spawnPoolPackages` in `lib/sim/engine.ts` replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new `generatePoolPackage` helper in `lib/game/seed.ts`, so they are indistinguishable — `buildSeed` now seeds a full pool at the starter's home hub, and `PACKAGE_COUNT_MIN/MAX` + `MARKET_TARGET_OPEN_PACKAGES` are retired. +- **Open jobs live on the wall clock.** New `created_real_ms INTEGER NOT NULL DEFAULT 0` column (guarded ALTER backfilled with `Date.now()` for existing rows): an open job expires `JOB_LIFETIME_MS = 1 h` after creation regardless of time scale or fleet state — checked every tick OUTSIDE the advance gate, so a paused world still refreshes its board. Loaded jobs keep their sim-clock deadline logic unchanged (delivery check, expiry floor, abandon penalty all untouched). Taken slots (loaded OR ignored) stamp `slot_free_at_real_ms` and hold their pool slot for a refill cooldown — `REFILL_DELAY_MS = 15 min`, or `SMALL_REFILL_DELAY_MS = 1 min` for the <4 t band — so the board visibly refreshes instead of instantly refilling; expired/delivered/abandoned never block. ponytail: one shared per-airport cooldown (the max stamp) rather than a per-band table, add when per-band refill timing matters. +- **Visibility + UI.** `/api/state` and SSE now filter open packages to pool origins (`poolAirports` in `lib/game/state.ts`; all non-open statuses pass through unchanged), so the right pane only ever shows jobs you can take — no disabled Load buttons for foreign-origin work. `PackageState` gains `expiresRealMs` (open → `createdRealMs + JOB_LIFETIME_MS`, else null; OpenAPI updated) and the open-job row renders a wall-clock countdown via the existing pure `formatDeadlineRelative` formatter (no new timer — the ~1 s poll re-renders anyway); loaded jobs keep their sim-deadline display. +- **Test hooks.** `/api/test/expire-package` rewritten for the wall clock: `{packageId}` ages an OPEN job's `createdRealMs` past its lifetime (`HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000`) and runs a tick, so it comes back already expired (200; unknown/non-open ids 404); no body creates one already-aged hub job (201). CI env: `docker-compose.ci.yml` sets `FLIGHT_JOB_REFILL_MS=5000` + `FLIGHT_JOB_SMALL_REFILL_MS=2000` under the existing `FLIGHT_TEST_HOOKS=1` gate — the lifetime stays 1 h everywhere on purpose, so `state.spec.ts`'s mid-suite container restart can't race a whole board expiring. +- **Tests.** Unit: `lib/sim/engine.test.ts` rewritten around the new functions — `expireLoadedPackages (sim clock)` and `expireOpenPackages (v0.65.0 wall clock)` suites (both with inclusive-boundary edges), the frozen-top-up test now asserts exactly 10 open jobs all at EDDF, and a six-test `spawnPoolPackages` suite pins band composition, determinism under an injected RNG + wall clock, pool membership for flying/queued airports (departure airport excluded while its plane is airborne), the taken-slot cooldown ladder, and the full tickOnce pipeline. `lib/game/seed.test.ts`: buildSeed now asserts the exact hub-pool composition (10 jobs, band counts, weights in-band, `createdRealMs` = seed moment) plus a "board always offers jobs beyond the starter" pin; the replay test mirrors `generatePoolPackage`; five full-object equality tests pin `createdRealMs` to avoid millisecond-boundary flakes. E2E: `e2e/sim.spec.ts`'s expiry tests are wall-clock now (hook ages + ticks, then polls the pool back to target — no scale or clock activation); `e2e/right-pane.spec.ts` replaces the LEMD Load-disabled test with an absence assertion (a non-pool-origin open job never reaches `/api/state`); `e2e/hover.spec.ts`'s antimeridian pair moves NZAA→NFTF to EDDF→NFTF (same ±180 eastward crossing, sampled maxJump 359.98) because the hook must create jobs at a pool airport; `e2e/expiry.spec.ts` pins the new hook contract (aged job expired by response time, unknown id 404, already-expired 404). + ## v0.64.0 — 2026-09-23 10:06 - **Market keeps topping up while the fleet is grounded (#135 follow-up).** Root cause of a soft-lock introduced by v0.62.0's frozen clock: in `tickOnce` (`lib/sim/engine.ts`) every package-CREATION path (market top-up + coverage spawn) sat behind the same `advanceMs > 0` gate as expiry/delivery/repairs, while removal paths did not — `ignorePackage` runs on any request regardless of advance. A fully grounded fleet therefore froze the clock AND stopped all spawning, so 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 stuck forever. Fix: only the two spawn calls moved OUT of the gate — `spawnOpenPackages` + `spawnCoveragePackages` now run on EVERY tick, including frozen ones, passing the current (possibly frozen) `clockMs`; `expirePackages`, `deliverPackages` and `finishRepairs` stay inside it. 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 that `sim.clockMs` is unchanged (freeze preserved) AND that the open count is back at ≥ `MARKET_TARGET_OPEN_PACKAGES` 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. diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts index 57026d4..eaa5b06 100644 --- a/app/api/packages/[id]/abandon/route.test.ts +++ b/app/api/packages/[id]/abandon/route.test.ts @@ -57,7 +57,7 @@ describe('POST /api/packages/:id/abandon', () => { const { db } = await getGameDb(); await db.insert(schema.packages).values({ id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, - deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), + deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — stay open past the pre-action tick }); const res = await post('p-open'); diff --git a/app/api/packages/[id]/load/route.test.ts b/app/api/packages/[id]/load/route.test.ts index dc715e2..faccd1c 100644 --- a/app/api/packages/[id]/load/route.test.ts +++ b/app/api/packages/[id]/load/route.test.ts @@ -45,7 +45,7 @@ describe('POST /api/packages/:id/load', () => { const { db } = await getGameDb(); await db.insert(schema.packages).values({ id: 'p-ghost-plane', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, - deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), + deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it }); const res = await post('p-ghost-plane', { planeId: 'ghost' }); @@ -57,7 +57,7 @@ describe('POST /api/packages/:id/load', () => { // The seeded starter sits at EDDF — a package from LEMD cannot be loaded there. await db.insert(schema.packages).values({ id: 'p-elsewhere', originIcao: 'LEMD', destIcao: 'EDDF', reward: 1, cargoType: 'Mail', cargoCount: 1, - deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), + deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it }); const res = await post('p-elsewhere', { planeId: 'plane-starter' }); @@ -82,7 +82,7 @@ describe('POST /api/packages/:id/load', () => { // A known open package originating where the starter actually sits (EDDF). await db.insert(schema.packages).values({ id: 'p-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4, - deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), + deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — fresh wall-clock stamp or the pre-action tick expires it }); const res = await post('p-home', { planeId: 'plane-starter' }); diff --git a/app/api/test/expire-package/route.ts b/app/api/test/expire-package/route.ts index 3aeb936..fb2595c 100644 --- a/app/api/test/expire-package/route.ts +++ b/app/api/test/expire-package/route.ts @@ -3,21 +3,22 @@ import { eq } from 'drizzle-orm'; import { getGameDb, persist } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; -import { generatePackage, mulberry32 } from '@/lib/game/seed'; +import { generatePackage, HUB_ICAO, JOB_LIFETIME_MS, mulberry32 } from '@/lib/game/seed'; import { tickNow } from '@/lib/sim/engine'; -// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0) — lets the e2e suite -// observe package expiry without waiting hours of game time. Disabled unless -// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance -// only; every other deployment 404s here. +// TEST-ONLY hook (v0.7.0, adapted to packages in v0.12.0; reworked for wall-clock expiry in v0.65.0) — +// lets the e2e suite observe open-job expiry without waiting an hour of real time. Disabled unless +// FLIGHT_TEST_HOOKS=1, which is set in docker-compose.yml for the e2e instance only; every other +// deployment 404s here. // // POST /api/test/expire-package // body { packageId?: string } -// - with packageId: moves that OPEN package's deadline to now + HOOK_DEADLINE_MS -// (game time) so the next fast tick expires it; 404 if unknown/not open. -// - without: creates a fresh open package with the same short deadline. +// - with packageId: ages that OPEN package's createdRealMs to HOOK_AGE_MS in the past so the next +// tick (run immediately via tickNow) flips it to 'expired'; 404 if unknown/not open. +// - without: creates a fresh open package at the home hub, already aged past its lifetime — visible +// while open (the hub is always in the pool), expired by the time the caller polls /api/state. -const HOOK_DEADLINE_MS = 10_000; // game ms past the current clock (≈ 0.01 s real at the fastest 1000×) +const HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000; // comfortably past the job lifetime export async function POST(req: Request) { if (process.env.FLIGHT_TEST_HOOKS !== '1') { @@ -41,8 +42,6 @@ export async function POST(req: Request) { try { const inst = await getGameDb(); - const sim = await tickNow(); // clock is current before we set the deadline - const deadlineSimTime = Math.round(sim.clockMs + HOOK_DEADLINE_MS); if (typeof packageId === 'string') { const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); @@ -50,8 +49,11 @@ export async function POST(req: Request) { if (!row || row.status !== 'open') { return Response.json({ error: 'package not found or not open' }, { status: 404 }); } - await inst.db.update(schema.packages).set({ deadlineSimTime }).where(eq(schema.packages.id, packageId)); + await inst.db.update(schema.packages).set({ createdRealMs: Date.now() - HOOK_AGE_MS }).where(eq(schema.packages.id, packageId)); await persist(inst); + // AFTER the mutation on purpose: tickNow runs a full tickOnce, so expireOpenPackages flips the + // aged row to 'expired' within this same call — callers can poll /api/state immediately. + await tickNow(); const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)))[0]; return Response.json(updated ?? row); } @@ -64,11 +66,18 @@ export async function POST(req: Request) { lon: f.properties.lon, })); const rng = mulberry32(randomInt(0, 2 ** 31)); - // special: null — the hook never rolls a special status (v0.37.0). - const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null }); + // nowMs anchors createdSimTime/deadlineSimTime to the GAME clock (ISS-117) — only createdRealMs + // is real time. special: null — the hook never rolls a special status (v0.37.0). Origin forced to + // the home hub so the job is visible while open; already aged past its lifetime, it expires on + // the tick below. + const sim = (await inst.db.select().from(schema.simState))[0]; + if (!sim) throw new Error('sim_state row missing'); + const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, originIcao: HUB_ICAO, special: null, createdRealMs: Date.now() - HOOK_AGE_MS }); await inst.db.insert(schema.packages).values(created); await persist(inst); - return Response.json(created, { status: 201 }); + await tickNow(); + const updated = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, created.id)))[0]; + return Response.json(updated ?? created, { status: 201 }); } catch (err) { console.error('test hook failed', err); return Response.json({ error: 'test hook failed' }, { status: 500 }); diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 6e39829..c887e15 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -238,8 +238,10 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr {formatKm(pkg.distanceKm)} {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */} {formatCargo(pkg.cargoType, pkg.weightKg)} + {/* v0.65.0: open jobs expire on the WALL clock — live countdown from expiresRealMs; + re-renders every poll tick (~1 s) keep it current without a timer. */} - {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} + {formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())} diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index 113e7db..ece48bb 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -47,6 +47,7 @@ function pkg(over: Partial & Pick): PackageSta status: 'open', planeId: null, createdSimTime: 0, + expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed ...over, }; } diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 3e89c74..c6faa0d 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -14,6 +14,11 @@ services: image: flight-ops-ci environment: FLIGHT_TEST_HOOKS: "1" + # v0.65.0: shorten the pool refill cooldowns to seconds so e2e can watch taken slots come back; + # the job LIFETIME stays at its 1 h default on purpose — state.spec.ts restarts the container + # mid-suite and must not race a whole board expiring (the expire-package hook ages jobs instead). + FLIGHT_JOB_REFILL_MS: "5000" + FLIGHT_JOB_SMALL_REFILL_MS: "2000" volumes: # Self-hosted map-asset pack (styles/fonts/sprites): the workflow downloads it into ./public # before build; absent/empty → app falls back to remote OFM styles. Mirrors docker-compose.yml's diff --git a/e2e/expiry.spec.ts b/e2e/expiry.spec.ts index b6f4a33..e170c0a 100644 --- a/e2e/expiry.spec.ts +++ b/e2e/expiry.spec.ts @@ -70,3 +70,23 @@ test('long-leg priority packages always expire after travel time at max speed + } } }); + +// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire +// regardless of time scale or fleet state. The age hook (FLIGHT_TEST_HOOKS=1) stamps a job past its +// lifetime and runs a tick, so expiry is observable in one round-trip; self-contained like the test +// above (the assertion lives in the hook response, no /api/state polling). +test('an open job aged past its wall-clock lifetime expires on the next tick', async ({ page }) => { + const pkg = await createPriorityPackage(page, FROM, TO); + + const res = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } }); + expect(res.status()).toBe(200); + const aged = (await res.json()) as Pkg & { status: string }; + expect(aged.id).toBe(pkg.id); + expect(aged.status, 'the hook runs a tick after aging the job, so it is expired by response time').toBe('expired'); + + // Unknown ids and non-open jobs still 404. + const missing = await page.request.post('/api/test/expire-package', { data: { packageId: 'no-such-id' } }); + expect(missing.status()).toBe(404); + const alreadyExpired = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } }); + expect(alreadyExpired.status()).toBe(404); // expired is not open — the hook only ages live jobs +}); diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index a22c8c9..a7c1af6 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -361,14 +361,17 @@ test('hovering an open package highlights its endpoint airports', async ({ page }).toBe(0); }); -// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great -// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and -// MapLibre connected them with a chord across the whole map; now the trace is one continuous line. +// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD +// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair). +// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a +// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to +// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at +// the hub; same crossing, no relocation needed. test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); - const pkg = await createPackage(page, 'NZAA', 'NFTF'); + const pkg = await createPackage(page, HUB, 'NFTF'); await page.goto('/'); await waitForAirportCount(page); @@ -377,10 +380,10 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); // fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. - await fitToAirports(page, ['NZAA', 'NFTF']); + await fitToAirports(page, [HUB, 'NFTF']); await row.hover(); - await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120) + await expectHoverRoute(page, HUB, 'NFTF'); // endpoint check is mod-360 aware now (ISS-120) // The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord. await expect.poll(async () => { diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 548c3a2..ba74725 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -35,6 +35,8 @@ interface PlaneState { interface PackageState { id: string; + originIcao: string; // v0.65.0 — pool visibility assertions need the origin + destIcao: string; originName: string; destName: string; reward: number; @@ -248,15 +250,19 @@ test('a priority package shows its special badge on the open-package row', async await expect(badge).toHaveText('Priority'); }); -test('Load is disabled when no plane sits at the package origin', async ({ page }) => { +test('open jobs only appear where a plane can take them — foreign-origin jobs are hidden', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run - await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here + await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF} const pkg = await createPackage(page, 'LEMD', HUB); + expect(pkg.originIcao).toBe('LEMD'); + // v0.65.0: open jobs are only visible from airports where a plane can take them (the pool). LEMD is + // not in the pool while the starter sits at EDDF, so the job exists server-side but never reaches + // /api/state — no row and no disabled Load button to speak of. + expect((await getState(page)).packages.some((p) => p.id === pkg.id), 'a non-pool-origin open job must not appear in /api/state').toBe(false); + await page.goto('/'); - const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); - await expect(row).toBeVisible({ timeout: 15_000 }); - await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled(); + await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 }); }); test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => { diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index c602301..9e24f12 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -1,4 +1,8 @@ import { test, expect, type Page } from '@playwright/test'; +// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it +// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one +// pool, at EDDF. +import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed'; // v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully // grounded fleet freezes the clock, background-tick advance at a raised rate while something is @@ -10,8 +14,6 @@ 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 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; const STARTER = 'plane-starter'; interface StateBody { @@ -101,7 +103,9 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t // Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never // spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every // request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to - // bound runtime either way (a zero-open snapshot would mean the bug is back). + // bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring + // starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board + // comes back a few seconds after the drain, not instantly. for (let pass = 0; pass < 3; pass++) { const s = await getState(page); const open = s.packages.filter((p) => p.status === 'open'); @@ -111,14 +115,15 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t } } - // The ~1 s background tick must top the market back up within ~5 s of real time… + // The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real + // time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)… let topped: StateBody | undefined; await expect .poll(async () => { const body = await getState(page); if (body.packages.some((p) => p.status === 'open')) topped = body; return !!topped; - }, { timeout: 5_000, message: 'waiting for the market to refill while grounded' }) + }, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' }) .toBe(true); const after = topped!; @@ -146,69 +151,50 @@ test('timeScale 1000 advances the clock by roughly real-time × 1000 between pol } }); -test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => { - test.setTimeout(60_000); - await activateWorld(page); - try { - const before = await getState(page); - const victim = before.packages.find((p) => p.status === 'open'); - expect(victim).toBeDefined(); // steady state always has open packages +test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => { + const before = await getState(page); + const victim = before.packages.find((p) => p.status === 'open'); + expect(victim).toBeDefined(); // steady state always has open packages - // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open - // package's deadline ~10 game-seconds out so expiry is observable at scale 1000. - const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } }); - expect(hook.status()).toBe(200); - const moved = (await hook.json()) as { id: string; deadlineSimTime: number }; - expect(moved.id).toBe(victim!.id); + // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its + // lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no + // scale or clock activation needed. + const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } }); + expect(hook.status()).toBe(200); + const moved = (await hook.json()) as { id: string; status: string }; + expect(moved.id).toBe(victim!.id); + expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired'); - await setScale(page, SCALE); - - let expiredBody: StateBody | undefined; - await expect - .poll(async () => { - const body = await getState(page); - if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body; - return !!expiredBody; - }, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' }) - .toBe(true); - - const after = expiredBody!; - expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired'); - // Expired packages leave the available set… - expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false); - // …and spawn tops the market back up to the target — never below it. - const openCount = after.packages.filter((p) => p.status === 'open').length; - expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); - } finally { - await deactivateWorld(page); - } + // Expired jobs leave the market… + let after: StateBody | undefined; + await expect + .poll(async () => { + const body = await getState(page); + if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body; + return !!after; + }, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' }) + .toBe(true); + // …and the spawner refills the pool back up — never below its target. + expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); + expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market }); -test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => { - test.setTimeout(60_000); - await activateWorld(page); - try { - const created = await page.request.post('/api/test/expire-package'); - expect(created.status()).toBe(201); - const c = (await created.json()) as { id: string; status: string }; - expect(c.status).toBe('open'); +test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => { + const created = await page.request.post('/api/test/expire-package'); + expect(created.status()).toBe(201); + const c = (await created.json()) as { id: string; status: string }; + // The hook ages the job before its final tick, so it never even appears open in /api/state. + expect(c.status).toBe('expired'); - await setScale(page, SCALE); - - let expiredBody: StateBody | undefined; - await expect - .poll(async () => { - const body = await getState(page); - if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body; - return !!expiredBody; - }, { timeout: 30_000, message: 'waiting for the hook-created package to expire' }) - .toBe(true); - - const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length; - expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too - } finally { - await deactivateWorld(page); - } + let after: StateBody | undefined; + await expect + .poll(async () => { + const body = await getState(page); + if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body; + return !!after; + }, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' }) + .toBe(true); + expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too }); test('POST /api/test/set-timescale validates its input', async ({ page }) => { diff --git a/lib/db/client.ts b/lib/db/client.ts index 2ea1ebf..8ba899f 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS packages ( deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, - created_sim_time INTEGER NOT NULL + created_sim_time INTEGER NOT NULL, + created_real_ms INTEGER NOT NULL DEFAULT 0, + slot_free_at_real_ms INTEGER ); CREATE TABLE IF NOT EXISTS account ( cash REAL NOT NULL, @@ -98,6 +100,15 @@ function migrateColumns(raw: RawSqlJsDatabase): void { if (!cols('planes').has('repair_until_sim_time')) { raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER'); } + // v0.65.0: real-time creation stamp — legacy rows backfill to NOW so they get a fresh full lifetime + // instead of expiring on the first tick (created_real_ms = 0 is far in the past). + if (!cols('packages').has('created_real_ms')) { + raw.exec(`ALTER TABLE packages ADD COLUMN created_real_ms INTEGER NOT NULL DEFAULT ${Date.now()}`); + } + // v0.65.0: per-(origin, band) refill cooldown — nullable; legacy rows stay NULL = no cooldown. + if (!cols('packages').has('slot_free_at_real_ms')) { + raw.exec('ALTER TABLE packages ADD COLUMN slot_free_at_real_ms INTEGER'); + } // v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN // current time_scale so a legacy DB resumes at the speed it was running, not the default. if (!cols('sim_state').has('last_selected_time_scale')) { diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1c72dc3..81c238a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -57,6 +57,14 @@ export const packages = sqliteTable('packages', { status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(), planeId: text('plane_id'), createdSimTime: integer('created_sim_time').notNull(), + // Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim + // clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows + // backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick. + createdRealMs: integer('created_real_ms').notNull().default(0), + // Real-time epoch ms when this (origin, band) slot was last taken — loaded or ignored (v0.65.0). The pool + // spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open + // jobs set nothing: an untaken job's slot respawns immediately. + slotFreeAtRealMs: integer('slot_free_at_real_ms'), }); export const account = sqliteTable('account', { diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts index 8468cee..314561b 100644 --- a/lib/deriveEvents.test.ts +++ b/lib/deriveEvents.test.ts @@ -38,6 +38,7 @@ const basePackage = (id: string): PackageState => ({ cargoCount: 2, weightKg: 600, special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures + expiresRealMs: null, // v0.65.0 — wall-clock expiry; not exercised here distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance deadlineSimTime: 0, status: 'open', diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 5974501..391274e 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -90,8 +90,10 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`); } + // v0.65.0: taking the job starts its slot's refill cooldown — the pool spawner holds this + // (origin, band) empty for the delay after now so the board visibly refreshes. await inst.db.update(schema.packages) - .set({ status: 'loaded', planeId }) + .set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() }) .where(eq(schema.packages.id, packageId)); await persist(inst); } @@ -264,8 +266,8 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ // --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B --- // Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else -// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with -// a new random package on the next tick. 404 unknown id; 409 unless open. +// changes. The pool spawner refills this (origin, band) slot after its refill delay — +// ignoring starts that cooldown via slotFreeAtRealMs (v0.65.0). 404 unknown id; 409 unless open. export async function ignorePackage(inst: GameDb, packageId: string): Promise { const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)); const pkg = pkgRows[0]; @@ -274,7 +276,7 @@ export async function ignorePackage(inst: GameDb, packageId: string): Promise { expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao); }); - it('seeds 8–12 open packages between distinct fixture airports', () => { + it('seeds a full pool of POOL_TARGET_PER_AIRPORT open packages at the hub (v0.65.0)', () => { const seed = seedWith(42); - expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN); - expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX); + expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT); const icaoSet = new Set(AIRPORTS.map((a) => a.icao)); + const byBand = [0, 0, 0, 0]; for (const c of seed.packages) { - expect(icaoSet.has(c.originIcao)).toBe(true); + expect(c.originIcao).toBe(HUB_ICAO); // the starter's home hub — its only pool airport + const band = POOL_BANDS[poolBandOf(c.weightKg)]; + byBand[poolBandOf(c.weightKg)]++; + expect(c.weightKg).toBeGreaterThanOrEqual(band.minKg); // weight stays in its band… + expect(c.weightKg).toBeLessThanOrEqual(band.maxKg); // …on both ends expect(icaoSet.has(c.destIcao)).toBe(true); - expect(c.originIcao).not.toBe(c.destIcao); + expect(c.destIcao).not.toBe(HUB_ICAO); expect(c.status).toBe('open'); expect(c.planeId).toBeNull(); expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117) + expect(c.createdRealMs).toBe(NOW); // …but a fresh real-time lifetime from the seed moment } + expect(byBand).toEqual(POOL_BANDS.map((b) => b.count)); expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); }); @@ -93,15 +101,16 @@ describe('buildSeed', () => { } }); - it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => { + it('gives every package a positive integer weight, and the board always offers jobs beyond the starter', () => { const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg; for (const seed of [seedWith(42), seedWith(7)]) { for (const c of seed.packages) { expect(Number.isInteger(c.weightKg)).toBe(true); expect(c.weightKg).toBeGreaterThan(0); - // max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG. - expect(c.weightKg).toBeLessThanOrEqual(maxPayload); } + // v0.65.0: the heavy bands (18–80 t) deliberately exceed the starter's payload — bigger jobs are + // what you buy better planes for, so the board must always show work it can't yet do alone. + expect(seed.packages.some((c) => c.weightKg > maxPayload)).toBe(true); } }); @@ -171,8 +180,8 @@ describe('generatePackage (shared seed/spawn builder)', () => { }); it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => { - const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); - const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW }); + const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW }); + const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW }); expect(a).toEqual(b); const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 }); @@ -186,13 +195,15 @@ describe('generatePackage (shared seed/spawn builder)', () => { expect(c.deadlineSimTime).toBe(NOW + 10_000); }); - it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => { + it('buildSeed packages are exactly the shared pool builder output (spawn matches seed)', () => { const rngA = mulberry32(42); - const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1)); const expected: string[] = []; - for (let i = 0; i < count; i++) { - // ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input. - expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS }))); + let n = 1; + for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) { + for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) { + // ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input. + expected.push(JSON.stringify(generatePoolPackage({ airports: AIRPORTS, originIcao: HUB_ICAO, bandIdx, rng: rngA, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: NOW }))); + } } const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected); @@ -234,8 +245,8 @@ describe('generatePackage (shared seed/spawn builder)', () => { }); it('is deterministic for a fixed RNG with forced endpoints', () => { - const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' }); - const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' }); + const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW }); + const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW }); expect(a).toEqual(b); }); @@ -248,8 +259,8 @@ describe('generatePackage (shared seed/spawn builder)', () => { it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => { // Same seed as a historical unforced call → same package, proving forced // endpoints consume no extra RNG draws when absent. - const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); - const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined }); + const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW }); + const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW }); expect(a).toEqual(b); }); }); @@ -266,7 +277,7 @@ describe('generatePackage (shared seed/spawn builder)', () => { expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult)); // Deterministic for a fixed RNG… - const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 }); + const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234, createdRealMs: c.createdRealMs }); expect(again).toEqual(c); // …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would. const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); @@ -323,8 +334,8 @@ describe('generatePackage (shared seed/spawn builder)', () => { }); it('defaults to the starting reputation when omitted — byte-identical output', () => { - const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); - const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION }); + const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW }); + const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION, createdRealMs: NOW }); expect(a).toEqual(b); // no bonus at the starting value }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index f73e07e..d0dca45 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -63,6 +63,9 @@ export interface SeedPackage { status: 'open'; planeId: null; createdSimTime: number; + // Real-time (Date.now()) creation stamp (v0.65.0) — open jobs expire on the wall clock, not the sim + // clock, so this is what their lifetime is measured from. + createdRealMs: number; } export interface SeedData { @@ -86,12 +89,44 @@ export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1); // failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.) export const HUB_ICAO = 'EDDF'; // Frankfurt Main -export const PACKAGE_COUNT_MIN = 8; -export const PACKAGE_COUNT_MAX = 12; // inclusive +// v0.65.0: per-airport job pools replace the global market target (v0.7.0). Each airport where a +// plane can take work keeps this fixed composition of open packages — heavier bands are rarer, so +// big jobs feel like opportunities instead of background noise. +export const POOL_BANDS = [ + { minKg: 500, maxKg: 3_999, count: 2 }, // <4 t + { minKg: 4_000, maxKg: 17_999, count: 6 }, // 4–18 t + { minKg: 18_000, maxKg: 48_000, count: 1 }, // 18–48 t + { minKg: 48_001, maxKg: 80_000, count: 1 }, // >48 t +] as const; -// Market target (v0.7.0): the sim engine tops open packages back up to this -// count after expiries, so the job board never runs dry. -export const MARKET_TARGET_OPEN_PACKAGES = 10; +// Which pool band a package's weight falls into (v0.65.0) — the spawner counts open packages per +// band and holds taken slots per band, so this must agree with POOL_BANDS' boundaries exactly. +export function poolBandOf(weightKg: number): number { + if (weightKg < 4_000) return 0; + if (weightKg < 18_000) return 1; + if (weightKg <= 48_000) return 2; + return 3; +} + +// Total open packages per pool airport — the seed builds exactly this at the starter's home hub. +export const POOL_TARGET_PER_AIRPORT = POOL_BANDS.reduce((sum, b) => sum + b.count, 0); + +// v0.65.0: open jobs live on the WALL clock, not the sim clock — they expire JOB_LIFETIME_MS after +// creation regardless of time scale or fleet state (checked every tick), so a paused world still +// refreshes its board. Test hooks may shorten these via env when FLIGHT_TEST_HOOKS=1 (CI sets the +// refill delays to seconds; the lifetime stays at 1 h everywhere). +function envMs(name: string, fallback: number): number { + if (process.env.FLIGHT_TEST_HOOKS !== '1') return fallback; + const v = Number(process.env[name]); + return Number.isFinite(v) && v > 0 ? Math.round(v) : fallback; +} + +export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock +// Refill cooldown after a job is TAKEN (loaded or ignored): the slot stays empty this long so the +// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block — +// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common. +export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock +export const SMALL_REFILL_DELAY_MS = envMs('FLIGHT_JOB_SMALL_REFILL_MS', 60_000); // 1 min wall clock // Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost // modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD + @@ -165,6 +200,8 @@ export function rewardFor(km: number, weightKg: number): number { // v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at // FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is // finishable by the fastest catalog plane; short legs are byte-identical to before. +// v0.65.0: createdRealMs optionally FORCES the wall-clock creation stamp (default Date.now()) — a pure +// input consuming no RNG draws; the seed passes it explicitly so seeded worlds stay deterministic. export function generatePackage(input: { id: string; airports: SeedAirport[]; @@ -176,6 +213,7 @@ export function generatePackage(input: { weightKg?: number; special?: string | null; reputation?: number; + createdRealMs?: number; }): SeedPackage { const { airports, rng, nowMs } = input; if (airports.length < 2) throw new Error('package generation needs at least two airports'); @@ -231,6 +269,9 @@ export function generatePackage(input: { // Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER // cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw. const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng())); + // v0.65.0: a FORCED weight overrides the drawn count so display/payload math agree with weightKg — + // pool packages carry band weights far beyond the starter's payload, where the drawn count would lie. + const finalCargoCount = input.weightKg !== undefined ? Math.max(1, Math.round(input.weightKg / UNIT_WEIGHT_KG[cargoType])) : cargoCount; // Drawn deadline window — the existing draw, position unchanged; special scaling is applied // below AFTER the appended special draw so the fixed order above holds. const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; @@ -257,16 +298,51 @@ export function generatePackage(input: { destIcao: dest.icao, reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)), cargoType, - cargoCount, + cargoCount: finalCargoCount, weightKg, special, deadlineSimTime: Math.round(nowMs + deadlineMs), status: 'open', planeId: null, createdSimTime: nowMs, + createdRealMs: input.createdRealMs ?? Date.now(), }; } +// v0.65.0: one pool package — a forced origin (the pool airport), a within-cap random destination +// (same cap as generated legs, so every pool job is flyable by some catalog plane), and a weight drawn +// uniformly from the band's range. Shared by buildSeed and the runtime spawner so seeded and spawned +// packages are indistinguishable. RNG draws per package: dest, weight, then generatePackage's own order. +export function generatePoolPackage(input: { + airports: SeedAirport[]; + originIcao: string; + bandIdx: number; + rng: Rng; + clockMs: number; // game time — anchors createdSimTime/deadlineSimTime + id: string; + createdRealMs?: number; // wall-clock stamp (default Date.now()) + reputation?: number; +}): SeedPackage { + const { airports, originIcao, bandIdx, rng } = input; + const oi = airports.findIndex((a) => a.icao === originIcao); + if (oi < 0) throw new Error(`unknown pool airport ${originIcao}`); + const di = pickDestWithinCap(airports, oi, rng); + if (di < 0) throw new Error(`no within-cap destination from ${originIcao}`); + const band = POOL_BANDS[bandIdx]; + const weightKg = Math.round(band.minKg + rng() * (band.maxKg - band.minKg)); + return generatePackage({ + id: input.id, + airports, + rng, + nowMs: input.clockMs, + originIcao, + destIcao: airports[di].icao, + weightKg, + createdRealMs: input.createdRealMs ?? Date.now(), + reputation: input.reputation, + }); +} + // v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx] // (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's // exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan. @@ -309,13 +385,20 @@ export function buildSeed(input: { health: 100, // full health on first run (v0.39.0, ISS-069) }; - const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1)); - // Stable index-based ids for the seed set; runtime-generated entities must use - // random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock - // origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate. + // v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport + // composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick. + // Stable index-based ids for the seed set; runtime-generated entities must use random UUIDs to avoid + // colliding with these. Deadlines are drawn relative to the GAME clock origin (ISS-117), not the real + // wall time, so they stay meaningful at the fixed world rate; createdRealMs is the REAL seed moment + // (input.nowMs) so seeded jobs get a fresh full wall-clock lifetime. const packages: SeedPackage[] = []; - for (let i = 0; i < count; i++) { - packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS })); + let n = 1; + for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) { + for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) { + packages.push( + generatePoolPackage({ airports, originIcao: hub.icao, bandIdx, rng, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: nowMs }), + ); + } } return { diff --git a/lib/game/state.test.ts b/lib/game/state.test.ts index 5fde416..926e891 100644 --- a/lib/game/state.test.ts +++ b/lib/game/state.test.ts @@ -14,6 +14,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({ cargoCount: 3, weightKg: 900, special: null, + expiresRealMs: status === 'open' ? 2_000_000 : null, // v0.65.0 — open jobs carry their wall-clock expiry distanceKm: 2000, deadlineSimTime: 1_000_000, status, diff --git a/lib/game/state.ts b/lib/game/state.ts index 96f2f58..a241235 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -5,6 +5,7 @@ import { getGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; +import { JOB_LIFETIME_MS } from './seed'; // Read-only game-state snapshot for client polling (GET /api/state). @@ -49,6 +50,20 @@ export function parseIcaoQueue(raw: string | null): string[] { } } +// v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination +// joins the pool while airborne), or queued for it. Repairing planes count nowhere: a fleet in the +// shop has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's +// pool spawner so both agree on exactly which airports keep open jobs. +export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set { + const set = new Set(); + for (const p of planes) { + if (p.status === 'available') set.add(p.locationIcao); + else if (p.status === 'flying' && p.destIcao) set.add(p.destIcao); + for (const icao of parseIcaoQueue(p.queue)) set.add(icao); + } + return set; +} + export interface PackageState { id: string; originIcao: string; @@ -69,6 +84,9 @@ export interface PackageState { status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; planeId: string | null; createdSimTime: number; + // Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live + // countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime). + expiresRealMs: number | null; } export interface StateSnapshot { @@ -121,6 +139,11 @@ export async function getStateSnapshot(): Promise { return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0; }; + // v0.65.0: pool visibility — open packages appear only where a plane can take work (poolAirports); + // every other status is always visible (on-board cargo, history). Filtered rows are absent from the + // body entirely, so clients never see jobs they could not dispatch. + const pool = poolAirports(planeRows); + // Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its // departure values — live position and fuel are pure functions of the clock. const livePlane = (p: schema.PlaneRow) => { @@ -171,22 +194,23 @@ export async function getStateSnapshot(): Promise { repairUntilSimTime: p.repairUntilSimTime, }; }), - packages: packageRows.map((p) => ({ - id: p.id, - originIcao: p.originIcao, - originName: nameOf(p.originIcao), - destIcao: p.destIcao, - destName: nameOf(p.destIcao), - reward: p.reward, - cargoType: p.cargoType, - cargoCount: p.cargoCount, - weightKg: p.weightKg, - special: p.special, - distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095) - deadlineSimTime: p.deadlineSimTime, - status: p.status, - planeId: p.planeId, - createdSimTime: p.createdSimTime, - })), + packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({ + id: p.id, + originIcao: p.originIcao, + originName: nameOf(p.originIcao), + destIcao: p.destIcao, + destName: nameOf(p.destIcao), + reward: p.reward, + cargoType: p.cargoType, + cargoCount: p.cargoCount, + weightKg: p.weightKg, + special: p.special, + distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095) + deadlineSimTime: p.deadlineSimTime, + status: p.status, + planeId: p.planeId, + createdSimTime: p.createdSimTime, + expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null, + })), }; } diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 7d36bb2..e35a4ec 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -137,6 +137,7 @@ describe('OpenAPI spec', () => { 'destIcao', 'destName', 'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km + 'expiresRealMs', // v0.65.0 — wall-clock expiry for open jobs; null otherwise 'id', 'originIcao', 'originName', diff --git a/lib/openapi.ts b/lib/openapi.ts index 07a2d42..50b590b 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -608,6 +608,7 @@ export const openApiSpec = { status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }, planeId: { type: ['string', 'null'] }, createdSimTime: { type: 'integer' }, + expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' }, }, required: [ 'id', @@ -624,6 +625,7 @@ export const openApiSpec = { 'status', 'planeId', 'createdSimTime', + 'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise ], }, }, diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 40602f0..d51beea 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -10,17 +10,17 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo'; 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 { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed'; +import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { deliverPackages, - expirePackages, + expireLoadedPackages, + expireOpenPackages, finishRepairs, MAX_TICK_ELAPSED_MS, reputationGainFor, shouldAdvanceTime, - spawnCoveragePackages, - spawnOpenPackages, + spawnPoolPackages, tickOnce, WORLD_TIME_SCALE, } from './engine'; @@ -140,41 +140,70 @@ describe('tickOnce — clock advance', () => { }); }); -describe('expirePackages', () => { - it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { - const inst = await freshDb('expiry.db'); +describe('expireLoadedPackages (sim clock)', () => { + it('flips loaded packages past their sim deadline; terminal statuses never expire', async () => { + const inst = await freshDb('expiry-loaded.db'); // Game-clock anchor just after the seed moment — well inside every seeded package's deadline // window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire. const clock = STARTING_CLOCK_MS + 100_000; await inst.db.insert(schema.packages).values([ - { id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS }, - { id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS }, { id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS }, + { id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS }, { id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS }, { id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS }, { id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS }, ]); - const n = await expirePackages(inst, clock); - expect(n).toBe(2); // the open and the loaded one + const n = await expireLoadedPackages(inst, clock); + expect(n).toBe(1); // only the loaded one — open jobs live on the wall clock now (v0.65.0) const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status])); - expect(byId.get('p-open-past')).toBe('expired'); - expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot) + expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips }); it('a deadline exactly at the clock counts as expired', async () => { - const inst = await freshDb('expiry-edge.db'); + const inst = await freshDb('expiry-loaded-edge.db'); + await inst.db.insert(schema.packages).values({ + id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, + deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS, + }); + expect(await expireLoadedPackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive + }); +}); + +describe('expireOpenPackages (v0.65.0 wall clock)', () => { + it('flips open packages past their real-time lifetime; fresh ones and terminal statuses never', async () => { + const inst = await freshDb('expiry-open.db'); + // T0 is the fake Date.now() at DB open — seeded rows carry createdRealMs = T0, so they are FRESH + // (a full JOB_LIFETIME_MS away) and only the aged row below can expire. + await inst.db.insert(schema.packages).values([ + { id: 'p-open-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 }, + { id: 'p-open-fresh', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 }, + { id: 'p-delivered-aged', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 3_600_000, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS, createdRealMs: T0 - JOB_LIFETIME_MS - 1 }, + ]); + + const n = await expireOpenPackages(inst, T0); + expect(n).toBe(1); // only the aged open one — loaded jobs are sim-clock territory now + + const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status])); + expect(byId.get('p-open-aged')).toBe('expired'); + expect(byId.get('p-open-fresh')).toBe('open'); // lifetime not reached yet + expect(byId.get('p-delivered-aged')).toBe('delivered'); // terminal — never flips + }); + + it('a lifetime exactly at the clock counts as expired', async () => { + const inst = await freshDb('expiry-open-edge.db'); await inst.db.insert(schema.packages).values({ id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS, + createdRealMs: T0 - JOB_LIFETIME_MS, }); - expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive + expect(await expireOpenPackages(inst, T0)).toBe(1); // <= is inclusive }); }); @@ -642,9 +671,11 @@ describe('grounded fleet freezes the world clock (#135)', () => { expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving }); - it('a frozen tick still tops up the market so a grounded fleet never starves (#135 follow-up)', async () => { + it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => { const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen // Drain the open market to zero the way repeated ignores would (removal paths run while frozen). + // Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real + // ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS. for (const p of await inst.db.select().from(schema.packages)) { if (p.status === 'open') { await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id)); @@ -656,14 +687,14 @@ describe('grounded fleet freezes the world clock (#135)', () => { expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // freeze preserved… const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); - expect(openRows.length).toBeGreaterThanOrEqual(MARKET_TARGET_OPEN_PACKAGES); // …but the market is topped back up… - expect(openRows.some((p) => p.originIcao === 'EDDF')).toBe(true); // …including coverage for the grounded starter's airport + expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // …but the starter's pool is refilled to its full composition… + expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // …all at the grounded starter's airport }); }); -describe('spawnOpenPackages', () => { - it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => { - const inst = await freshDb('spawn.db'); +describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => { + it('tops each pool airport up to the POOL_BANDS composition using the shared seed builder', async () => { + const inst = await freshDb('pool.db'); const clock = T0 + 123_456; // Leave exactly 3 open packages (the rest of the seeded set is "done"). @@ -672,16 +703,18 @@ describe('spawnOpenPackages', () => { await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id)); } - const needed = await spawnOpenPackages(inst, clock, mulberry32(7)); - expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3); + const needed = await spawnPoolPackages(inst, clock, mulberry32(7), T0); + expect(needed).toBe(POOL_TARGET_PER_AIRPORT - 3); const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); - expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES); + expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); + expect(openRows.every((p) => p.originIcao === 'EDDF')).toBe(true); // single grounded plane → one pool const fc = await loadAirportsGeoJson(); const icaoSet = new Set(fc.features.map((f) => f.properties.icao)); for (const p of openRows.slice(3)) { // the spawned ones expect(p.createdSimTime).toBe(clock); // created at game time, not real time + expect(p.createdRealMs).toBe(T0); // …and stamped with the injected wall clock expect(icaoSet.has(p.originIcao)).toBe(true); expect(icaoSet.has(p.destIcao)).toBe(true); expect(p.originIcao).not.toBe(p.destIcao); @@ -693,15 +726,36 @@ describe('spawnOpenPackages', () => { } // Already at target → nothing more to spawn. - expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0); + expect(await spawnPoolPackages(inst, clock, mulberry32(7), T0)).toBe(0); }); - it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => { - const a = await freshDb('spawn-a.db'); - const b = await freshDb('spawn-b.db'); + it('keeps the band composition per airport — weights stay inside their band', async () => { + const inst = await freshDb('pool-composition.db'); + // Wipe all seeded packages so the spawner builds the pool from zero with a known RNG. + for (const p of await inst.db.select().from(schema.packages)) { + await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); + } + + const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0); + expect(n).toBe(POOL_TARGET_PER_AIRPORT); // one pool (EDDF) at full composition + + const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); + const byBand = [0, 0, 0, 0]; + for (const p of openRows) { + const b = poolBandOf(p.weightKg); + byBand[b]++; + expect(p.weightKg).toBeGreaterThanOrEqual(POOL_BANDS[b].minKg); // weight stays in its band… + expect(p.weightKg).toBeLessThanOrEqual(POOL_BANDS[b].maxKg); // …on both ends + } + expect(byBand).toEqual(POOL_BANDS.map((b) => b.count)); + }); + + it('is deterministic for an injected RNG and wall clock', async () => { + const a = await freshDb('pool-a.db'); + const b = await freshDb('pool-b.db'); const clock = T0 + 999; - // Wipe all packages in both so both spawn from zero with the same RNG. + // Wipe all packages in both so both spawn from zero with the same RNG and now. for (const inst of [a, b]) { const rows = await inst.db.select().from(schema.packages); for (const r of rows) { @@ -709,8 +763,8 @@ describe('spawnOpenPackages', () => { } } - expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); - expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES); + expect(await spawnPoolPackages(a, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT); + expect(await spawnPoolPackages(b, clock, mulberry32(42), T0)).toBe(POOL_TARGET_PER_AIRPORT); const strip = (rows: schema.PackageRow[]) => rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort(); @@ -718,82 +772,62 @@ describe('spawnOpenPackages', () => { strip(await b.db.select().from(schema.packages)), ); }); -}); -describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => { - // The seeded starter plane is grounded at EDDF with no seeded package from - // there, so a fresh DB always has exactly one uncovered airport: EDDF. - - it('spawns an open package originating at the airport of each uncovered grounded plane', async () => { - const inst = await freshDb('coverage.db'); - const clock = T0 + 5_000; - - // Wipe all seeded packages so coverage is the only source of new ones. + it('spawns for flying destinations and queued airports, not just grounded locations', async () => { + const inst = await freshDb('pool-membership.db'); for (const p of await inst.db.select().from(schema.packages)) { await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); } - - const n = await spawnCoveragePackages(inst, clock, mulberry32(7)); - expect(n).toBe(1); // only EDDF is uncovered (single grounded plane) - - const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); - expect(openRows.length).toBe(1); - expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport - expect(openRows[0].createdSimTime).toBe(clock); - }); - - it('does nothing when every grounded airport already has an open package', async () => { - const inst = await freshDb('coverage-covered.db'); - for (const p of await inst.db.select().from(schema.packages)) { - await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); - } - // One open package from EDDF covers the starter plane's airport. - await inst.db.insert(schema.packages).values({ - id: 'p-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, - deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0, + // Starter flies to LEMD with a queued leg to EHAM; a second plane sits at LEMD. The pool is + // {LEMD (flying dest + grounded), EHAM (queued)} — the starter's departure airport is NOT in it. + await inst.db.update(schema.planes) + .set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) }) + .where(eq(schema.planes.id, 'plane-starter')); + await inst.db.insert(schema.planes).values({ + id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null, }); - expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); - expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added - }); - - it('spawns one package per uncovered airport when planes sit at several airports', async () => { - const inst = await freshDb('coverage-multi.db'); - for (const p of await inst.db.select().from(schema.packages)) { - await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); - } - // A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn. - await inst.db.insert(schema.planes).values([ - { id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null }, - { id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null }, - ]); - - const n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7)); - expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD + const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0); + expect(n).toBe(POOL_TARGET_PER_AIRPORT * 2); // one full pool per airport in the set const origins = (await inst.db.select().from(schema.packages)) .filter((p) => p.status === 'open') .map((p) => p.originIcao) .sort(); - expect(origins).toEqual(['EDDF', 'LEMD']); + expect(origins).toEqual( + [...Array(POOL_TARGET_PER_AIRPORT).fill('EHAM'), ...Array(POOL_TARGET_PER_AIRPORT).fill('LEMD')].sort(), + ); }); - it('ignores flying planes — they cannot take new work until they land', async () => { - const inst = await freshDb('coverage-flying.db'); - for (const p of await inst.db.select().from(schema.packages)) { - await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id)); - } - await inst.db.update(schema.planes) - .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) - .where(eq(schema.planes.id, 'plane-starter')); + it('holds taken slots for their refill delay, then refills them', async () => { + const inst = await freshDb('pool-cooldown.db'); + // Take one small job and one heavy job at EDDF: both stamp slotFreeAtRealMs (the shared per-airport + // cooldown — see the ponytail note in spawnPoolPackages). + const all = await inst.db.select().from(schema.packages); + const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!; + const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!; + await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id)); + await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id)); - expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes + // While the cooldown runs (real = T0), nothing refills — even though two slots are empty. + expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0); + + // The small slot frees first (SMALL_REFILL_DELAY_MS) — exactly its missing count refills; the heavy + // one is still held by REFILL_DELAY_MS. + expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + SMALL_REFILL_DELAY_MS)).toBe(1); + + // The heavy slot frees at REFILL_DELAY_MS — the last empty band tops up too. + expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0 + REFILL_DELAY_MS)).toBe(1); + + const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); + expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT); // pool back to full composition }); - it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => { - const inst = await freshDb('coverage-tick.db'); + it('runs inside tickOnce: a fresh DB keeps every pool topped up through the full pipeline', async () => { + const inst = await freshDb('pool-tick.db'); // A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays - // grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here. + // grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it + // never lands here. await inst.db.insert(schema.planes).values({ id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS, @@ -802,10 +836,9 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => { await tickOnce(inst); - const fromEdff = (await inst.db.select().from(schema.packages)).filter( - (p) => p.status === 'open' && p.originIcao === 'EDDF', - ); - expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline + const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open'); + expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline + expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true); }); }); diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index a877a59..192e0b1 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -1,5 +1,5 @@ import { randomInt, randomUUID } from 'node:crypto'; -import { eq, inArray } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; @@ -7,14 +7,17 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo'; 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 { getStateSnapshot, parseIcaoQueue, sseFrame } from '@/lib/game/state'; +import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state'; import { broadcast } from '@/lib/sse'; import { - generatePackage, - MARKET_TARGET_OPEN_PACKAGES, + JOB_LIFETIME_MS, + POOL_BANDS, + REFILL_DELAY_MS, + SMALL_REFILL_DELAY_MS, + generatePoolPackage, mulberry32, - pickDestWithinCap, + poolBandOf, type Rng, type SeedAirport, } from '@/lib/game/seed'; @@ -101,20 +104,21 @@ export async function tickOnce(inst: GameDb): Promise { const clockMs = Math.round(sim.clockMs + advanceMs); if (advanceMs > 0) { - await expirePackages(inst, clockMs); + await expireLoadedPackages(inst, clockMs); await deliverPackages(inst, clockMs); // v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when - // nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too. + // nothing landed this tick; BEFORE the spawns so a just-repaired plane gets pool work too. await finishRepairs(inst, clockMs); } - // #135 follow-up (v0.64.0): market top-up + coverage spawn run on EVERY tick — frozen ones - // included — because while grounded nothing else creates packages but removal paths (ignore) - // still run; without this a grounded fleet could drain the market to zero and soft-lock forever. - // Both are idempotent no-ops when satisfied, so advancing ticks behave exactly as before. - await spawnOpenPackages(inst, clockMs); - // AFTER delivery on purpose: a plane that just landed gets work spawned - // at its new location within the same tick. - await spawnCoveragePackages(inst, clockMs); + // v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) — + // because while grounded nothing else creates packages but removal paths (ignore) still run; without + // this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the + // WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board; + // both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before. + await expireOpenPackages(inst, nowMs); + // AFTER delivery on purpose: a plane that just landed gets work spawned at its new location within + // the same tick (its destination joins the pool while flying, and its arrival airport on landing). + await spawnPoolPackages(inst, clockMs); // Persist lastRealTickMs even when nothing advanced — it is the tick anchor. await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs }); @@ -124,12 +128,12 @@ export async function tickOnce(inst: GameDb): Promise { return updated; } -// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave -// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep -// occupying their slot, deliver no reward on arrival, and abandon for free). -// Delivered/abandoned packages are terminal and never flip. Returns the count. -export async function expirePackages(inst: GameDb, clockMs: number): Promise { - const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded'])); +// v0.65.0: expiry split by clock. LOADED cargo expires on the SIM clock (unchanged since v0.12.0 — +// it keeps occupying its slot, delivers no reward on arrival, and abandons for free); OPEN jobs +// expire on the WALL clock (see expireOpenPackages). Delivered/abandoned packages are terminal and +// never flip. Both return the count flipped. +export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promise { + const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'loaded')); let n = 0; for (const p of rows) { if (p.deadlineSimTime <= clockMs) { @@ -140,6 +144,22 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise { + const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')); + let n = 0; + for (const p of rows) { + if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) { + await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id)); + n++; + } + } + return n; +} + // Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along // round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started — // derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips @@ -328,19 +348,28 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise { - const openCount = ( - await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')) - ).length; - const needed = MARKET_TARGET_OPEN_PACKAGES - openCount; - if (needed <= 0) return 0; + const real = nowMs ?? Date.now(); + const [planeRows, packageRows] = await Promise.all([ + inst.db.select().from(schema.planes), + inst.db.select().from(schema.packages), + ]); + + const pool = poolAirports(planeRows); + if (pool.size === 0) return 0; // whole fleet repairing — nothing to spawn for until one frees up const fc = await loadAirportsGeoJson(); const airports: SeedAirport[] = fc.features.map((f) => ({ @@ -354,74 +383,41 @@ export async function spawnOpenPackages( const account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); - for (let i = 0; i < needed; i++) { - await inst.db.insert(schema.packages).values( - generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }), - ); - } - return needed; -} - -// Spawn coverage rule (v0.8.0): in addition to topping the market up, every -// airport that currently has a grounded 'available' plane must have ≥1 OPEN -// package originating there — so work is always available where the fleet sits -// (including onboarding at EDDF). One package per uncovered airport, generated -// with generatePackage and origin forced to that airport. Runs after -// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage -// too; and after deliverPackages, so a plane that just landed gets work spawned -// at its new location within the same tick. Returns the number created. -export async function spawnCoveragePackages( - inst: GameDb, - clockMs: number, - rng?: Rng, -): Promise { - const [planeRows, openRows] = await Promise.all([ - inst.db.select().from(schema.planes), - inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')), - ]); - - // Distinct airports with at least one grounded plane (flying planes don't count — - // they can't take new work until they land). - const groundedIcas = [ - ...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)), - ]; - if (groundedIcas.length === 0) return 0; - - const covered = new Set(openRows.map((c) => c.originIcao)); - const missing = groundedIcas.filter((icao) => !covered.has(icao)); - if (missing.length === 0) return 0; - - const fc = await loadAirportsGeoJson(); - const airports: SeedAirport[] = fc.features.map((f) => ({ - icao: f.properties.icao, - name: f.properties.name, - lat: f.properties.lat, - lon: f.properties.lon, - })); - const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); - // v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch. - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - let n = 0; - for (const icao of missing) { - const oi = airports.findIndex((a) => a.icao === icao); - if (oi < 0) continue; // defensive — the dataset is the source of all ICACs - // v0.44.0 (ISS-079): cap the random dest at MAX_JOB_LEG_KM like generatePackage's unforced pairs — - // one draw in the same stream position as the old uncapped pick, so downstream draws are unchanged. - const di = pickDestWithinCap(airports, oi, rand); - await inst.db.insert(schema.packages).values( - generatePackage({ - id: randomUUID(), - airports, - rng: rand, - nowMs: clockMs, - originIcao: icao, - ...(di >= 0 ? { destIcao: airports[di].icao } : {}), // di < 0 (no within-cap neighbour) → old uncapped behavior - reputation: account.reputation, - }), - ); - n++; + for (const originIcao of pool) { + // Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter: + // terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp. + const openByBand = [0, 0, 0, 0]; + let blockedUntil: number | null = null; + for (const p of packageRows) { + if (p.originIcao !== originIcao || p.status === 'delivered' || p.status === 'expired' || p.status === 'abandoned') continue; + const b = poolBandOf(p.weightKg); + if (p.status === 'open') openByBand[b]++; + else if (p.slotFreeAtRealMs !== null && (blockedUntil === null || p.slotFreeAtRealMs > blockedUntil)) { + // ponytail: one shared cooldown per airport, not per band — a taken heavy job delays small-job + // refills too. Per-band tracking is a trivial change if the board feels sluggish. + blockedUntil = p.slotFreeAtRealMs; + } + } + for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) { + const delay = bandIdx === 0 ? SMALL_REFILL_DELAY_MS : REFILL_DELAY_MS; + while (openByBand[bandIdx] < POOL_BANDS[bandIdx].count && !(blockedUntil !== null && blockedUntil + delay > real)) { + await inst.db.insert(schema.packages).values( + generatePoolPackage({ + airports, + originIcao, + bandIdx, + rng: rand, + clockMs, + id: randomUUID(), + createdRealMs: real, + reputation: account.reputation, + }), + ); + openByBand[bandIdx]++; + n++; + } + } } return n; } diff --git a/package.json b/package.json index a0ab047..b35c8c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.64.0", + "version": "0.65.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From 199704f76131c61ec277621c71409d62d552a3b7 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 13:13:44 +0000 Subject: [PATCH 2/3] =?UTF-8?q?v0.65.0:=20fix=20pool=20comments=20?= =?UTF-8?q?=E2=80=94=20queued=20legs=20count=20toward=20the=20pool=20even?= =?UTF-8?q?=20while=20their=20plane=20is=20in=20the=20shop=20(startRepair?= =?UTF-8?q?=20keeps=20the=20queue),=20so=20an=20all-repairing=20fleet=20on?= =?UTF-8?q?ly=20has=20no=20pools=20when=20its=20queues=20are=20empty=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/game/state.ts | 5 +++-- lib/sim/engine.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ca245..80787c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## v0.65.0 — 2026-09-23 12:58 -- **Per-airport job pools with real-time refresh.** The market was a global bag of jobs (top-up to `MARKET_TARGET_OPEN_PACKAGES = 10` + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' `locationIcao`, flying planes' `destIcao` and queued-leg ICAOs (repairing planes excluded; an all-repairing fleet has no pool, and orphaned pools simply expire out). Each pool airport holds exactly `POOL_TARGET_PER_AIRPORT = 10` open jobs in a fixed weight composition (`POOL_BANDS`: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. `spawnPoolPackages` in `lib/sim/engine.ts` replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new `generatePoolPackage` helper in `lib/game/seed.ts`, so they are indistinguishable — `buildSeed` now seeds a full pool at the starter's home hub, and `PACKAGE_COUNT_MIN/MAX` + `MARKET_TARGET_OPEN_PACKAGES` are retired. +- **Per-airport job pools with real-time refresh.** The market was a global bag of jobs (top-up to `MARKET_TARGET_OPEN_PACKAGES = 10` + coverage spawn at uncovered grounded airports), so the board showed work you could never take and hid nothing. Now open jobs are generated PER AIRPORT where a plane can actually pick them up — the pool is the set of grounded planes' `locationIcao`, flying planes' `destIcao` and queued-leg ICAOs — queued legs count even while their plane is in the shop (the work will be waiting at its next stop); a fleet fully repairing with empty queues has no pool, and orphaned pools simply expire out. Each pool airport holds exactly `POOL_TARGET_PER_AIRPORT = 10` open jobs in a fixed weight composition (`POOL_BANDS`: 2×[500,4000) + 6×[4000,18000) + 1×[18000,48000] + 1×(48000,80000] kg), so the board always mixes jobs you can take now with heavier ones that are what you buy better planes for. `spawnPoolPackages` in `lib/sim/engine.ts` replaces both old spawners and runs on every tick (including frozen ones) like v0.64.0's top-up; seeded and spawned packages come from the same new `generatePoolPackage` helper in `lib/game/seed.ts`, so they are indistinguishable — `buildSeed` now seeds a full pool at the starter's home hub, and `PACKAGE_COUNT_MIN/MAX` + `MARKET_TARGET_OPEN_PACKAGES` are retired. - **Open jobs live on the wall clock.** New `created_real_ms INTEGER NOT NULL DEFAULT 0` column (guarded ALTER backfilled with `Date.now()` for existing rows): an open job expires `JOB_LIFETIME_MS = 1 h` after creation regardless of time scale or fleet state — checked every tick OUTSIDE the advance gate, so a paused world still refreshes its board. Loaded jobs keep their sim-clock deadline logic unchanged (delivery check, expiry floor, abandon penalty all untouched). Taken slots (loaded OR ignored) stamp `slot_free_at_real_ms` and hold their pool slot for a refill cooldown — `REFILL_DELAY_MS = 15 min`, or `SMALL_REFILL_DELAY_MS = 1 min` for the <4 t band — so the board visibly refreshes instead of instantly refilling; expired/delivered/abandoned never block. ponytail: one shared per-airport cooldown (the max stamp) rather than a per-band table, add when per-band refill timing matters. - **Visibility + UI.** `/api/state` and SSE now filter open packages to pool origins (`poolAirports` in `lib/game/state.ts`; all non-open statuses pass through unchanged), so the right pane only ever shows jobs you can take — no disabled Load buttons for foreign-origin work. `PackageState` gains `expiresRealMs` (open → `createdRealMs + JOB_LIFETIME_MS`, else null; OpenAPI updated) and the open-job row renders a wall-clock countdown via the existing pure `formatDeadlineRelative` formatter (no new timer — the ~1 s poll re-renders anyway); loaded jobs keep their sim-deadline display. - **Test hooks.** `/api/test/expire-package` rewritten for the wall clock: `{packageId}` ages an OPEN job's `createdRealMs` past its lifetime (`HOOK_AGE_MS = JOB_LIFETIME_MS + 60_000`) and runs a tick, so it comes back already expired (200; unknown/non-open ids 404); no body creates one already-aged hub job (201). CI env: `docker-compose.ci.yml` sets `FLIGHT_JOB_REFILL_MS=5000` + `FLIGHT_JOB_SMALL_REFILL_MS=2000` under the existing `FLIGHT_TEST_HOOKS=1` gate — the lifetime stays 1 h everywhere on purpose, so `state.spec.ts`'s mid-suite container restart can't race a whole board expiring. diff --git a/lib/game/state.ts b/lib/game/state.ts index a241235..d881677 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -51,8 +51,9 @@ export function parseIcaoQueue(raw: string | null): string[] { } // v0.65.0: the airports where a plane can take work — grounded there, flying TO it (its destination -// joins the pool while airborne), or queued for it. Repairing planes count nowhere: a fleet in the -// shop has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's +// joins the pool while airborne), or queued for it (queued legs count even while their plane is in +// the shop — the work will be waiting at its next stop). A fleet fully repairing with empty queues +// has no pools until one frees up. Shared by the snapshot's visibility filter and the engine's // pool spawner so both agree on exactly which airports keep open jobs. export function poolAirports(planes: Array<{ status: string; locationIcao: string; destIcao: string | null; queue: string | null }>): Set { const set = new Set(); diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 192e0b1..f995c49 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -369,7 +369,7 @@ export async function spawnPoolPackages( ]); const pool = poolAirports(planeRows); - if (pool.size === 0) return 0; // whole fleet repairing — nothing to spawn for until one frees up + if (pool.size === 0) return 0; // no pool airports — e.g. the whole fleet is in the shop with empty queues const fc = await loadAirportsGeoJson(); const airports: SeedAirport[] = fc.features.map((f) => ({ -- 2.54.0 From 70a726d546cc9529baa0f7e154defcedfc2659cb Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 15:31:20 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(e2e):=20lift=20zoom=20floor=20for=20ant?= =?UTF-8?q?imeridian=20hover=20test=20(EDDF=E2=86=92NFTF=20spans=20~176?= =?UTF-8?q?=C2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/hover.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index a7c1af6..f646c76 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -380,6 +380,10 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); // fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. + // EDDF→NFTF spans ~176° of longitude — it can't fit at MIN_ZOOM=3 (components/Map.tsx), so the camera + // clamps to z=3 and both endpoints are off-viewport → queryRenderedFeatures never sees the trace. + // Lift the floor for this test's camera; quantization stays ≤~0.02° even near z=0, below EPS (:214–228). + await page.evaluate(() => (window as any).__flightMap?.setMinZoom(0)); await fitToAirports(page, [HUB, 'NFTF']); await row.hover(); -- 2.54.0