v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s
CI / build + unit + e2e (pull_request) Successful in 13m46s
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import initSqlJs from 'sql.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
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';
|
||||
@@ -88,6 +89,40 @@ CREATE TABLE sim_state (
|
||||
);
|
||||
`;
|
||||
|
||||
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
|
||||
// file for the owner_id backfill migration test.
|
||||
const PRE_OWNERSHIP_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,
|
||||
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 players (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
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,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
`;
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -350,4 +385,32 @@ describe('openGameDb', () => {
|
||||
expect(tables.has('account')).toBe(false);
|
||||
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
||||
});
|
||||
|
||||
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
|
||||
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(PRE_OWNERSHIP_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
|
||||
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
|
||||
const file = path.join(dir, 'pre-ownership.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Every legacy plane is backfilled to the local player…
|
||||
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||
expect(owners).toEqual(['local', 'local']);
|
||||
// …and the world was NOT re-seeded (the players row already existed).
|
||||
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
|
||||
|
||||
// Second open is a no-op: an owner change made after the migration survives a reopen.
|
||||
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
|
||||
await persist(inst);
|
||||
const second = await openGameDb(file);
|
||||
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
|
||||
expect(owners2).toEqual(['local', 'someone-else']);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-1
@@ -23,6 +23,9 @@ export interface GameDb {
|
||||
const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS planes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
|
||||
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
|
||||
owner_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fuel REAL NOT NULL,
|
||||
@@ -159,6 +162,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
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');
|
||||
}
|
||||
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
|
||||
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
|
||||
// constant default, which SQLite applies to every existing row at ALTER time).
|
||||
if (!cols('planes').has('owner_id')) {
|
||||
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||
@@ -267,7 +276,8 @@ async function seedEmpty(instance: GameDb): Promise<void> {
|
||||
|
||||
// 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 ?? []) });
|
||||
// v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
|
||||
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
|
||||
await instance.db.insert(schema.packages).values(data.packages);
|
||||
// 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 });
|
||||
|
||||
@@ -21,3 +21,20 @@ export async function updateLocalPlayerEconomy(
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
||||
}
|
||||
|
||||
// v1.2.0 (ISS-109): per-player variants — the actions thread the session player's id through and
|
||||
// read/write THAT row instead of the local one; getLocalPlayer stays for the snapshot/engine until
|
||||
// per-player views land in a later multiplayer step.
|
||||
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
|
||||
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
|
||||
if (!row) throw new Error(`player ${id} missing`);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updatePlayerEconomy(
|
||||
inst: GameDb,
|
||||
id: string,
|
||||
patch: { cash?: number; reputation?: number },
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const planes = sqliteTable('planes', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
|
||||
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
|
||||
// onboarding stamp it explicitly on insert.
|
||||
ownerId: text('owner_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
||||
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||
|
||||
Reference in New Issue
Block a user