Files
Flight-Ops/lib/game/seed.ts
T
kamilkrzywda 69b708dad3
CI / build + unit + e2e (pull_request) Failing after 6m37s
Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1)
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
  leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
  migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
  covering the first queued leg (same requiredFuelFor rule as /fly), the plane
  immediately starts that leg from the arrival airport (stays 'flying',
  departureSimTime = arrival instant, queue shortened). Short tank → grounds
  safely with the queue kept intact; empty queue → unchanged grounding. Cargo
  delivery for the reached airport runs first and is unaffected. Ordering kept:
  delivery still runs before tickOnce's pause check, so a chained plane counts
  as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
  (append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
  plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
  by index; active in-flight leg never affected; 400 non-integer/out-of-range,
  404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
  shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
  CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
  queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
  blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
  right-pane, mapData and seed tests.

v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
2026-09-20 10:39:00 +00:00

208 lines
7.1 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 ^ (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[];
}
export interface SeedPackage {
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;
packages: SeedPackage[];
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 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 — 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;
// 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;
export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
}
// 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-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 package
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
// 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.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
rng: Rng;
nowMs: number;
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
}): 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;
}
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,
destIcao: null,
lat: hub.lat,
lon: hub.lon,
queue: [], // no queued legs on first run (v0.25.0)
};
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.
const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) {
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
}
return {
plane,
packages,
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 },
};
}