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
CI / build + unit + e2e (pull_request) Failing after 17m26s
This commit is contained in:
+12
-1
@@ -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')) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+6
-4
@@ -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<void> {
|
||||
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<vo
|
||||
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: Date.now() }).where(eq(schema.packages.id, packageId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
|
||||
+35
-24
@@ -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
|
||||
});
|
||||
|
||||
|
||||
+95
-12
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+41
-17
@@ -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<string> {
|
||||
const set = new Set<string>();
|
||||
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<StateSnapshot> {
|
||||
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<StateSnapshot> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+127
-94
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+93
-97
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user