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;
+57 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' },
},
+264
View File
@@ -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;
}
+209
View File
@@ -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));
}
+122
View File
@@ -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,
);
});
});
+72
View File
@@ -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);
}