v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s

This commit is contained in:
2026-09-24 14:26:34 +00:00
parent 837aa60a7d
commit b2034ebc52
21 changed files with 258 additions and 166 deletions
+29 -5
View File
@@ -5,6 +5,7 @@ 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 { LOCAL_PLAYER } from './localPlayer';
import * as schema from './schema';
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
@@ -51,7 +52,12 @@ CREATE TABLE IF NOT EXISTS packages (
created_real_ms INTEGER NOT NULL DEFAULT 0,
slot_free_at_real_ms INTEGER
);
CREATE TABLE IF NOT EXISTS account (
-- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local
-- single-player row is seeded by seedEmpty() or migrated from account in open().
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL,
reputation REAL NOT NULL
);
@@ -182,6 +188,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
raw.exec('DROP TABLE contracts');
}
// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop
// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have
// `account`, so this runs exactly once per legacy file and is a no-op on later opens.
function migrateAccountToPlayers(raw: RawSqlJsDatabase): void {
raw.run(
'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account',
[LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username],
);
raw.exec('DROP TABLE account');
}
// 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 {
@@ -209,12 +226,18 @@ async function open(file: string): Promise<GameDb> {
if (tables.has('contracts')) {
migrateContractsToPackages(raw);
}
// v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a
// seeded local player and drop it. The players-empty guard makes this idempotent even if a file
// somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file.
const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0);
if (tables.has('account') && playerCount === 0) {
migrateAccountToPlayers(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) {
// First-run seed: only when the DB is empty (no player row yet).
if (playerCount === 0 && !tables.has('account')) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance);
}
@@ -246,7 +269,8 @@ async function seedEmpty(instance: GameDb): Promise<void> {
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);
// v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
await instance.db.insert(schema.simState).values(data.simState);
await persist(instance);
}