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;
|
||||
|
||||
+57
-1
@@ -6,6 +6,7 @@ import {
|
||||
buildSeed,
|
||||
CONTRACT_COUNT_MAX,
|
||||
CONTRACT_COUNT_MIN,
|
||||
generateContract,
|
||||
HUB_ICAO,
|
||||
mulberry32,
|
||||
REWARD_PER_KM,
|
||||
@@ -88,7 +89,8 @@ describe('buildSeed', () => {
|
||||
it('seeds the starting account and sim state', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
|
||||
});
|
||||
|
||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||
@@ -108,3 +110,57 @@ describe('buildSeed', () => {
|
||||
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContract (shared seed/spawn builder)', () => {
|
||||
it('builds an open contract between distinct fixture airports with the reward formula', () => {
|
||||
const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||
|
||||
expect(c.id).toBe('c-x');
|
||||
expect(c.status).toBe('open');
|
||||
expect(c.planeId).toBeNull();
|
||||
expect(c.createdSimTime).toBe(NOW);
|
||||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||
expect(c.originIcao).not.toBe(c.destIcao);
|
||||
|
||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||||
|
||||
// Same 6–72h game-time deadline window as the seed.
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
||||
});
|
||||
|
||||
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
||||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||||
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||||
expect(a).toEqual(b);
|
||||
|
||||
const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
|
||||
expect(c.id).toBe('c-2');
|
||||
expect(c.createdSimTime).toBe(NOW + 1000);
|
||||
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract
|
||||
});
|
||||
|
||||
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
|
||||
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
|
||||
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
||||
});
|
||||
|
||||
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
|
||||
const rngA = mulberry32(42);
|
||||
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
|
||||
const expected: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
||||
}
|
||||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
|
||||
});
|
||||
|
||||
it('rejects an airport list too small to form a route', () => {
|
||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+55
-29
@@ -57,7 +57,7 @@ export interface SeedData {
|
||||
plane: SeedPlane;
|
||||
contracts: SeedContract[];
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: false };
|
||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
|
||||
}
|
||||
|
||||
// --- Tunables (documented so future revenue/cost work can build on them) ---
|
||||
@@ -73,6 +73,10 @@ export const HUB_ICAO = 'EDDF'; // Frankfurt Main
|
||||
export const CONTRACT_COUNT_MIN = 8;
|
||||
export const CONTRACT_COUNT_MAX = 12; // inclusive
|
||||
|
||||
// Market target (v0.7.0): the sim engine tops open contracts back up to this
|
||||
// count after expiries, so the job board never runs dry.
|
||||
export const MARKET_TARGET_OPEN_CONTRACTS = 10;
|
||||
|
||||
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
|
||||
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
|
||||
export const BASE_REWARD = 500;
|
||||
@@ -88,6 +92,50 @@ export function rewardForDistanceKm(km: number): number {
|
||||
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
|
||||
}
|
||||
|
||||
// Shared contract generator, used by BOTH the first-run seed and the runtime
|
||||
// market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random
|
||||
// distinct airports from the dataset, distance-based reward, cargo flavor,
|
||||
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
|
||||
// the e2e test hook to force a near-term expiry). The RNG call order per contract
|
||||
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
|
||||
export function generateContract(input: {
|
||||
id: string;
|
||||
airports: SeedAirport[];
|
||||
rng: Rng;
|
||||
nowMs: number;
|
||||
deadlineOffsetMs?: number;
|
||||
}): SeedContract {
|
||||
const { airports, rng, nowMs } = input;
|
||||
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
|
||||
|
||||
const oi = Math.floor(rng() * airports.length);
|
||||
let di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
const origin = airports[oi];
|
||||
const dest = airports[di];
|
||||
|
||||
const km = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
const cargoType = CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)];
|
||||
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
|
||||
const cargoCount =
|
||||
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
|
||||
const deadlineMs =
|
||||
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
originIcao: origin.icao,
|
||||
destIcao: dest.icao,
|
||||
reward: rewardForDistanceKm(km),
|
||||
cargoType,
|
||||
cargoCount,
|
||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function pickHub(airports: SeedAirport[]): SeedAirport {
|
||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||
}
|
||||
@@ -116,41 +164,19 @@ export function buildSeed(input: {
|
||||
};
|
||||
|
||||
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
|
||||
// Stable index-based ids for the seed set; runtime-generated entities must use
|
||||
// random UUIDs to avoid colliding with these.
|
||||
const contracts: SeedContract[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Stable index-based ids for the seed set; runtime-generated entities must
|
||||
// use random UUIDs to avoid colliding with these.
|
||||
const oi = Math.floor(rng() * airports.length);
|
||||
let di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
const origin = airports[oi];
|
||||
const dest = airports[di];
|
||||
|
||||
const km = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
const cargoType = CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)];
|
||||
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
|
||||
const cargoCount =
|
||||
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
|
||||
const deadlineMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
||||
|
||||
contracts.push({
|
||||
id: `contract-${i + 1}`,
|
||||
originIcao: origin.icao,
|
||||
destIcao: dest.icao,
|
||||
reward: rewardForDistanceKm(km),
|
||||
cargoType,
|
||||
cargoCount,
|
||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: nowMs,
|
||||
});
|
||||
contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs }));
|
||||
}
|
||||
|
||||
return {
|
||||
plane,
|
||||
contracts,
|
||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false },
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs },
|
||||
};
|
||||
}
|
||||
|
||||
+36
-13
@@ -1,6 +1,9 @@
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { PLANE_TYPES } from './catalog';
|
||||
import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
|
||||
@@ -27,7 +30,7 @@ export interface ContractState {
|
||||
cargoType: string;
|
||||
cargoCount: number;
|
||||
deadlineSimTime: number;
|
||||
status: 'open' | 'accepted' | 'completed';
|
||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
}
|
||||
@@ -57,22 +60,42 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
||||
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
|
||||
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||
|
||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||
// departure values — live position and fuel are pure functions of the clock.
|
||||
const contractById = new Map(contractRows.map((c) => [c.id, c]));
|
||||
const livePlane = (p: schema.PlaneRow) => {
|
||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) return null;
|
||||
const contract = contractById.get(p.contractId);
|
||||
const type = PLANE_TYPES[p.model];
|
||||
const from = contract ? coordsByIcao.get(contract.originIcao) : undefined;
|
||||
const to = contract ? coordsByIcao.get(contract.destIcao) : undefined;
|
||||
if (!contract || !type || !from || !to) return null;
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||
return { ...pos, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) };
|
||||
};
|
||||
|
||||
return {
|
||||
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
||||
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
|
||||
planes: planeRows.map((p) => ({
|
||||
id: p.id,
|
||||
model: p.model,
|
||||
status: p.status,
|
||||
fuel: p.fuel,
|
||||
maxFuel: p.maxFuel,
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
})),
|
||||
planes: planeRows.map((p) => {
|
||||
const live = livePlane(p);
|
||||
return {
|
||||
id: p.id,
|
||||
model: p.model,
|
||||
status: p.status,
|
||||
fuel: live?.fuel ?? p.fuel,
|
||||
maxFuel: p.maxFuel,
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
};
|
||||
}),
|
||||
contracts: contractRows.map((c) => ({
|
||||
id: c.id,
|
||||
originIcao: c.originIcao,
|
||||
|
||||
+28
-1
@@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the three public endpoints', () => {
|
||||
it('documents exactly the four public endpoints', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
]);
|
||||
@@ -94,5 +95,31 @@ describe('OpenAPI spec', () => {
|
||||
|
||||
const sim = openApiSpec.components.schemas.SimState.properties;
|
||||
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
|
||||
|
||||
const contractStatus = openApiSpec.components.schemas.ContractState.properties.status;
|
||||
expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']);
|
||||
});
|
||||
|
||||
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
|
||||
const post = openApiSpec.paths['/api/sim'].post;
|
||||
expect(post.operationId).toBe('setSimControls');
|
||||
|
||||
const requestSchema = post.requestBody.content['application/json'].schema;
|
||||
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
|
||||
|
||||
const ok = post.responses[200].content['application/json'].schema;
|
||||
expect(ok.$ref).toBe('#/components/schemas/SimState');
|
||||
const bad = post.responses[400].content['application/json'].schema;
|
||||
expect(bad.$ref).toBe('#/components/schemas/Error');
|
||||
|
||||
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
|
||||
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
|
||||
expect(controls.timeScale.type).toBe('number');
|
||||
expect(controls.timeScale.minimum).toBe(1);
|
||||
expect(controls.timeScale.maximum).toBe(60);
|
||||
expect(controls.paused.type).toBe('boolean');
|
||||
|
||||
const error = openApiSpec.components.schemas.Error.properties;
|
||||
expect(Object.keys(error)).toEqual(['error']);
|
||||
});
|
||||
});
|
||||
|
||||
+57
-2
@@ -37,7 +37,7 @@ export const openApiSpec = {
|
||||
get: {
|
||||
summary: 'Game state snapshot',
|
||||
description:
|
||||
'Full current game state for client polling — account, sim clock, planes and contracts. Read-only.',
|
||||
'Full current game state for client polling — account, sim clock, planes and contracts. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
|
||||
operationId: 'getState',
|
||||
responses: {
|
||||
200: {
|
||||
@@ -51,6 +51,40 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/sim': {
|
||||
post: {
|
||||
summary: 'Set sim time controls',
|
||||
description:
|
||||
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; contract expiry, arrival finalization and market spawn run inside that tick.',
|
||||
operationId: 'setSimControls',
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SimControlsRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Updated sim state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SimState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, or wrong types)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/version': {
|
||||
get: {
|
||||
summary: 'App version',
|
||||
@@ -155,6 +189,26 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['clockMs', 'timeScale', 'paused'],
|
||||
},
|
||||
SimControlsRequest: {
|
||||
type: 'object',
|
||||
description: 'Partial update — omit a field to leave it unchanged.',
|
||||
properties: {
|
||||
timeScale: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 60,
|
||||
description: 'Game-time multiplier; values outside [1, 60] are clamped.',
|
||||
},
|
||||
paused: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
},
|
||||
required: ['error'],
|
||||
},
|
||||
PlaneState: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -194,7 +248,8 @@ export const openApiSpec = {
|
||||
cargoType: { type: 'string' },
|
||||
cargoCount: { type: 'integer' },
|
||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||
status: { enum: ['open', 'accepted', 'completed'] },
|
||||
// 'expired' = an open contract whose deadline passed; accepted/in-flight contracts never expire.
|
||||
status: { enum: ['open', 'accepted', 'completed', 'expired'] },
|
||||
planeId: { type: ['string', 'null'] },
|
||||
createdSimTime: { type: 'integer' },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { MARKET_TARGET_OPEN_CONTRACTS, mulberry32 } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor } from './movement';
|
||||
import {
|
||||
expireContracts,
|
||||
finalizeArrivals,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
reputationGainFor,
|
||||
spawnOpenContracts,
|
||||
tickOnce,
|
||||
} from './engine';
|
||||
|
||||
// Engine tests run against throwaway DB files (openGameDb) with fake timers so
|
||||
// Date.now() — the only wall-clock input to a tick — is fully controlled. The
|
||||
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered
|
||||
// by app/api/sim/route.test.ts and the e2e suite.
|
||||
|
||||
let dir: string;
|
||||
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(T0);
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function freshDb(name: string) {
|
||||
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
|
||||
return openGameDb(path.join(dir, name));
|
||||
}
|
||||
|
||||
describe('tickOnce — clock advance', () => {
|
||||
it('advances the clock by elapsed real ms × timeScale', async () => {
|
||||
const inst = await freshDb('scale.db');
|
||||
vi.setSystemTime(T0 + 1_000); // one real second later
|
||||
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||
});
|
||||
|
||||
it('freezes the clock while paused but keeps tracking real time', async () => {
|
||||
const inst = await freshDb('pause.db');
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
|
||||
|
||||
vi.setSystemTime(T0 + 3_600_000); // an hour of real pause
|
||||
let sim = await tickOnce(inst);
|
||||
expect(sim.clockMs).toBe(T0); // frozen — no advance at all
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
||||
|
||||
// Unpause: resumes from where it stopped, with NO catch-up for the pause.
|
||||
await inst.db.update(schema.simState).set({ paused: false });
|
||||
vi.setSystemTime(T0 + 3_600_500);
|
||||
sim = await tickOnce(inst);
|
||||
expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60
|
||||
});
|
||||
|
||||
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
|
||||
const inst = await freshDb('clamp.db');
|
||||
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
|
||||
|
||||
const sim = await tickOnce(inst); // timeScale is the seeded 1
|
||||
|
||||
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward
|
||||
vi.setSystemTime(T0); // reset for later tests in this file
|
||||
});
|
||||
|
||||
it('does not advance when two ticks land in the same millisecond', async () => {
|
||||
const inst = await freshDb('same-ms.db');
|
||||
vi.setSystemTime(T0 + 1_000);
|
||||
const a = await tickOnce(inst);
|
||||
const b = await tickOnce(inst); // no real time passed since `a`
|
||||
expect(b.clockMs).toBe(a.clockMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireContracts', () => {
|
||||
it('flips only open contracts past their deadline; accepted ones never expire', async () => {
|
||||
const inst = await freshDb('expiry.db');
|
||||
const clock = T0 + 100_000;
|
||||
|
||||
await inst.db.insert(schema.contracts).values([
|
||||
{ id: 'c-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'c-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'c-accepted-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'accepted', planeId: null, createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const n = await expireContracts(inst, clock);
|
||||
expect(n).toBe(1);
|
||||
|
||||
const byId = new Map((await inst.db.select().from(schema.contracts)).map((c) => [c.id, c.status]));
|
||||
expect(byId.get('c-open-past')).toBe('expired');
|
||||
expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet
|
||||
expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire
|
||||
});
|
||||
|
||||
it('a deadline exactly at the clock counts as expired', async () => {
|
||||
const inst = await freshDb('expiry-edge.db');
|
||||
await inst.db.insert(schema.contracts).values({
|
||||
id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
|
||||
});
|
||||
expect(await expireContracts(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeArrivals', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
async function setupFlight(name: string) {
|
||||
const inst = await freshDb(name);
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
// A dispatched flight: contract accepted + bound to the plane, plane flying
|
||||
// with its fuel frozen at departure (the v0.8.0 dispatch step writes these).
|
||||
await inst.db.insert(schema.contracts).values({
|
||||
id: 'c-flight', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers',
|
||||
cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: 'plane-starter', createdSimTime: T0,
|
||||
});
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', contractId: 'c-flight', departureSimTime: T0, fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
return { inst, from, to, km, type };
|
||||
}
|
||||
|
||||
it('does nothing before the arrival time', async () => {
|
||||
const { inst, km, type } = await setupFlight('finalize-early.db');
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
||||
expect(await finalizeArrivals(inst, clock)).toBe(0);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(account.cash).toBe(accountBefore.cash); // no award before landing
|
||||
});
|
||||
|
||||
it('awards cash + reputation exactly once and grounds the plane at the destination', async () => {
|
||||
const { inst, to, km, type } = await setupFlight('finalize-late.db');
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await finalizeArrivals(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.locationIcao).toBe(TO);
|
||||
expect(plane.lat).toBe(to.lat);
|
||||
expect(plane.lon).toBe(to.lon);
|
||||
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
|
||||
expect(plane.departureSimTime).toBeNull();
|
||||
|
||||
const contract = (await inst.db.select().from(schema.contracts)).find((c) => c.id === 'c-flight')!;
|
||||
expect(contract.status).toBe('completed');
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // exact reward
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
||||
9,
|
||||
);
|
||||
|
||||
// A later tick must not award again — the plane is no longer 'flying'.
|
||||
expect(await finalizeArrivals(inst, clock + 3_600_000)).toBe(0);
|
||||
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(accountAfter.cash).toBe(account.cash);
|
||||
expect(accountAfter.reputation).toBe(account.reputation);
|
||||
});
|
||||
|
||||
it('leaves planes without route info alone', async () => {
|
||||
const inst = await freshDb('finalize-noroute.db');
|
||||
// Flying plane whose contract row is missing — defensive skip, no crash.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' });
|
||||
expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnOpenContracts', () => {
|
||||
it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => {
|
||||
const inst = await freshDb('spawn.db');
|
||||
const clock = T0 + 123_456;
|
||||
|
||||
// Leave exactly 3 open contracts (the rest of the seeded set is "done").
|
||||
const all = await inst.db.select().from(schema.contracts);
|
||||
for (const c of all.slice(3)) {
|
||||
await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id));
|
||||
}
|
||||
|
||||
const needed = await spawnOpenContracts(inst, clock, mulberry32(7));
|
||||
expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
|
||||
expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
||||
for (const c of openRows.slice(3)) { // the spawned ones
|
||||
expect(c.createdSimTime).toBe(clock); // created at game time, not real time
|
||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||
expect(c.originIcao).not.toBe(c.destIcao);
|
||||
expect(c.planeId).toBeNull();
|
||||
// Same deadline window as the seed: now + 6–72h game time.
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
||||
}
|
||||
|
||||
// Already at target → nothing more to spawn.
|
||||
expect(await spawnOpenContracts(inst, clock, mulberry32(7))).toBe(0);
|
||||
});
|
||||
|
||||
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
|
||||
const a = await freshDb('spawn-a.db');
|
||||
const b = await freshDb('spawn-b.db');
|
||||
const clock = T0 + 999;
|
||||
|
||||
// Wipe all contracts in both so both spawn from zero with the same RNG.
|
||||
for (const inst of [a, b]) {
|
||||
const rows = await inst.db.select().from(schema.contracts);
|
||||
for (const r of rows) {
|
||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, r.id));
|
||||
}
|
||||
}
|
||||
|
||||
expect(await spawnOpenContracts(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
expect(await spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
|
||||
const strip = (rows: schema.ContractRow[]) =>
|
||||
rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
||||
expect(strip(await a.db.select().from(schema.contracts))).toEqual(
|
||||
strip(await b.db.select().from(schema.contracts)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
||||
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
||||
function travelMs(km: number, speedKmh: number): number {
|
||||
return (km / speedKmh) * 3_600_000;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import {
|
||||
generateContract,
|
||||
MARKET_TARGET_OPEN_CONTRACTS,
|
||||
mulberry32,
|
||||
type Rng,
|
||||
type SeedAirport,
|
||||
} from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAt } from './movement';
|
||||
|
||||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||||
// (real ms since last tick) × timeScale, clamped per tick so long absences never
|
||||
// fast-forward the game.
|
||||
|
||||
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms —
|
||||
// comfortably above a normal client poll interval (~1 s), so active play advances
|
||||
// ~real time × scale; small enough that one stale read after an absence moves game
|
||||
// time by at most 2 s × scale (≤ 2 min even at max scale 60). The sim therefore
|
||||
// only meaningfully progresses while actively polled.
|
||||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
|
||||
export const MIN_TIME_SCALE = 1;
|
||||
export const MAX_TIME_SCALE = 60;
|
||||
|
||||
// Reputation gain per completed contract: BASE + PER_KM × great-circle km, rounded
|
||||
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
||||
// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10.
|
||||
export const REP_BASE = 1;
|
||||
export const REP_PER_KM = 0.0005;
|
||||
|
||||
export function reputationGainFor(km: number): number {
|
||||
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface SimControlsInput {
|
||||
timeScale?: number;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the
|
||||
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
||||
// no advance is ever applied twice.
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = chain.then(fn);
|
||||
chain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
// --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
|
||||
|
||||
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
const rows = await inst.db.select().from(schema.simState);
|
||||
const sim = rows[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
|
||||
const nowMs = Date.now();
|
||||
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||||
// While paused the clock is frozen; lastRealTickMs still tracks real time so
|
||||
// unpausing resumes cleanly with no catch-up jump for the pause duration.
|
||||
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
|
||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||
|
||||
if (advanceMs > 0) {
|
||||
await expireContracts(inst, clockMs);
|
||||
await finalizeArrivals(inst, clockMs);
|
||||
await spawnOpenContracts(inst, clockMs);
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
|
||||
await persist(inst);
|
||||
const updated = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!updated) throw new Error('sim_state row missing after tick');
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight
|
||||
// contracts never expire — they complete. Returns the number flipped.
|
||||
export async function expireContracts(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'));
|
||||
let n = 0;
|
||||
for (const c of open) {
|
||||
if (c.deadlineSimTime <= clockMs) {
|
||||
await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Finalize in-flight planes whose arrival time has passed: award the contract
|
||||
// reward (cash) + reputation gain exactly once, mark the contract completed, and
|
||||
// ground the plane at the destination with its remaining fuel. Idempotent — a
|
||||
// finalized plane leaves the 'flying' state, so it can never be awarded twice.
|
||||
export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, contractRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
inst.db.select().from(schema.contracts),
|
||||
]);
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||
const contractById = new Map(contractRows.map((c) => [c.id, c]));
|
||||
|
||||
let n = 0;
|
||||
for (const p of planeRows) {
|
||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue;
|
||||
const contract = contractById.get(p.contractId);
|
||||
const type = PLANE_TYPES[p.model];
|
||||
const from = contract ? byIcao.get(contract.originIcao) : undefined;
|
||||
const to = contract ? byIcao.get(contract.destIcao) : undefined;
|
||||
if (!contract || !type || !from || !to) continue; // no route info — leave alone
|
||||
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||
|
||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg burn.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({
|
||||
status: 'available',
|
||||
locationIcao: to.icao,
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
|
||||
departureSimTime: null,
|
||||
})
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
await inst.db.update(schema.contracts)
|
||||
.set({ status: 'completed' })
|
||||
.where(eq(schema.contracts.id, contract.id));
|
||||
// Fresh account read per award so multiple arrivals in one tick all count.
|
||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||
if (!fresh) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({
|
||||
cash: fresh.cash + contract.reward,
|
||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
||||
});
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the
|
||||
// SAME builder as the first-run seed (generateContract), so spawned contracts are
|
||||
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
|
||||
export async function spawnOpenContracts(
|
||||
inst: GameDb,
|
||||
clockMs: number,
|
||||
rng?: Rng,
|
||||
): Promise<number> {
|
||||
const openCount = (
|
||||
await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'))
|
||||
).length;
|
||||
const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount;
|
||||
if (needed <= 0) return 0;
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||||
icao: f.properties.icao,
|
||||
name: f.properties.name,
|
||||
lat: f.properties.lat,
|
||||
lon: f.properties.lon,
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
await inst.db.insert(schema.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
|
||||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||||
|
||||
export function tickNow(): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => tickOnce(await getGameDb()));
|
||||
}
|
||||
|
||||
// Tick first so the returned state reflects current time, then apply the new
|
||||
// controls. timeScale is clamped to [1, 60]; validation of raw input happens in
|
||||
// the route handler (400 on non-number / non-boolean).
|
||||
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
|
||||
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
|
||||
if (input.paused !== undefined) sets.paused = input.paused;
|
||||
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
const updated = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!updated) throw new Error('sim_state row missing');
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export function clampTimeScale(v: number): number {
|
||||
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { arrivalTimeFor, fuelAt, positionAt, progressAt, travelDurationMs } from './movement';
|
||||
|
||||
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
|
||||
// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts.
|
||||
const DEP = 1_750_000_000_000; // departure sim time (ms)
|
||||
const SPEED = 510; // km/h
|
||||
const BURN = 1.75; // L/km
|
||||
|
||||
// Frankfurt → Madrid, real coordinates from the CSV dataset.
|
||||
const FROM = { lat: 50.0333, lon: 8.5706 };
|
||||
const TO = { lat: 40.47, lon: -3.56 };
|
||||
const KM = haversineKm(FROM.lat, FROM.lon, TO.lat, TO.lon); // ≈ 2190 km
|
||||
|
||||
describe('travelDurationMs / arrivalTimeFor', () => {
|
||||
it('duration is distance/speed converted to ms', () => {
|
||||
expect(travelDurationMs(510, SPEED)).toBe(3_600_000); // one hour at cruise
|
||||
expect(travelDurationMs(KM, SPEED)).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
|
||||
});
|
||||
|
||||
it('rejects a non-positive speed', () => {
|
||||
expect(() => travelDurationMs(100, 0)).toThrow();
|
||||
});
|
||||
|
||||
it('arrival is departure + duration', () => {
|
||||
const arrival = arrivalTimeFor(DEP, KM, SPEED);
|
||||
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
|
||||
expect(arrival).toBeGreaterThan(DEP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressAt', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
|
||||
it('is 0 before departure and at departure', () => {
|
||||
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
|
||||
});
|
||||
|
||||
it('is linear in the middle of the flight', () => {
|
||||
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
|
||||
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
|
||||
});
|
||||
|
||||
it('is exactly 1 at arrival and stays 1 after', () => {
|
||||
const arrival = DEP + duration;
|
||||
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
|
||||
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('a zero-distance leg is complete immediately', () => {
|
||||
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positionAt (great-circle slerp)', () => {
|
||||
it('returns the exact endpoints at progress 0 and 1', () => {
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0)).toEqual({ lat: FROM.lat, lon: FROM.lon });
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1)).toEqual({ lat: TO.lat, lon: TO.lon });
|
||||
});
|
||||
|
||||
it('clamps out-of-range progress to the endpoints', () => {
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, -0.5)).toEqual({ lat: FROM.lat, lon: FROM.lon });
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1.5)).toEqual({ lat: TO.lat, lon: TO.lon });
|
||||
});
|
||||
|
||||
it('stays on the great circle: arc lengths add up to the total distance', () => {
|
||||
for (const t of [0.1, 0.37, 0.5, 0.9]) {
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, t);
|
||||
const d1 = haversineKm(FROM.lat, FROM.lon, p.lat, p.lon);
|
||||
const d2 = haversineKm(p.lat, p.lon, TO.lat, TO.lon);
|
||||
expect(d1 + d2).toBeCloseTo(KM, 6); // slerp keeps the point on the arc
|
||||
}
|
||||
});
|
||||
|
||||
it('the midpoint is equidistant from both ends', () => {
|
||||
const m = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0.5);
|
||||
expect(haversineKm(FROM.lat, FROM.lon, m.lat, m.lon)).toBeCloseTo(KM / 2, 6);
|
||||
expect(haversineKm(m.lat, m.lon, TO.lat, TO.lon)).toBeCloseTo(KM / 2, 6);
|
||||
});
|
||||
|
||||
it('is exact along the equator (great circle = constant latitude)', () => {
|
||||
for (const t of [0.25, 0.5, 0.75]) {
|
||||
const p = positionAt(0, -10, 0, 10, t);
|
||||
expect(p.lat).toBeCloseTo(0, 9);
|
||||
expect(p.lon).toBeCloseTo(-10 + 20 * t, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles the same-point route without dividing by zero', () => {
|
||||
const p = positionAt(48.85, 2.35, 48.85, 2.35, 0.5);
|
||||
expect(p).toEqual({ lat: 48.85, lon: 2.35 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuelAt', () => {
|
||||
it('burns fuel proportionally to distance flown', () => {
|
||||
const start = 6_300; // full ATR tank
|
||||
expect(fuelAt(start, BURN, KM, 0)).toBe(start);
|
||||
expect(fuelAt(start, BURN, KM, 0.5)).toBeCloseTo(start - BURN * KM / 2, 9);
|
||||
expect(fuelAt(start, BURN, KM, 1)).toBeCloseTo(start - BURN * KM, 9);
|
||||
});
|
||||
|
||||
it('clamps at zero instead of going negative', () => {
|
||||
expect(fuelAt(100, BURN, 1_000, 1)).toBe(0); // needs 1750 L, has 100
|
||||
expect(fuelAt(100, BURN, 1_000, 0.01)).toBeCloseTo(100 - 17.5, 9);
|
||||
});
|
||||
|
||||
it('composes with progressAt for a just-before-arrival snapshot', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
const clock = DEP + duration - 1; // one ms before arrival
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
|
||||
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
|
||||
expect(fuelAt(6_300, BURN, KM, progressAt(DEP, KM, SPEED, clock))).toBeCloseTo(
|
||||
6_300 - BURN * KM * (1 - 1 / duration),
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Pure analytic flight math (v0.7.0). Position and fuel of an in-flight plane are
|
||||
// functions of the sim clock — no incremental stepping, so a tick can jump any
|
||||
// distance in game time without losing accuracy. All inputs are explicit; there
|
||||
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
|
||||
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const toDeg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
// Flight duration in ms for a leg of distanceKm at speedKmh.
|
||||
export function travelDurationMs(distanceKm: number, speedKmh: number): number {
|
||||
if (!(speedKmh > 0)) throw new Error('speedKmh must be > 0');
|
||||
return (distanceKm / speedKmh) * 3_600_000;
|
||||
}
|
||||
|
||||
// Sim-clock time at which the leg completes.
|
||||
export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number {
|
||||
return departureSimTime + travelDurationMs(distanceKm, speedKmh);
|
||||
}
|
||||
|
||||
// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before
|
||||
// departure and 1 after arrival. A zero-distance leg is complete immediately.
|
||||
export function progressAt(
|
||||
departureSimTime: number,
|
||||
distanceKm: number,
|
||||
speedKmh: number,
|
||||
clockMs: number,
|
||||
): number {
|
||||
const duration = travelDurationMs(distanceKm, speedKmh);
|
||||
if (duration <= 0) return 1;
|
||||
return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration));
|
||||
}
|
||||
|
||||
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]
|
||||
// (slerp on the unit sphere). Exact endpoints: t=0 → from, t=1 → to.
|
||||
export function positionAt(
|
||||
fromLat: number,
|
||||
fromLon: number,
|
||||
toLat: number,
|
||||
toLon: number,
|
||||
t: number,
|
||||
): { lat: number; lon: number } {
|
||||
if (t <= 0) return { lat: fromLat, lon: fromLon };
|
||||
if (t >= 1) return { lat: toLat, lon: toLon };
|
||||
|
||||
const vec = (lat: number, lon: number): [number, number, number] => {
|
||||
const phi = toRad(lat);
|
||||
const lambda = toRad(lon);
|
||||
return [Math.cos(phi) * Math.cos(lambda), Math.cos(phi) * Math.sin(lambda), Math.sin(phi)];
|
||||
};
|
||||
const a = vec(fromLat, fromLon);
|
||||
const b = vec(toLat, toLon);
|
||||
|
||||
const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
|
||||
const theta = Math.acos(dot);
|
||||
if (theta < 1e-9) return { lat: fromLat, lon: fromLon }; // same point
|
||||
|
||||
const sinTheta = Math.sin(theta);
|
||||
const wA = Math.sin((1 - t) * theta) / sinTheta;
|
||||
const wB = Math.sin(t * theta) / sinTheta;
|
||||
const v: [number, number, number] = [a[0] * wA + b[0] * wB, a[1] * wA + b[1] * wB, a[2] * wA + b[2] * wB];
|
||||
|
||||
return {
|
||||
lat: toDeg(Math.asin(Math.max(-1, Math.min(1, v[2])))),
|
||||
lon: toDeg(Math.atan2(v[1], v[0])),
|
||||
};
|
||||
}
|
||||
|
||||
// Fuel remaining after flying `progress` of a leg of distanceKm. Clamped at 0 —
|
||||
// range exhaustion is not modeled yet (fuel management/refueling comes later).
|
||||
export function fuelAt(startFuel: number, burnPerKm: number, distanceKm: number, progress: number): number {
|
||||
return Math.max(0, startFuel - burnPerKm * distanceKm * progress);
|
||||
}
|
||||
Reference in New Issue
Block a user