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:
2026-09-17 12:38:52 +00:00
parent 7fdc43bc7e
commit 96835e935d
22 changed files with 1393 additions and 55 deletions
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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;