feat: v0.7.0 — server-authoritative sim engine: tick-on-read clock (time-scale + pause via POST /api/sim), contract expiry + market spawn, analytic plane movement/finalization
This commit is contained in:
@@ -1,10 +1,47 @@
|
||||
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 { openGameDb, persist } from './client';
|
||||
import * as schema from './schema';
|
||||
|
||||
// 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
|
||||
);
|
||||
`;
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -66,4 +103,29 @@ describe('openGameDb', () => {
|
||||
const reloaded = await openGameDb(file);
|
||||
expect((await reloaded.db.select().from(schema.account))[0]?.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)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)');
|
||||
const file = path.join(dir, 'legacy.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Not re-seeded (account row exists)…
|
||||
expect((await inst.db.select().from(schema.account))[0]?.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);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-2
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS planes (
|
||||
location_icao TEXT NOT NULL,
|
||||
contract_id TEXT,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
lon REAL NOT NULL,
|
||||
departure_sim_time INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contracts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -50,10 +51,25 @@ CREATE TABLE IF NOT EXISTS account (
|
||||
CREATE TABLE IF NOT EXISTS sim_state (
|
||||
clock_ms INTEGER NOT NULL,
|
||||
time_scale REAL NOT NULL,
|
||||
paused INTEGER NOT NULL
|
||||
paused INTEGER NOT NULL,
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||
// already have everything from DDL above and this is a no-op for them.
|
||||
function migrate(raw: RawSqlJsDatabase): void {
|
||||
const cols = (table: string): Set<string> =>
|
||||
new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1])));
|
||||
if (!cols('sim_state').has('last_real_tick_ms')) {
|
||||
raw.exec('ALTER TABLE sim_state ADD COLUMN last_real_tick_ms INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!cols('planes').has('departure_sim_time')) {
|
||||
raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -70,6 +86,7 @@ async function open(file: string): Promise<GameDb> {
|
||||
}
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL);
|
||||
migrate(raw);
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
@@ -78,6 +95,15 @@ async function open(file: string): Promise<GameDb> {
|
||||
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
||||
await seedEmpty(instance);
|
||||
}
|
||||
|
||||
// v0.7.0 fixup: pre-v0.7.0 DBs got last_real_tick_ms = 0 from the ALTER TABLE
|
||||
// default. Reset it to "now" so the first tick-on-read resumes cleanly instead
|
||||
// of taking one clamped catch-up jump. Fresh seeds already carry a real value.
|
||||
const simRows = raw.exec('SELECT clock_ms, last_real_tick_ms FROM sim_state');
|
||||
if (simRows[0]?.values?.length && Number(simRows[0].values[0][1]) === 0) {
|
||||
raw.exec(`UPDATE sim_state SET last_real_tick_ms = ${Date.now()}`);
|
||||
await persist(instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -14,6 +14,10 @@ export const planes = sqliteTable('planes', {
|
||||
contractId: text('contract_id'),
|
||||
lat: real('lat').notNull(),
|
||||
lon: real('lon').notNull(),
|
||||
// Sim-clock departure time of the current flight (null when available). While
|
||||
// flying, `fuel` is frozen at its departure value — live position/fuel are pure
|
||||
// functions of the clock (lib/sim/movement.ts), recomputed on read.
|
||||
departureSimTime: integer('departure_sim_time'),
|
||||
});
|
||||
|
||||
export const contracts = sqliteTable('contracts', {
|
||||
@@ -24,7 +28,9 @@ export const contracts = sqliteTable('contracts', {
|
||||
cargoType: text('cargo_type').notNull(),
|
||||
cargoCount: integer('cargo_count').notNull(),
|
||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||
status: text('status', { enum: ['open', 'accepted', 'completed'] }).notNull(),
|
||||
// 'expired' = an open contract whose deadline passed (v0.7.0). Accepted/
|
||||
// in-flight contracts never expire — they complete.
|
||||
status: text('status', { enum: ['open', 'accepted', 'completed', 'expired'] }).notNull(),
|
||||
planeId: text('plane_id'),
|
||||
createdSimTime: integer('created_sim_time').notNull(),
|
||||
});
|
||||
@@ -38,6 +44,9 @@ export const simState = sqliteTable('sim_state', {
|
||||
clockMs: integer('clock_ms').notNull(),
|
||||
timeScale: real('time_scale').notNull(),
|
||||
paused: integer('paused', { mode: 'boolean' }).notNull(),
|
||||
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
|
||||
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
|
||||
lastRealTickMs: integer('last_real_tick_ms').notNull(),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user