v0.65.0: per-airport job pools with real-time refresh — open jobs spawn per pool airport (grounded/flying/queued locations) in a fixed POOL_BANDS composition, expire on the wall clock after JOB_LIFETIME_MS even while the world is frozen, and refill taken slots after a per-band cooldown; /api/state + SSE filter open jobs to pool origins and expose expiresRealMs for the right-pane countdown
CI / build + unit + e2e (pull_request) Failing after 17m26s

This commit is contained in:
2026-09-23 13:09:49 +00:00
parent 9c2c64fe89
commit 12d900b22a
24 changed files with 556 additions and 344 deletions
+93 -97
View File
@@ -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<schema.SimStateRow> {
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<schema.SimStateRow> {
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<number> {
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<number> {
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<num
return n;
}
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). Returns the count flipped.
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
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<nu
return n;
}
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
// SAME builder as the first-run seed (generatePackage), so spawned packages are
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
export async function spawnOpenPackages(
// v0.65.0: per-airport job pools replace the global market top-up (v0.7.0) and the coverage spawn
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded/ignored) stays empty for the band's refill delay after
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
export async function spawnPoolPackages(
inst: GameDb,
clockMs: number,
rng?: Rng,
nowMs?: number,
): Promise<number> {
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<number> {
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;
}