feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot

This commit is contained in:
2026-09-17 11:51:42 +00:00
parent 4d9a1e9fa9
commit 7fdc43bc7e
23 changed files with 1006 additions and 8 deletions
+156
View File
@@ -0,0 +1,156 @@
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 };
}
// --- 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
// 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);
}
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));
const contracts: SeedContract[] = [];
for (let i = 0; i < count; i++) {
// Stable index-based ids for the seed set; runtime-generated entities must
// use random UUIDs to avoid colliding with these.
const oi = Math.floor(rng() * airports.length);
let di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
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 = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
contracts.push({
id: `contract-${i + 1}`,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardForDistanceKm(km),
cargoType,
cargoCount,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
createdSimTime: nowMs,
});
}
return {
plane,
contracts,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
simState: { clockMs: nowMs, timeScale: 1, paused: false },
};
}