Files
Flight-Ops/lib/db/client.ts
T
kamilkrzywda 179fad450d
CI / build + unit + e2e (pull_request) Successful in 8m2s
Package weight, payload capacity and per-kg payout (ISS-045)
Packages carry a real weightKg (unit weight x count with +/-30% jitter),
plane capacity becomes maxPayloadKg instead of 6 slots, load validation
sums cargo weight against the payload, and the payout gains a per-kg part.
Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for
maxPayloadKg and adds PackageState.weightKg.
2026-09-20 12:42:03 +00:00

202 lines
7.8 KiB
TypeScript

import { randomInt } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildSeed, mulberry32 } from '@/lib/game/seed';
import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
// alpine Docker image without a toolchain. Trade-off: the DB lives in memory and
// every write must be exported to disk; persist() does that atomically (tmp file
// + rename) and is called after each mutation.
export interface GameDb {
db: SQLJsDatabase<typeof schema>;
raw: RawSqlJsDatabase;
file: string;
}
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync.
const DDL = `
CREATE TABLE IF NOT EXISTS planes (
id TEXT PRIMARY KEY NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL,
origin_icao TEXT NOT NULL,
dest_icao TEXT NOT NULL,
reward REAL NOT NULL,
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
created_sim_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS account (
cash REAL NOT NULL,
reputation REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS sim_state (
clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL,
paused INTEGER NOT NULL,
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
);
`;
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
// already have everything from DDL above and this is a no-op for them.
function migrateColumns(raw: RawSqlJsDatabase): void {
const cols = (table: string): Set<string> =>
new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1])));
if (!cols('sim_state').has('last_real_tick_ms')) {
raw.exec('ALTER TABLE sim_state ADD COLUMN last_real_tick_ms INTEGER NOT NULL DEFAULT 0');
}
if (!cols('planes').has('departure_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER');
}
// v0.12.0: the flight destination column (set by fly, cleared on arrival).
if (!cols('planes').has('dest_icao')) {
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
}
// v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs.
if (!cols('planes').has('queue')) {
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
}
// v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default.
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
}
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
// presence of the old `contracts` table (fresh and already-migrated DBs never
// have it, so this runs exactly once per file). Preserves account/sim_state rows
// and every plane row; planes are reset to GROUNDED at their locationIcao with
// departure_sim_time/dest_icao cleared — an in-flight plane from the old state
// simply lands back where it took off. Old contract data is wiped (the packages
// table starts empty and the engine's spawn rules reseed the market on the next
// tick). The orphaned pre-v0.12.0 `contract_id` column is dropped too (SQLite
// ≥3.35 DROP COLUMN; guarded so a failure can never block startup).
function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
const cols = new Set(
raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])),
);
raw.exec(
`UPDATE planes SET status = 'available', departure_sim_time = NULL, dest_icao = NULL` +
(cols.has('contract_id') ? ', contract_id = NULL' : ''),
);
if (cols.has('contract_id')) {
try {
raw.exec('ALTER TABLE planes DROP COLUMN contract_id');
} catch {
// Leaving the orphaned column is harmless — nothing references it anymore.
}
}
raw.exec('DROP TABLE contracts');
}
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
// of the real ./db/flight.db.
export function defaultDbPath(): string {
return process.env.FLIGHT_DB_PATH ?? path.join(process.cwd(), 'db', 'flight.db');
}
async function open(file: string): Promise<GameDb> {
const SQL = await initSqlJs();
let bytes: Buffer | undefined;
try {
bytes = await fs.readFile(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL); // no-op for tables that already exist in an older file
migrateColumns(raw);
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
// without this it would keep the dead contracts table forever.
const tables = new Set(
raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
if (tables.has('contracts')) {
migrateContractsToPackages(raw);
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
await seedEmpty(instance);
}
// v0.7.0 fixup: pre-v0.7.0 DBs got last_real_tick_ms = 0 from the ALTER TABLE
// default. Reset it to "now" so the first tick-on-read resumes cleanly instead
// of taking one clamped catch-up jump. Fresh seeds already carry a real value.
const simRows = raw.exec('SELECT clock_ms, last_real_tick_ms FROM sim_state');
if (simRows[0]?.values?.length && Number(simRows[0].values[0][1]) === 0) {
raw.exec(`UPDATE sim_state SET last_real_tick_ms = ${Date.now()}`);
await persist(instance);
}
return instance;
}
async function seedEmpty(instance: GameDb): Promise<void> {
const fc = await loadAirportsGeoJson();
const airports = fc.features.map((f) => ({
icao: f.properties.icao,
name: f.properties.name,
lat: f.properties.lat,
lon: f.properties.lon,
}));
// Random per-install seed; tests call buildSeed directly with a fixed RNG.
const rng = mulberry32(randomInt(0, 2 ** 31));
const data = buildSeed({ airports, rng, nowMs: Date.now() });
// SeedPlane.queue is a string[]; the column stores it as JSON text.
const { queue, ...planeCols } = data.plane;
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState);
await persist(instance);
}
// Durably save the in-memory DB: export bytes, write to a temp file, rename over
// the target so a crash mid-write never leaves a truncated DB.
export async function persist(instance: GameDb): Promise<void> {
const bytes = instance.raw.export();
await fs.mkdir(path.dirname(instance.file), { recursive: true });
const tmp = `${instance.file}.tmp`;
await fs.writeFile(tmp, Buffer.from(bytes));
await fs.rename(tmp, instance.file);
}
// Singleton for the app process; openGameDb() is the uncached variant (tests).
let ready: Promise<GameDb> | null = null;
export function getGameDb(): Promise<GameDb> {
if (!ready) ready = open(defaultDbPath());
return ready;
}
export async function openGameDb(file: string): Promise<GameDb> {
return open(file);
}