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:
@@ -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