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
+95 -12
View File
@@ -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 {