CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
import { haversineKm } from '@/lib/geo';
|
||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||
import { pickSpecial, SPECIALS } from './specials';
|
||
|
||
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
||
// so it is fully deterministic and unit-testable: the RNG is injected, never
|
||
// read internally. Production calls this with a crypto-random seed; tests use
|
||
// mulberry32(fixed).
|
||
|
||
export interface SeedAirport {
|
||
icao: string;
|
||
name: string;
|
||
lat: number;
|
||
lon: number;
|
||
}
|
||
|
||
export type Rng = () => number;
|
||
|
||
// Small deterministic PRNG (mulberry32) — good enough for game seeding, and it
|
||
// makes tests reproducible. Returns floats in [0, 1).
|
||
export function mulberry32(seed: number): Rng {
|
||
let a = seed >>> 0;
|
||
return () => {
|
||
a |= 0;
|
||
a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (a >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
export interface SeedPlane {
|
||
id: string;
|
||
model: string;
|
||
status: 'available' | 'flying';
|
||
fuel: number;
|
||
maxFuel: number;
|
||
locationIcao: string;
|
||
destIcao: null;
|
||
lat: number;
|
||
lon: number;
|
||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||
// column as JSON text on insert; defaults to an empty queue.
|
||
queue?: string[];
|
||
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
|
||
health: number;
|
||
}
|
||
|
||
export interface SeedPackage {
|
||
id: string;
|
||
originIcao: string;
|
||
destIcao: string;
|
||
reward: number;
|
||
cargoType: string;
|
||
cargoCount: number;
|
||
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
||
weightKg: number;
|
||
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||
special: string | null;
|
||
deadlineSimTime: number;
|
||
status: 'open';
|
||
planeId: null;
|
||
createdSimTime: number;
|
||
}
|
||
|
||
export interface SeedData {
|
||
plane: SeedPlane;
|
||
packages: SeedPackage[];
|
||
account: { cash: number; reputation: number };
|
||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number };
|
||
}
|
||
|
||
// --- Tunables (documented so future revenue/cost work can build on them) ---
|
||
|
||
export const STARTING_CASH = 20_000;
|
||
export const STARTING_REPUTATION = 10;
|
||
|
||
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
|
||
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
|
||
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
|
||
|
||
// The starter plane grounds at a large central-European hub. If the ICAO is
|
||
// ever missing from the dataset we fall back to the first airport rather than
|
||
// 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
|
||
|
||
// 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;
|
||
|
||
// 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 +
|
||
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
||
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
|
||
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
|
||
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
|
||
export const BASE_REWARD = 500;
|
||
export const REWARD_PER_KM = 2.5;
|
||
export const REWARD_PER_KG = 2.0;
|
||
|
||
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||
// higher reputation, exactly ×1 at the starting value.
|
||
// ponytail: balance-tunable — job reward bonus per rep point above the starting value.
|
||
export const JOB_BONUS_PER_REP = 0.01;
|
||
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
|
||
export const MAX_JOB_BONUS = 1.0;
|
||
|
||
// v0.44.0 (ISS-079): generated jobs are capped at this great-circle distance so every spawned
|
||
// leg is flyable by at least one catalog plane even in worst-case headwind — the b777's full-tank
|
||
// range is ~13045 km nominal and ~10871 km with the +20% wind factor (maxRangeKm ÷ 1.2), so 9000
|
||
// leaves margin below it while still spanning most of a continent. ponytail: balance-tunable —
|
||
// must stay ≤ maxRangeKm(b777) / 1.2 or some generated legs are permanently unflyable.
|
||
export const MAX_JOB_LEG_KM = 9000;
|
||
|
||
// Bounded rejection attempts in generatePackage before the within-cap fallback kicks in.
|
||
const LEG_CAP_MAX_ATTEMPTS = 25;
|
||
|
||
// Package deadlines: now + a plausible game-time window of 6–72 hours.
|
||
const DEADLINE_MIN_HOURS = 6;
|
||
const DEADLINE_MAX_HOURS = 72;
|
||
|
||
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
|
||
|
||
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
|
||
// type at MAX count × max jitter (1.3×) still fits the ATR 72-600's payload alone, and
|
||
// typical packages land ~400–2500 kg — a normal flight carries roughly 3–6 by weight.
|
||
export const UNIT_WEIGHT_KG: Record<string, number> = {
|
||
Passengers: 50, // max 50 × 68 × 1.3 = 4420 < 4800
|
||
Mail: 60, // max 60 × 50 × 1.3 = 3900
|
||
Perishables: 70, // max 70 × 50 × 1.3 = 4550
|
||
Electronics: 40, // max 40 × 50 × 1.3 = 2600
|
||
'Auto parts': 72, // max 72 × 50 × 1.3 = 4680
|
||
};
|
||
|
||
export function rewardFor(km: number, weightKg: number): number {
|
||
return Math.round(BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg);
|
||
}
|
||
|
||
// Shared package generator, used by BOTH the first-run seed and the runtime
|
||
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
|
||
// distinct airports from the dataset, distance+weight-based reward, cargo flavor,
|
||
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
|
||
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
|
||
// force a near-term expiry). The RNG call order per package is fixed: origin,
|
||
// dest, cargoType, cargoCount, weight jitter, [deadline window], [special].
|
||
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
||
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
||
// callers that omit both get byte-identical behavior to before.
|
||
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
||
// jitter draw, same pattern as forced endpoints.
|
||
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
|
||
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
|
||
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
||
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
||
// starting value so seed-time behavior is byte-identical.
|
||
export function generatePackage(input: {
|
||
id: string;
|
||
airports: SeedAirport[];
|
||
rng: Rng;
|
||
nowMs: number;
|
||
deadlineOffsetMs?: number;
|
||
originIcao?: string;
|
||
destIcao?: string;
|
||
weightKg?: number;
|
||
special?: string | null;
|
||
reputation?: number;
|
||
}): SeedPackage {
|
||
const { airports, rng, nowMs } = input;
|
||
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
||
|
||
let oi: number;
|
||
if (input.originIcao !== undefined) {
|
||
oi = airports.findIndex((a) => a.icao === input.originIcao);
|
||
if (oi < 0) throw new Error(`unknown origin airport ${input.originIcao}`);
|
||
} else {
|
||
oi = Math.floor(rng() * airports.length);
|
||
}
|
||
let di: number;
|
||
if (input.destIcao !== undefined) {
|
||
di = airports.findIndex((a) => a.icao === input.destIcao);
|
||
if (di < 0) throw new Error(`unknown destination airport ${input.destIcao}`);
|
||
// A RANDOM origin may have landed on the forced destination — shift it (no
|
||
// extra RNG draw), mirroring the unforced collision handling below.
|
||
if (oi === di && input.originIcao === undefined) oi = (oi + 1) % airports.length;
|
||
} else {
|
||
di = Math.floor(rng() * airports.length);
|
||
if (di === oi) di = (di + 1) % airports.length;
|
||
// v0.44.0 (ISS-079): cap on generated legs — when BOTH endpoints are unforced, rejection-sample
|
||
// the pair until it fits MAX_JOB_LEG_KM so spawned jobs stay flyable by at least one catalog plane.
|
||
// Forced paths keep their pre-v0.44.0 draw behavior exactly (the engine's coverage spawn caps its
|
||
// random dest itself via pickDestWithinCap). Under-cap pairs take just the two draws above, so the
|
||
// RNG stream is byte-identical to before for them.
|
||
if (input.originIcao === undefined) {
|
||
let km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
|
||
for (let attempt = 1; km > MAX_JOB_LEG_KM && attempt < LEG_CAP_MAX_ATTEMPTS; attempt++) {
|
||
oi = Math.floor(rng() * airports.length);
|
||
di = Math.floor(rng() * airports.length);
|
||
if (di === oi) di = (di + 1) % airports.length;
|
||
km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
|
||
}
|
||
if (km > MAX_JOB_LEG_KM) {
|
||
// Exhausted: keep the last origin and pick uniformly from its within-cap neighbours. If none
|
||
// exist (impossible in the curated dataset — every airport has a neighbour well inside the cap),
|
||
// keep the over-cap pair rather than throw mid-tick.
|
||
const fallback = pickDestWithinCap(airports, oi, rng);
|
||
if (fallback >= 0) di = fallback;
|
||
}
|
||
}
|
||
}
|
||
if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
|
||
const origin = airports[oi];
|
||
const dest = airports[di];
|
||
|
||
const km = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||
const cargoType = CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)];
|
||
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
|
||
const cargoCount =
|
||
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
|
||
// 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()));
|
||
// 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;
|
||
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
|
||
// forced value (including explicit null) skips it, same pattern as the other overrides.
|
||
const special = input.special !== undefined ? input.special : pickSpecial(rng);
|
||
const mods = special !== null ? SPECIALS[special] : undefined;
|
||
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
|
||
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
|
||
|
||
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
||
|
||
return {
|
||
id: input.id,
|
||
originIcao: origin.icao,
|
||
destIcao: dest.icao,
|
||
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
||
cargoType,
|
||
cargoCount,
|
||
weightKg,
|
||
special,
|
||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||
status: 'open',
|
||
planeId: null,
|
||
createdSimTime: nowMs,
|
||
};
|
||
}
|
||
|
||
// 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.
|
||
export function pickDestWithinCap(airports: SeedAirport[], originIdx: number, rng: Rng): number {
|
||
const candidates: number[] = [];
|
||
for (let j = 0; j < airports.length; j++) {
|
||
if (j === originIdx) continue;
|
||
if (haversineKm(airports[originIdx].lat, airports[originIdx].lon, airports[j].lat, airports[j].lon) <= MAX_JOB_LEG_KM) candidates.push(j);
|
||
}
|
||
if (candidates.length === 0) return -1;
|
||
return candidates[Math.floor(rng() * candidates.length)];
|
||
}
|
||
|
||
function pickHub(airports: SeedAirport[]): SeedAirport {
|
||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||
}
|
||
|
||
export function buildSeed(input: {
|
||
airports: SeedAirport[];
|
||
rng: Rng;
|
||
nowMs: number;
|
||
}): SeedData {
|
||
const { airports, rng, nowMs } = input;
|
||
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
||
|
||
const type = PLANE_TYPES[STARTER_MODEL];
|
||
const hub = pickHub(airports);
|
||
|
||
const plane: SeedPlane = {
|
||
id: 'plane-starter',
|
||
model: STARTER_MODEL,
|
||
status: 'available',
|
||
fuel: type.maxFuel, // starts full
|
||
maxFuel: type.maxFuel,
|
||
locationIcao: hub.icao,
|
||
destIcao: null,
|
||
lat: hub.lat,
|
||
lon: hub.lon,
|
||
queue: [], // no queued legs on first run (v0.25.0)
|
||
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.
|
||
const packages: SeedPackage[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
|
||
}
|
||
|
||
return {
|
||
plane,
|
||
packages,
|
||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||
// ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
|
||
// is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
|
||
// install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
|
||
// stability — no game code reads them anymore.
|
||
simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
|
||
};
|
||
}
|