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
+48 -13
View File
@@ -4,10 +4,14 @@ 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_CLOCK_MS } from '@/lib/game/seed';
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 (
@@ -110,8 +114,13 @@ describe('openGameDb', () => {
expect(packages.length).toBeGreaterThanOrEqual(8);
expect(packages.every((p) => p.status === 'open')).toBe(true);
const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0);
// 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);
@@ -126,25 +135,25 @@ describe('openGameDb', () => {
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 account1 = (await first.db.select().from(schema.account))[0];
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 second.db.select().from(schema.account))[0]).toEqual(account1);
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 inst.db.update(schema.account).set({ cash: 12345 });
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 reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345);
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 () => {
@@ -161,8 +170,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file);
// Not re-seeded (account row exists)…
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
// 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();
@@ -194,8 +203,8 @@ describe('openGameDb', () => {
const inst = await openGameDb(file);
// Wallet + sim clock survive byte-identical…
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
// 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)…
@@ -257,8 +266,8 @@ describe('openGameDb', () => {
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.
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
// 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 () => {
@@ -315,4 +324,30 @@ describe('openGameDb', () => {
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);
});
});
+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);
}
+23
View File
@@ -0,0 +1,23 @@
import { eq } from 'drizzle-orm';
import type { GameDb } from './client';
import * as schema from './schema';
// v1.0.0 (ISS-105): TEMPORARY single-player economy access — resolves the local player row so all
// pre-multiplayer code keeps working while `account` is replaced by `players`. A later step
// parameterizes these by playerId for real multiplayer; until then every read/write of the
// singleton economy goes through this file and nowhere else.
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
export async function getLocalPlayer(inst: GameDb): Promise<schema.PlayerRow> {
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, LOCAL_PLAYER.id));
if (!row) throw new Error('local player row missing');
return row;
}
export async function updateLocalPlayerEconomy(
inst: GameDb,
patch: { cash?: number; reputation?: number },
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
}
+8 -2
View File
@@ -66,7 +66,13 @@ export const packages = sqliteTable('packages', {
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
});
export const account = sqliteTable('account', {
// v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local
// single-player row (id 'local') is seeded on first run / migrated from `account` by open();
// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only.
export const players = sqliteTable('players', {
id: text('id').primaryKey().notNull(),
provider: text('provider').notNull(),
username: text('username').notNull(),
cash: real('cash').notNull(),
reputation: real('reputation').notNull(),
});
@@ -104,7 +110,7 @@ export const sessions = sqliteTable('sessions', {
export type PlaneRow = typeof planes.$inferSelect;
export type PackageRow = typeof packages.$inferSelect;
export type AccountRow = typeof account.$inferSelect;
export type PlayerRow = typeof players.$inferSelect;
export type SimStateRow = typeof simState.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;