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
+35 -24
View File
@@ -10,12 +10,14 @@ import {
EXPIRY_BUFFER_MS,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
generatePoolPackage,
HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
POOL_BANDS,
poolBandOf,
POOL_TARGET_PER_AIRPORT,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
@@ -65,20 +67,26 @@ describe('buildSeed', () => {
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
});