CI / build + unit + e2e (pull_request) Failing after 6m37s
- 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.
208 lines
7.1 KiB
TypeScript
208 lines
7.1 KiB
TypeScript
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 },
|
||
};
|
||
}
|