CI / build + unit + e2e (pull_request) Successful in 8m2s
Packages carry a real weightKg (unit weight x count with +/-30% jitter), plane capacity becomes maxPayloadKg instead of 6 slots, load validation sums cargo weight against the payload, and the payout gains a per-kg part. Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for maxPayloadKg and adds PackageState.weightKg.
148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
import { loadAirportsGeoJson } from '@/lib/airports';
|
|
import { haversineKm } from '@/lib/geo';
|
|
import { getGameDb } from '@/lib/db/client';
|
|
import * as schema from '@/lib/db/schema';
|
|
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
|
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
|
|
|
|
// Read-only game-state snapshot for client polling (GET /api/state).
|
|
|
|
export interface PlaneState {
|
|
id: string;
|
|
model: string;
|
|
status: 'available' | 'flying';
|
|
fuel: number;
|
|
maxFuel: number;
|
|
locationIcao: string;
|
|
locationName: string;
|
|
// Current flight destination (v0.12.0) — set by fly, null when grounded. The
|
|
// origin of the current leg is always locationIcao while flying.
|
|
destIcao: string | null;
|
|
destName: string | null;
|
|
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
|
|
// set by fly while flying so clients can compute progress/ETA from the clock.
|
|
departureSimTime: number | null;
|
|
lat: number;
|
|
lon: number;
|
|
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
|
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
|
queue: string[];
|
|
}
|
|
|
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
|
// null/empty/malformed values so a corrupt cell can never break a snapshot or tick.
|
|
export function parseIcaoQueue(raw: string | null): string[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const v: unknown = JSON.parse(raw);
|
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export interface PackageState {
|
|
id: string;
|
|
originIcao: string;
|
|
originName: string;
|
|
destIcao: string;
|
|
destName: string;
|
|
reward: number;
|
|
cargoType: string;
|
|
cargoCount: number;
|
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
|
weightKg: number;
|
|
deadlineSimTime: number;
|
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
|
planeId: string | null;
|
|
createdSimTime: number;
|
|
}
|
|
|
|
export interface StateSnapshot {
|
|
account: { cash: number; reputation: number };
|
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
|
planes: PlaneState[];
|
|
packages: PackageState[];
|
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
|
// the client can compute buy/sell/refuel previews without a separate endpoint.
|
|
catalog: CatalogEntry[];
|
|
fuelPricePerL: number;
|
|
sellFraction: number;
|
|
}
|
|
|
|
export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|
const { db } = await getGameDb();
|
|
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
|
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
|
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
|
const [accountRows, simRows, planeRows, packageRows] = await Promise.all([
|
|
db.select().from(schema.account),
|
|
db.select().from(schema.simState),
|
|
db.select().from(schema.planes),
|
|
db.select().from(schema.packages),
|
|
]);
|
|
const accountRow = accountRows[0];
|
|
const simRow = simRows[0];
|
|
if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row');
|
|
|
|
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 livePlane = (p: schema.PlaneRow) => {
|
|
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) return null;
|
|
const type = PLANE_TYPES[p.model];
|
|
const from = coordsByIcao.get(p.locationIcao);
|
|
const to = coordsByIcao.get(p.destIcao);
|
|
if (!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: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
|
|
};
|
|
|
|
return {
|
|
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
|
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
|
|
catalog: catalogEntries(),
|
|
fuelPricePerL: FUEL_PRICE_PER_L,
|
|
sellFraction: SELL_FRACTION,
|
|
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),
|
|
destIcao: p.destIcao,
|
|
destName: p.destIcao ? nameOf(p.destIcao) : null,
|
|
departureSimTime: p.departureSimTime,
|
|
lat: live?.lat ?? p.lat,
|
|
lon: live?.lon ?? p.lon,
|
|
queue: parseIcaoQueue(p.queue),
|
|
};
|
|
}),
|
|
packages: packageRows.map((p) => ({
|
|
id: p.id,
|
|
originIcao: p.originIcao,
|
|
originName: nameOf(p.originIcao),
|
|
destIcao: p.destIcao,
|
|
destName: nameOf(p.destIcao),
|
|
reward: p.reward,
|
|
cargoType: p.cargoType,
|
|
cargoCount: p.cargoCount,
|
|
weightKg: p.weightKg,
|
|
deadlineSimTime: p.deadlineSimTime,
|
|
status: p.status,
|
|
planeId: p.planeId,
|
|
createdSimTime: p.createdSimTime,
|
|
})),
|
|
};
|
|
}
|