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:
@@ -0,0 +1,26 @@
|
||||
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
|
||||
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
|
||||
|
||||
export interface PlaneType {
|
||||
id: string;
|
||||
name: string;
|
||||
// Fuel capacity, liters.
|
||||
maxFuel: number;
|
||||
// Fuel burn per km of flight, liters/km (cruise).
|
||||
burnPerKm: number;
|
||||
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
|
||||
speedKmh: number;
|
||||
}
|
||||
|
||||
export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
atr72: {
|
||||
id: 'atr72',
|
||||
name: 'ATR 72-600',
|
||||
maxFuel: 6300,
|
||||
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
|
||||
speedKmh: 510,
|
||||
},
|
||||
};
|
||||
|
||||
// The plane the player starts with.
|
||||
export const STARTER_MODEL = 'atr72';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||
import {
|
||||
BASE_REWARD,
|
||||
buildSeed,
|
||||
CONTRACT_COUNT_MAX,
|
||||
CONTRACT_COUNT_MIN,
|
||||
HUB_ICAO,
|
||||
mulberry32,
|
||||
REWARD_PER_KM,
|
||||
rewardForDistanceKm,
|
||||
STARTING_CASH,
|
||||
STARTING_REPUTATION,
|
||||
type SeedAirport,
|
||||
} from './seed';
|
||||
|
||||
const NOW = 1_750_000_000_000;
|
||||
|
||||
// Small fixed fixture — includes the hub (EDDF Frankfurt) so the happy path is covered.
|
||||
const AIRPORTS: SeedAirport[] = [
|
||||
{ icao: 'EDDF', name: 'Frankfurt Main', lat: 50.0333, lon: 8.5706 },
|
||||
{ icao: 'EHAM', name: 'Amsterdam Schiphol', lat: 52.31, lon: 4.76 },
|
||||
{ icao: 'EGLL', name: 'London Heathrow', lat: 51.47, lon: -0.454 },
|
||||
{ icao: 'LEBC', name: 'Madrid Barajas', lat: 40.47, lon: -3.56 },
|
||||
{ icao: 'LIRF', name: 'Rome Fiumicino', lat: 41.8, lon: 12.25 },
|
||||
];
|
||||
|
||||
const seedWith = (rngSeed: number, airports: SeedAirport[] = AIRPORTS) =>
|
||||
buildSeed({ airports, rng: mulberry32(rngSeed), nowMs: NOW });
|
||||
|
||||
describe('buildSeed', () => {
|
||||
it('grounds the starter plane full of fuel at the hub airport', () => {
|
||||
const seed = seedWith(42);
|
||||
const type = PLANE_TYPES[STARTER_MODEL];
|
||||
expect(seed.plane).toEqual({
|
||||
id: 'plane-starter',
|
||||
model: STARTER_MODEL,
|
||||
status: 'available',
|
||||
fuel: type.maxFuel,
|
||||
maxFuel: type.maxFuel,
|
||||
locationIcao: HUB_ICAO,
|
||||
contractId: null,
|
||||
lat: 50.0333,
|
||||
lon: 8.5706,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the first airport when the hub ICAO is missing', () => {
|
||||
const seed = buildSeed({ airports: AIRPORTS.slice(1), rng: mulberry32(42), nowMs: NOW });
|
||||
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
|
||||
});
|
||||
|
||||
it('seeds 8–12 open contracts between distinct fixture airports', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
|
||||
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
|
||||
|
||||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||||
for (const c of seed.contracts) {
|
||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||
expect(c.originIcao).not.toBe(c.destIcao);
|
||||
expect(c.status).toBe('open');
|
||||
expect(c.planeId).toBeNull();
|
||||
expect(c.createdSimTime).toBe(NOW);
|
||||
}
|
||||
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
|
||||
});
|
||||
|
||||
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
|
||||
const seed = seedWith(42);
|
||||
for (const c of seed.contracts) {
|
||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||||
}
|
||||
});
|
||||
|
||||
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
||||
const seed = seedWith(42);
|
||||
for (const c of seed.contracts) {
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('seeds the starting account and sim state', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
|
||||
});
|
||||
|
||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||
expect(seedWith(42)).toEqual(seedWith(42));
|
||||
});
|
||||
|
||||
it('produces different contracts for a different RNG seed', () => {
|
||||
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
|
||||
});
|
||||
|
||||
it('rejects an airport list too small to form a route', () => {
|
||||
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
||||
});
|
||||
|
||||
it('rewardForDistanceKm applies the documented formula', () => {
|
||||
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
|
||||
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
|
||||
export interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
locationName: string;
|
||||
contractId: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface ContractState {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
originName: string;
|
||||
destIcao: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
cargoType: string;
|
||||
cargoCount: number;
|
||||
deadlineSimTime: number;
|
||||
status: 'open' | 'accepted' | 'completed';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
contracts: ContractState[];
|
||||
}
|
||||
|
||||
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const { db } = await getGameDb();
|
||||
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
||||
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
||||
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
||||
const [accountRows, simRows, planeRows, contractRows] = await Promise.all([
|
||||
db.select().from(schema.account),
|
||||
db.select().from(schema.simState),
|
||||
db.select().from(schema.planes),
|
||||
db.select().from(schema.contracts),
|
||||
]);
|
||||
const accountRow = accountRows[0];
|
||||
const simRow = simRows[0];
|
||||
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row');
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
||||
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
|
||||
|
||||
return {
|
||||
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
||||
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
|
||||
planes: planeRows.map((p) => ({
|
||||
id: p.id,
|
||||
model: p.model,
|
||||
status: p.status,
|
||||
fuel: p.fuel,
|
||||
maxFuel: p.maxFuel,
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
})),
|
||||
contracts: contractRows.map((c) => ({
|
||||
id: c.id,
|
||||
originIcao: c.originIcao,
|
||||
originName: nameOf(c.originIcao),
|
||||
destIcao: c.destIcao,
|
||||
destName: nameOf(c.destIcao),
|
||||
reward: c.reward,
|
||||
cargoType: c.cargoType,
|
||||
cargoCount: c.cargoCount,
|
||||
deadlineSimTime: c.deadlineSimTime,
|
||||
status: c.status,
|
||||
planeId: c.planeId,
|
||||
createdSimTime: c.createdSimTime,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user