import { haversineKm } from '@/lib/geo'; import { PLANE_TYPES, STARTER_MODEL } from './catalog'; // 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 ^ (t >>> 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; contractId: null; lat: number; lon: number; } export interface SeedContract { id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number; deadlineSimTime: number; status: 'open'; planeId: null; createdSimTime: number; } export interface SeedData { plane: SeedPlane; contracts: SeedContract[]; account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number }; } // --- Tunables (documented so future revenue/cost work can build on them) --- export const STARTING_CASH = 20_000; export const STARTING_REPUTATION = 10; // 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 CONTRACT_COUNT_MIN = 8; export const CONTRACT_COUNT_MAX = 12; // inclusive // Market target (v0.7.0): the sim engine tops open contracts back up to this // count after expiries, so the job board never runs dry. export const MARKET_TARGET_OPEN_CONTRACTS = 10; // Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a // later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km. export const BASE_REWARD = 500; export const REWARD_PER_KM = 2.5; // Contract 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; export function rewardForDistanceKm(km: number): number { return Math.round(BASE_REWARD + REWARD_PER_KM * km); } // Shared contract generator, used by BOTH the first-run seed and the runtime // market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random // distinct airports from the dataset, distance-based reward, cargo flavor, // 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 contract // is fixed: origin, dest, cargoType, cargoCount, [deadline window]. // v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule + // the e2e create-contract hook); a forced endpoint consumes no RNG draws, so // callers that omit both get byte-identical behavior to before. export function generateContract(input: { id: string; airports: SeedAirport[]; rng: Rng; nowMs: number; deadlineOffsetMs?: number; originIcao?: string; destIcao?: string; }): SeedContract { const { airports, rng, nowMs } = input; if (airports.length < 2) throw new Error('contract 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; } 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); const deadlineMs = input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000; return { id: input.id, originIcao: origin.icao, destIcao: dest.icao, reward: rewardForDistanceKm(km), cargoType, cargoCount, deadlineSimTime: Math.round(nowMs + deadlineMs), status: 'open', planeId: null, createdSimTime: nowMs, }; } 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, contractId: null, lat: hub.lat, lon: hub.lon, }; const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1)); // Stable index-based ids for the seed set; runtime-generated entities must use // random UUIDs to avoid colliding with these. const contracts: SeedContract[] = []; for (let i = 0; i < count; i++) { contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs })); } return { plane, contracts, account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION }, // lastRealTickMs starts at the seed moment so the first tick-on-read advances // by ~0 instead of treating "time since install" as elapsed play time. simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs }, }; }