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

204 lines
6.9 KiB
TypeScript
Raw 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 { 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 },
};
}