v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s
CI / build + unit + e2e (pull_request) Successful in 14m25s
This commit is contained in:
+48
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user