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
+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,