import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import initSqlJs from 'sql.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed'; import { openGameDb, persist } from './client'; import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer'; import * as schema from './schema'; // v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry. const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' }; // The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. const LEGACY_DDL = ` CREATE TABLE 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, contract_id TEXT, lat REAL NOT NULL, lon REAL NOT NULL ); CREATE TABLE contracts ( 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, deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, created_sim_time INTEGER NOT NULL ); CREATE TABLE account ( cash REAL NOT NULL, reputation REAL NOT NULL ); CREATE TABLE sim_state ( clock_ms INTEGER NOT NULL, time_scale REAL NOT NULL, paused INTEGER NOT NULL ); `; // The v0.11.0 DDL — the last schema before the packages pivot; used to build a // "pre-v0.12.0" DB file for the migration test (has departure_sim_time + // contract_id, has contracts, no packages/dest_icao). const PRE_V12_DDL = ` CREATE TABLE 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, contract_id TEXT, lat REAL NOT NULL, lon REAL NOT NULL, departure_sim_time INTEGER ); CREATE TABLE contracts ( 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, deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, created_sim_time INTEGER NOT NULL ); CREATE TABLE account ( cash REAL NOT NULL, reputation REAL NOT NULL ); CREATE TABLE 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 ); `; let dir: string; beforeAll(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-db-')); }); afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); describe('openGameDb', () => { it('seeds a fresh DB file on first open and persists it to disk', async () => { const file = path.join(dir, 'fresh.db'); const inst = await openGameDb(file); const planes = await inst.db.select().from(schema.planes); expect(planes).toHaveLength(1); expect(planes[0].id).toBe('plane-starter'); expect(planes[0].status).toBe('available'); expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV expect(planes[0].fuel).toBe(planes[0].maxFuel); const packages = await inst.db.select().from(schema.packages); expect(packages.length).toBeGreaterThanOrEqual(8); expect(packages.every((p) => p.status === 'open')).toBe(true); // v1.0.0 (ISS-105): the singleton economy is now a seeded local player row… expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION }); // …and fresh DBs never create an `account` table. const tables = new Set( inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), ); expect(tables.has('account')).toBe(false); const sim = (await inst.db.select().from(schema.simState))[0]; // ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate. expect(sim?.clockMs).toBe(STARTING_CLOCK_MS); expect(sim?.timeScale).toBe(WORLD_TIME_SCALE); // The seed was durably written: the file exists and is non-empty. const st = await fs.stat(file); expect(st.size).toBeGreaterThan(0); }); it('does not re-seed an existing DB — state survives reopen', async () => { const file = path.join(dir, 'reopen.db'); const first = await openGameDb(file); const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort(); const player1 = await getLocalPlayer(first); // Simulate a process restart: brand-new instance over the same file. const second = await openGameDb(file); const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort(); expect(packages2).toEqual(packages1); expect(await getLocalPlayer(second)).toEqual(player1); }); it('persist() durably saves writes made after open', async () => { const file = path.join(dir, 'write.db'); const inst = await openGameDb(file); await updateLocalPlayerEconomy(inst, { cash: 12345 }); await persist(inst); // A fresh instance over the same file sees the update. const reloaded = await openGameDb(file); expect((await getLocalPlayer(reloaded)).cash).toBe(12345); }); it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { // Build a legacy file with the v0.6.0 schema (no departure_sim_time / last_real_tick_ms). const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(LEGACY_DDL); raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); raw.exec(`INSERT INTO account VALUES (20000, 10)`); // time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale. raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)'); const file = path.join(dir, 'legacy.db'); await fs.writeFile(file, Buffer.from(raw.export())); const inst = await openGameDb(file); // Not re-seeded (the legacy account row was migrated into the local player)… expect((await getLocalPlayer(inst)).cash).toBe(20000); // …but the new columns exist and are usable… const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane?.departureSimTime ?? null).toBeNull(); const sim = (await inst.db.select().from(schema.simState))[0]; expect(sim?.clockMs).toBe(1_750_000_000_000); // …and the ALTER-default 0 was fixed up to a real timestamp so the first // tick-on-read resumes cleanly instead of taking one clamped catch-up jump. expect(sim?.lastRealTickMs).toBeGreaterThan(0); // v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default. expect(sim?.timeScale).toBe(50); expect(sim?.lastSelectedTimeScale).toBe(50); }); it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => { // Build a v0.11.0 file with an IN-FLIGHT plane (old dispatch state) and live contracts. const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(PRE_V12_DDL); raw.exec( `INSERT INTO planes VALUES ('plane-starter', 'atr72', 'flying', 4000, 6300, 'EDDF', 'c-1', 50.03, 8.57, 1789000000000)`, ); raw.exec( `INSERT INTO contracts VALUES ('c-1', 'EDDF', 'LEMD', 5000, 'Mail', 4, 1789100000000, 'accepted', 'plane-starter', 1788900000000)`, ); raw.exec(`INSERT INTO account VALUES (54020.34, 62.57)`); raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288)'); const file = path.join(dir, 'pre-v12.db'); await fs.writeFile(file, Buffer.from(raw.export())); const inst = await openGameDb(file); // Wallet (migrated into the local player) + sim clock survive byte-identical… expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 }); expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605); // …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)… const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); expect(plane.locationIcao).toBe('EDDF'); // unchanged — grounded AT the origin expect(plane.departureSimTime ?? null).toBeNull(); expect(plane.destIcao ?? null).toBeNull(); // …old contract data is wiped and the packages table exists (empty until spawn reseeds)… let contractsGone = false; try { inst.raw.exec('SELECT COUNT(*) FROM contracts'); } catch { contractsGone = true; } expect(contractsGone).toBe(true); const packages = await inst.db.select().from(schema.packages); expect(packages).toHaveLength(0); // …and the orphaned contract_id column is dropped from planes. const cols = new Set(inst.raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1]))); expect(cols.has('contract_id')).toBe(false); expect(cols.has('dest_icao')).toBe(true); }); // v0.71.0: the seeded admin row — hash-only, idempotent across every open(). const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf'; // The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(), // so it never verified; production DBs seeded by that version carry exactly this value. const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0'; it('seeds exactly one admin user on first open and never duplicates on reopen', async () => { const file = path.join(dir, 'admin-seed.db'); const inst = await openGameDb(file); let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'"); expect(rows[0].values).toHaveLength(1); expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]); // Simulate a process restart: brand-new instance over the same file. const second = await openGameDb(file); rows = second.raw.exec('SELECT COUNT(*) FROM users'); expect(Number(rows[0].values[0][0])).toBe(1); }); it('seeds the admin into a legacy DB that predates the users table', async () => { // A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none). const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(LEGACY_DDL); raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); raw.exec(`INSERT INTO account VALUES (20000, 10)`); raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)'); const file = path.join(dir, 'legacy-admin.db'); await fs.writeFile(file, Buffer.from(raw.export())); const inst = await openGameDb(file); const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'"); expect(rows[0].values).toHaveLength(1); expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]); // The game world is untouched by the seed (its account row migrated to the local player). expect((await getLocalPlayer(inst)).cash).toBe(20000); }); it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => { // A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest. const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(`CREATE TABLE users ( id TEXT PRIMARY KEY NOT NULL, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, admin INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL )`); raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [ 'admin-kamilkrzywda', 'kamilkrzywda', LEGACY_HASH, Date.now(), ]); const file = path.join(dir, 'admin-rotate.db'); await fs.writeFile(file, Buffer.from(raw.export())); // First open heals the row to the current digest… const first = await openGameDb(file); let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'"); expect(rows[0].values).toHaveLength(1); expect(rows[0].values[0][0]).toBe(ADMIN_HASH); // …and a second open is a no-op (idempotent rotation). const second = await openGameDb(file); rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'"); expect(rows[0].values[0][0]).toBe(ADMIN_HASH); }); it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => { const file = path.join(dir, 'pre-v12-reopen.db'); const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(PRE_V12_DDL); raw.exec( `INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL)`, ); raw.exec(`INSERT INTO account VALUES (1000, 1)`); raw.exec('INSERT INTO sim_state VALUES (1789000000000, 1, 0, 1789000000000)'); await fs.writeFile(file, Buffer.from(raw.export())); const first = await openGameDb(file); // migration runs here // Simulate post-migration play: the plane flies again. await first.db.update(schema.planes).set({ status: 'flying', destIcao: 'LEMD', departureSimTime: 1789000001000 }); await persist(first); const second = await openGameDb(file); // must NOT ground the plane again const plane = (await second.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.destIcao).toBe('LEMD'); }); it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => { // Minimal legacy file — just an `account` table; open() creates everything else from DDL. const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`); raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`); const file = path.join(dir, 'pre-v1.db'); await fs.writeFile(file, Buffer.from(raw.export())); const inst = await openGameDb(file); expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 }); let tables = new Set( inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), ); expect(tables.has('account')).toBe(false); // Second open is a no-op (idempotent): the row survives unchanged, still exactly one player. const second = await openGameDb(file); expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 }); tables = new Set( second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), ); expect(tables.has('account')).toBe(false); expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1); }); });