import { haversineKm } from '@/lib/geo'; import { travelDurationMs } from '@/lib/sim/movement'; import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { FASTEST_CATALOG_SPEED_KMH, 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; // 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 { 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 // 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; // 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): 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 + // 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; // v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at // least this much after the fastest plane lands, so dispatch + landing are never instant. export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h 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 = { 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. // 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[]; rng: Rng; nowMs: number; deadlineOffsetMs?: number; originIcao?: string; destIcao?: string; 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'); 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())); // 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; // 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; // v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane: // deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a // single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs // (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls // below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of // airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate // ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling. const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS; const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs); // 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: 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. 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]; } // v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse // it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The // caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane. export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane { const type = PLANE_TYPES[STARTER_MODEL]; const hub = pickHub(airports); return { id, 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) }; } 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 plane = buildStarterPlane(airports, 'plane-starter'); const hub = pickHub(airports); // 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[] = []; 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 { 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 }, }; }