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
+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;
}