227 lines
9.4 KiB
TypeScript
227 lines
9.4 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 '[]',
|
|
health REAL NOT NULL DEFAULT 100,
|
|
repair_until_sim_time INTEGER
|
|
);
|
|
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,
|
|
special TEXT,
|
|
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,
|
|
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
|
);
|
|
`;
|
|
|
|
// 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.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
|
|
if (!cols('packages').has('special')) {
|
|
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
|
}
|
|
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
|
|
if (!cols('planes').has('health')) {
|
|
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
|
|
}
|
|
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
|
|
if (!cols('planes').has('repair_until_sim_time')) {
|
|
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
|
}
|
|
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
|
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
|
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
|
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
|
|
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). ISS-129 (v0.57.0):
|
|
// lives on globalThis because Next.js can load this module more than once per process —
|
|
// instrumentation.ts and route handlers get separate module instances, so a plain `let` would give
|
|
// each its own in-memory sql.js DB (both persisting to the same file). One world per process.
|
|
const g = globalThis as unknown as { __flightGameDb?: Promise<GameDb> };
|
|
|
|
export function getGameDb(): Promise<GameDb> {
|
|
return (g.__flightGameDb ??= open(defaultDbPath()));
|
|
}
|
|
|
|
export async function openGameDb(file: string): Promise<GameDb> {
|
|
return open(file);
|
|
}
|