Files
Flight-Ops/lib/game/seed.ts

332 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
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;
// 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<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.
// 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.
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;
// 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,
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 },
};
}