Compare commits

..
Author SHA1 Message Date
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
24 changed files with 483 additions and 36 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog # 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)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12 ## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up) - **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change - **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
+14 -1
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route'; import { GET } from './route';
@@ -58,9 +60,20 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>; const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0); 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); 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 () => { it('is stable across repeated calls (same seeded DB)', async () => {
+54
View File
@@ -0,0 +1,54 @@
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
// stays for relative grants, this one is exact.
//
// POST /api/test/set-cash
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
// account.cash to exactly that value and returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const amount = input.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
}
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+3 -1
View File
@@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty'; 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 { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock'; 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"> <span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao} {pkg.originIcao} → {pkg.destIcao}
</span> </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. */} {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && ( {pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge"> <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 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 { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement'; 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 { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -234,6 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <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". */} {/* 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-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}> <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, cargoCount: 4,
weightKg: 1_000, weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures 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, deadlineSimTime: 1_000_000,
status: 'open', status: 'open',
planeId: null, 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(); 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. // 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}`); 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). // 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}`); const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
+156
View File
@@ -1,4 +1,11 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
import { loadAirportsGeoJson } from '../lib/airports';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
@@ -14,7 +21,9 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
interface PlaneState { interface PlaneState {
id: string; id: string;
model: string;
status: 'available' | 'flying'; status: 'available' | 'flying';
fuel: number;
maxFuel: number; maxFuel: number;
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
@@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
async function setCash(page: Page, amount: number): Promise<void> {
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon // Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
// leftover cargo so every test starts from the canonical state even after an interrupted run. // leftover cargo so every test starts from the canonical state even after an interrupted run.
async function flyHomeViaApi(page: Page): Promise<void> { async function flyHomeViaApi(page: Page): Promise<void> {
@@ -450,3 +470,139 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
return !!p && p.status === 'available' && p.locationIcao === HUB; return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true); }, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
}); });
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
for (const icao of [DEST, QUEUE, HUB]) {
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
}
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
expect(p0.status).toBe('flying');
expect(p0.destIcao).toBe(DEST);
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
// Landing at DEST auto-chains EHAM (leg 2)…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
// …and landing at EHAM chains the return to HUB (leg 3).
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
});
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two legs at scale 1000
await flyHomeViaApi(page);
const s0 = await getState(page);
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
await setFuelFull(page, 'plane-starter');
const STAGED_CASH = 20_000;
await setCash(page, STAGED_CASH);
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
await setFuel(page, 'plane-starter', 0);
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
const fc = await loadAirportsGeoJson();
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
const wHub = weatherFor(HUB);
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
expect(landedFuel).toBeCloseTo(0, 6);
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
const liters = Math.max(0, target - landedFuel);
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
const s = await getState(page);
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
// Let leg 2 finish so the next test starts from a known airport.
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
});
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one leg at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
expect(p.destIcao).toBeNull(); // no chained leg started…
expect(p.queue).toEqual([]); // …and the queue is empty
});
+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(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/); await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').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). // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page); 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. // 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-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); 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}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
+46
View File
@@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort()); expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
}); });
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
test.setTimeout(180_000);
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
.toBe('available');
// Stage a two-leg queue on the starter…
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
expect(q1.status()).toBe(200);
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
expect(q2.status()).toBe(200);
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
// Cleanup: cancel both legs so the starter is left with an empty queue.
for (let i = 0; i < 2; i++) {
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c.status()).toBe(200);
}
});
+12 -2
View File
@@ -495,20 +495,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 }); await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao); await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination… // Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
// response snapshot must already show only [b] left (previously the leg stayed queued and the
// landing chained a zero-distance self-hop a→a before reaching b).
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } }); const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200); expect(flyRes.status()).toBe(200);
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with // …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain… // the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
await setScale(page, SCALE); await setScale(page, SCALE);
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER); const p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
return !!p && p.status === 'flying' && p.destIcao === b.icao; return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) }, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true); .toBe(true);
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
// is either the outbound leg (HUB→a) or the chained leg (a→b).
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
// …then restore speed so the chained leg fast-forwards too. // …then restore speed so the chained leg fast-forwards too.
await setScale(page, SCALE); await setScale(page, SCALE);
await expect await expect
+4 -3
View File
@@ -36,9 +36,10 @@ const basePackage = (id: string): PackageState => ({
reward: 1234.5, reward: 1234.5,
cargoType: 'mail', cargoType: 'mail',
cargoCount: 2, cargoCount: 2,
weightKg: 600, weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0, distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0,
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => { 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', () => { describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { 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'); 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`; 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". // Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string { export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1); return special.charAt(0).toUpperCase() + special.slice(1);
+41
View File
@@ -331,6 +331,34 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
}); });
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
});
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('EHAM');
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
});
}); });
describe('queueLeg (v0.25.0, ISS-043)', () => { describe('queueLeg (v0.25.0, ISS-043)', () => {
@@ -671,6 +699,19 @@ describe('sellPlane', () => {
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
}); });
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await queueLeg(inst, 'plane-extra', 'LEMD');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
});
it('rejects selling the last remaining plane with 409', async () => { it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
+12
View File
@@ -153,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
const queue = parseIcaoQueue(plane.queue);
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ .set({
status: 'flying', status: 'flying',
@@ -161,6 +167,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
lat: origin.lat, lat: origin.lat,
lon: origin.lon, lon: origin.lon,
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed) fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
queue: JSON.stringify(queue),
}) })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
if (cost > 0) { if (cost > 0) {
@@ -340,6 +347,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `cannot sell a plane carrying cargo`); throw new GameActionError(409, `cannot sell a plane carrying cargo`);
} }
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
if (parseIcaoQueue(plane.queue).length > 0) {
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
const all = await inst.db.select().from(schema.planes); const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
+4 -1
View File
@@ -78,7 +78,7 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); 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); const seed = seedWith(42);
for (const c of seed.packages) { for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!; const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
@@ -134,6 +134,9 @@ describe('buildSeed', () => {
it('rewardFor applies the documented formula', () => { it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD); 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)); 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 // 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 + // 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. // 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 BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5; 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 // Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value. // higher reputation, exactly ×1 at the starting value.
+9
View File
@@ -62,6 +62,8 @@ export interface PackageState {
weightKg: number; weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none. // Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null; special: string | null;
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number; deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; 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 nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao; const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); 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 // 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. // 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, cargoCount: p.cargoCount,
weightKg: p.weightKg, weightKg: p.weightKg,
special: p.special, special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime, deadlineSimTime: p.deadlineSimTime,
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
+1
View File
@@ -136,6 +136,7 @@ describe('OpenAPI spec', () => {
'deadlineSimTime', 'deadlineSimTime',
'destIcao', 'destIcao',
'destName', 'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'id', 'id',
'originIcao', 'originIcao',
'originName', 'originName',
+2
View File
@@ -633,6 +633,7 @@ export const openApiSpec = {
cargoCount: { type: 'integer' }, cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' }, 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.' }, 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)' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick; // 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free. // loaded expired packages keep their slot, deliver no reward, abandon free.
@@ -651,6 +652,7 @@ export const openApiSpec = {
'cargoType', 'cargoType',
'cargoCount', 'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg 'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'deadlineSimTime', 'deadlineSimTime',
'status', 'status',
'planeId', 'planeId',
+46
View File
@@ -378,6 +378,52 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.status).toBe('available'); // unchanged landing behavior… expect(plane.status).toBe('available'); // unchanged landing behavior…
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
}); });
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
const wTo = weatherFor(TO);
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
);
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['FGSL']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
});
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
});
}); });
describe('wear on landed legs (v0.39.0, ISS-069)', () => { describe('wear on landed legs (v0.39.0, ISS-069)', () => {
+35 -24
View File
@@ -246,32 +246,43 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0 && newHealth > 0) { if (queue.length > 0 && newHealth > 0) {
const nextDest = queue[0]; const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain // ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
if (next) { // zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). // since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
const wNext = weatherFor(to.icao); if (nextDest === to.icao) {
const nextRequiredL = requiredFuelFor( queue.shift();
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)), } else {
type.emptyWeightKg, const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
type.maxFuel, if (next) {
haversineKm(to.lat, to.lon, next.lat, next.lon), // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
); const wNext = weatherFor(to.icao);
if (nextRequiredL <= landedFuel) { const nextRequiredL = requiredFuelFor(
queue.shift(); type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
chainedTo = nextDest; type.emptyWeightKg,
} else { type.maxFuel,
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. haversineKm(to.lat, to.lon, next.lat, next.lon),
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); );
const liters = Math.max(0, target - landedFuel); // v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. // intact, mirroring flyPlane's 409 without throwing in the engine.
const fresh = (await inst.db.select().from(schema.account))[0]; if (nextRequiredL > type.maxFuel) {
if (!fresh) throw new Error('account row missing'); // no chain — the grounded update below keeps the queue for a later dispatch
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; } else if (nextRequiredL <= landedFuel) {
if (cost === 0 || fresh.cash >= cost) {
queue.shift(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; } else {
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.45.0", "version": "0.48.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",