Compare commits

..
16 changed files with 74 additions and 10 deletions
+8
View File
@@ -1,5 +1,13 @@
# 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
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
+14 -1
View File
@@ -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<Record<string, unknown>>;
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 () => {
+3 -1
View File
@@ -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
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao}
</span>
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
+3 -1
View File
@@ -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
</div>
<div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}>
+1
View File
@@ -42,6 +42,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): 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,
+2
View File
@@ -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}`);
+4
View File
@@ -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();
+4 -3
View File
@@ -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,
+9 -1
View File
@@ -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');
+5
View File
@@ -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);
+4 -1
View File
@@ -78,7 +78,7 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
@@ -134,6 +134,9 @@ describe('buildSeed', () => {
it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
expect(rewardFor(1000, 500)).toBe(4_000);
expect(rewardFor(1000, 4_000)).toBe(11_000);
});
});
+4 -1
View File
@@ -90,9 +90,12 @@ export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 0.5;
export const REWARD_PER_KG = 2.0;
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value.
+9
View File
@@ -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<StateSnapshot> {
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<StateSnapshot> {
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,
+1
View File
@@ -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',
+2
View File
@@ -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',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.46.0",
"version": "0.48.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",