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:
+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