From a8233829c3954c967e59ac7a5a841d3441282997 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 06:58:05 +0000 Subject: [PATCH] Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0) --- CHANGELOG.md | 4 ++++ app/api/state/route.test.ts | 15 ++++++++++++++- components/LeftPane.tsx | 4 +++- components/RightPane.tsx | 4 +++- components/right-pane.test.ts | 1 + e2e/actions.spec.ts | 2 ++ e2e/right-pane.spec.ts | 4 ++++ lib/deriveEvents.test.ts | 7 ++++--- lib/format.test.ts | 10 +++++++++- lib/format.ts | 5 +++++ lib/game/state.ts | 9 +++++++++ lib/openapi.test.ts | 1 + lib/openapi.ts | 2 ++ package.json | 2 +- 14 files changed, 62 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a72e0..9852127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.48.0 — 2026-09-22 06:55 +- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored +- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line) + ## v0.47.0 — 2026-09-21 23:01 - **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income - **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index 9555c75..3711c70 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -2,6 +2,8 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { loadAirportsGeoJson } from '@/lib/airports'; +import { haversineKm } from '@/lib/geo'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { GET } from './route'; @@ -58,9 +60,20 @@ describe('GET /api/state', () => { const packages = body.packages as Array>; expect(packages.length).toBeGreaterThan(0); - for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) { + for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) { expect(packages[0], `package.${key}`).toHaveProperty(key); } + + // v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the + // fixture airports' coordinates for every package in the snapshot. + const fc = await loadAirportsGeoJson(); + const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); + for (const pkg of packages) { + const from = coords.get(String(pkg.originIcao)); + const to = coords.get(String(pkg.destIcao)); + if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset) + expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6); + } }); it('is stable across repeated calls (same seeded DB)', async () => { diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 544b336..8716647 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; -import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format'; +import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import { useGameAction } from '@/lib/useGameAction'; import { useSimClock } from '@/lib/useSimClock'; @@ -441,6 +441,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh {pkg.originIcao} → {pkg.destIcao} + {/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */} + {formatKm(pkg.distanceKm)} {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */} {pkg.special && ( diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 1218775..86f0cdf 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog'; import { maxRangeKm } from '@/lib/sim/movement'; -import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format'; +import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format'; import { useGameAction } from '@/lib/useGameAction'; import type { HoverTarget } from './hoverTypes'; @@ -234,6 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{formatCash(pkg.reward)} + {/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */} + {formatKm(pkg.distanceKm)} {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */} {formatCargo(pkg.cargoType, pkg.weightKg)} diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index 8c5f408..7bc6633 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -42,6 +42,7 @@ function pkg(over: Partial & Pick): PackageSta cargoCount: 4, weightKg: 1_000, special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures + distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance deadlineSimTime: 1_000_000, status: 'open', planeId: null, diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 65fecec..8723e5b 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -197,6 +197,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible(); // ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names. await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`); + // v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km. + await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/); // FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label). const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 1344a67..40a4ec7 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -210,6 +210,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toContainText(/→/); await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€'); + // v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km". + await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/); // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot). const nearest = await nearestToHub(page); @@ -220,6 +222,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a // 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below. await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t'); await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); + // v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too. + await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/); const loadBtn = page.getByTestId(`load-${pkg.id}`); await expect(loadBtn).toBeEnabled(); diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts index 68fa93f..c8be82c 100644 --- a/lib/deriveEvents.test.ts +++ b/lib/deriveEvents.test.ts @@ -36,9 +36,10 @@ const basePackage = (id: string): PackageState => ({ reward: 1234.5, cargoType: 'mail', cargoCount: 2, - weightKg: 600, - special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures - deadlineSimTime: 0, + weightKg: 600, + special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures + distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance + deadlineSimTime: 0, status: 'open', planeId: null, createdSimTime: 0, diff --git a/lib/format.test.ts b/lib/format.test.ts index ae99e08..44a9e30 100644 --- a/lib/format.test.ts +++ b/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format'; +import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format'; describe('formatCash', () => { it('formats EUR with thousands separators and two decimals', () => { @@ -30,6 +30,14 @@ describe('formatCargo', () => { }); }); +describe('formatKm', () => { + it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => { + expect(formatKm(554)).toBe('554km'); + expect(formatKm(214.6)).toBe('215km'); + expect(formatKm(0)).toBe('0km'); + }); +}); + describe('formatSimClock', () => { it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC'); diff --git a/lib/format.ts b/lib/format.ts index f485c1a..d0e65c3 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string { return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`; } +// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km". +export function formatKm(km: number): string { + return `${Math.round(km)}km`; +} + // Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority". export function formatSpecialLabel(special: string): string { return special.charAt(0).toUpperCase() + special.slice(1); diff --git a/lib/game/state.ts b/lib/game/state.ts index f71ed11..6cd99be 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -62,6 +62,8 @@ export interface PackageState { weightKg: number; // Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none. special: string | null; + // Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored. + distanceKm: number; deadlineSimTime: number; // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; @@ -100,6 +102,12 @@ export async function getStateSnapshot(): Promise { 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])); + // v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing. + const legDistanceKm = (originIcao: string, destIcao: string) => { + const from = coordsByIcao.get(originIcao); + const to = coordsByIcao.get(destIcao); + return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0; + }; // 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. @@ -162,6 +170,7 @@ export async function getStateSnapshot(): Promise { cargoCount: p.cargoCount, weightKg: p.weightKg, special: p.special, + distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095) deadlineSimTime: p.deadlineSimTime, status: p.status, planeId: p.planeId, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 554a893..e796175 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -136,6 +136,7 @@ describe('OpenAPI spec', () => { 'deadlineSimTime', 'destIcao', 'destName', + 'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km 'id', 'originIcao', 'originName', diff --git a/lib/openapi.ts b/lib/openapi.ts index ff92ac5..0b03761 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -633,6 +633,7 @@ export const openApiSpec = { cargoCount: { type: 'integer' }, weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' }, special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' }, + distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, // 'expired' = an open OR loaded package whose deadline passed on a tick; // loaded expired packages keep their slot, deliver no reward, abandon free. @@ -651,6 +652,7 @@ export const openApiSpec = { 'cargoType', 'cargoCount', 'weightKg', // v0.27.0 (ISS-045) — package weight in kg + 'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km 'deadlineSimTime', 'status', 'planeId', diff --git a/package.json b/package.json index 6bd808c..ff71d01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.47.0", + "version": "0.48.0", "private": true, "scripts": { "dev": "next dev -p 3000",