feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+14 -18
View File
@@ -1,9 +1,10 @@
import type { StateSnapshot } from './game/state';
import { formatFuel } from './format';
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature
// per plane, one LineString per accepted/in-flight contract. Kept out of
// components/Map.tsx so the classification logic is unit-testable without a DOM.
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
// current leg (origin → dest_icao). Kept out of components/Map.tsx so the
// classification logic is unit-testable without a DOM.
export interface PlaneFeatureProps {
id: string;
@@ -15,8 +16,7 @@ export interface PlaneFeatureProps {
}
export interface RouteFeatureProps {
contractId: string;
kind: 'pending' | 'inflight';
planeId: string;
}
export interface MapPointFeature {
@@ -41,12 +41,10 @@ const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollectio
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
if (!state) return EMPTY_POINTS;
const contractById = new Map(state.contracts.map((c) => [c.id, c]));
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
const features: MapPointFeature[] = state.planes.map((p) => {
const flying = p.status === 'flying';
const contract = p.contractId ? contractById.get(p.contractId) : undefined;
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
@@ -55,7 +53,7 @@ export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<Ma
modelLabel: labelByModel.get(p.model) ?? p.model,
status: p.status,
fuelText: formatFuel(p.fuel, p.maxFuel),
routeText: flying && contract ? `${contract.originName} → ${contract.destName}` : '',
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
flying,
},
};
@@ -69,22 +67,20 @@ export interface AirportCoord {
lon: number;
}
// One LineString per contract that is accepted but not yet dispatched ('pending')
// or already flying ('inflight' — planeId set and that plane's status is 'flying').
// Open/completed/expired contracts draw nothing.
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
// Grounded planes draw nothing; a flying plane missing either endpoint from the
// dataset is skipped, never guessed.
export function routesGeoJson(
state: StateSnapshot | null,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
if (!state) return EMPTY_LINES;
const planeById = new Map(state.planes.map((p) => [p.id, p]));
const features: MapLineFeature[] = [];
for (const c of state.contracts) {
if (c.status !== 'accepted') continue;
const from = airportsByIcao.get(c.originIcao);
const to = airportsByIcao.get(c.destIcao);
for (const p of state.planes) {
if (p.status !== 'flying' || !p.destIcao) continue;
const from = airportsByIcao.get(p.locationIcao);
const to = airportsByIcao.get(p.destIcao);
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
const plane = c.planeId ? planeById.get(c.planeId) : undefined;
features.push({
type: 'Feature',
geometry: {
@@ -94,7 +90,7 @@ export function routesGeoJson(
[to.lon, to.lat],
],
},
properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' },
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };