Compare commits

..
Author SHA1 Message Date
kamilkrzywda 8ec485b93e Harden right-pane/slow-on-landing e2e self-heal: clear leftover cargo, refuel-and-retry repositioning
CI / build + unit + e2e (pull_request) Successful in 9m4s
2026-09-21 10:08:33 +00:00
kamilkrzywda ea740fc378 Special jobs: data-driven special-status registry with priority status (ISS-073, v0.37.0)
CI / build + unit + e2e (pull_request) Failing after 7m56s
2026-09-21 09:30:36 +00:00
kamilkrzywda 718e3a0177 Merge pull request 'Refuel-for-flight buys 150% of requirement; wind adjusts per-leg burn ±20% (ISS-071, ISS-070, v0.36.0)' (#74) from feat/weather-fuel-burn into master
CI / build + unit + e2e (push) Failing after 12m14s
Merge feat/weather-fuel-burn: refuel ×1.5 + wind burn ±20% (ISS-071, ISS-070, v0.36.0)
2026-09-21 11:03:03 +02:00
kamilkrzywda 5030fcd8b4 Wind-adjusted fuel burn ±20% + refuel-for-flight buys 150% margin (ISS-070, ISS-071, v0.36.0)
CI / build + unit + e2e (pull_request) Successful in 10m34s
2026-09-21 08:47:42 +00:00
32 changed files with 607 additions and 120 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## v0.37.0 — 2026-09-21 09:29
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
- **Rep bonus at delivery.** `deliverPackages` (`lib/sim/engine.ts`) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
- **State shape & DB.** `special: string | null` added in all four places — `SeedPackage`, Drizzle schema (`lib/db/schema.ts`), raw DDL and a guarded nullable `ALTER TABLE … ADD COLUMN special TEXT` migration in `migrateColumns()` (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and `PackageState` + its row mapping
- **UI badge.** Open-package rows (`components/RightPane.tsx`) and on-board rows (`components/LeftPane.tsx`) show a sky-blue "Priority" pill next to the route when the package has a special status (new `.special-badge` class, `package-special-{id}` testid); label via new `formatSpecialLabel` in `lib/format.ts`
- **Test hooks & spec.** POST /api/test/create-package accepts `special` (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes `special: null` explicitly. OpenAPI PackageState gains optional `special: string | null`
- **Tests.** New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces `special: 'priority'` via the hook and asserts the row shows the "Priority" badge
- **E2E self-heal hardening.** The `flyHomeViaApi` helper in `e2e/right-pane.spec.ts` now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and `e2e/slow-on-landing.spec.ts`'s equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on `e2e/two-planes.spec.ts`)
## v0.36.0 — 2026-09-21 08:45
- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note)
## v0.35.0 — 2026-09-21 02:26
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
+16 -6
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route';
@@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
expect(res.status).toBe(400);
});
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
@@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => {
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Same wind-adjusted requirement the server computes (v0.36.0).
const w = weatherFor(HUB_ICAO);
const required = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
@@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => {
account: { cash: number };
planes: Array<{ id: string; fuel: number }>;
};
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
// ISS-071: target is 150% of the requirement, capped at tank capacity.
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
// Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
+9 -1
View File
@@ -21,6 +21,9 @@ import { tickNow } from '@/lib/sim/engine';
// hook deadline of expire-package) — pass an explicit value to override.
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
// flows that load several packages can stay under the plane's payload deterministically.
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
// e2e specs that assert exact reward/rep values stay deterministic.
// Returns 201 + the created package row.
export async function POST(req: Request) {
@@ -40,7 +43,7 @@ export async function POST(req: Request) {
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
@@ -55,6 +58,9 @@ export async function POST(req: Request) {
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
}
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
}
try {
const inst = await getGameDb();
@@ -85,6 +91,8 @@ export async function POST(req: Request) {
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null),
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+2 -1
View File
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
// special: null — the hook never rolls a special status (v0.37.0).
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
+9
View File
@@ -214,6 +214,15 @@ body {
color: #bfdbfe;
}
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
.special-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
}
.fuel-bar {
height: 4px;
border-radius: 2px;
+7 -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 } from '@/lib/format';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
@@ -424,6 +424,12 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</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">
{formatSpecialLabel(pkg.special)}
</span>
)}
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
+7 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative } from '@/lib/format';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
@@ -200,6 +200,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
>
<div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</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">
{formatSpecialLabel(pkg.special)}
</span>
)}
{eligible.length > 0 ? (
<LoadControls
pkg={pkg}
+1
View File
@@ -39,6 +39,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoType: 'mail',
cargoCount: 4,
weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 1_000_000,
status: 'open',
planeId: null,
+15 -2
View File
@@ -1,5 +1,10 @@
import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -219,10 +224,18 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
// wind-adjusted (v0.36.0) by departure weather vs route heading.
const entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
fuelAfter(
before.planes.find((p) => p.id === planeId)!.fuel,
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
exactKm,
),
6,
);
+68 -16
View File
@@ -1,5 +1,10 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
@@ -32,6 +37,7 @@ interface PackageState {
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
}
interface CatalogEntry {
@@ -119,22 +125,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
}
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
// this test wants a long-but-flyable leg instead.
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
// full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
async function farthestFlyableToHub(
page: Page,
entry: CatalogEntry,
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
@@ -157,10 +169,11 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number): Promise<PackageState> {
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
@@ -186,8 +199,13 @@ async function flyHomeViaApi(page: Page): Promise<void> {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
let flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
@@ -195,6 +213,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
})
.toBe(true);
}
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
}
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
@@ -234,6 +260,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
});
test('a priority package shows its special badge on the open-package row', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
const badge = page.getByTestId(`package-special-${pkg.id}`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
@@ -365,7 +408,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
}
});
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page);
@@ -375,7 +418,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
// exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
@@ -396,12 +439,21 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
// Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
const wFar = weatherFor(farthest.icao);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
);
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
const liters = Math.max(0, target - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
@@ -424,8 +476,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
// the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect
+7 -2
View File
@@ -80,8 +80,13 @@ async function flyHomeViaApi(page: Page): Promise<void> {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
let flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${plane.id}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
}
+14 -2
View File
@@ -1,5 +1,10 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
@@ -114,18 +119,25 @@ async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: n
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409).
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is flown after a top-up, which also 409s if it doesn't fit.
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
+5
View File
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS packages (
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
special TEXT,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
@@ -83,6 +84,10 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
// v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
if (!cols('packages').has('special')) {
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
}
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
if (!cols('sim_state').has('last_selected_time_scale')) {
+2
View File
@@ -40,6 +40,8 @@ export const packages = sqliteTable('packages', {
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
weightKg: real('weight_kg').notNull().default(2000),
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
special: text('special'),
deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free.
+1
View File
@@ -35,6 +35,7 @@ const basePackage = (id: string): PackageState => ({
cargoType: 'mail',
cargoCount: 2,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0,
status: 'open',
planeId: null,
+5
View File
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// 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);
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string {
+28 -18
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed';
import {
@@ -591,17 +592,29 @@ describe('refuelPlane', () => {
// --- forFlight mode (v0.16.0) ---
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
async function adjustedRequiredForHubToLemd() {
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
// Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2);
@@ -610,31 +623,28 @@ describe('refuelPlane', () => {
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
});
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', 'plane-starter');
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const required = await adjustedRequiredForHubToLemd();
// Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
+24 -13
View File
@@ -3,8 +3,9 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
@@ -106,8 +107,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
}
@@ -312,9 +316,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
// enough fuel for one loaded package's destination leg — required liters per the
// weighted burn model, independent of how much is already in the tank.
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions {
mode?: 'full' | 'forFlight';
packageId?: string;
@@ -350,15 +355,21 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
);
if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
}
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
targetFuel = Math.max(plane.fuel, requiredL);
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// → free no-op, tank untouched.
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
} else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel;
+66 -20
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials';
import {
BASE_REWARD,
buildSeed,
@@ -71,14 +72,14 @@ 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)', () => {
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}
});
@@ -94,11 +95,13 @@ describe('buildSeed', () => {
}
});
it('sets deadlines inside the 6–72h game-time window after now', () => {
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
}
});
@@ -143,13 +146,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
// Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
@@ -191,9 +195,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -209,9 +213,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -242,7 +246,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * 1_234));
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
@@ -252,4 +258,44 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
describe('special status (v0.37.0, ISS-073)', () => {
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
});
it('applies the registry reward multiplier to the base reward', () => {
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
});
it('scales the drawn deadline window by the registry factor', () => {
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
});
it('a forced deadline offset skips the special scaling', () => {
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
});
it('a forced special (including explicit null) skips the appended RNG draw', () => {
const countDraws = (special?: string | null): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
return n;
};
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
expect(countDraws('priority')).toBe(6);
expect(countDraws(null)).toBe(6); // explicit null skips too
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
});
});
});
+18 -4
View File
@@ -1,5 +1,6 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never
@@ -52,6 +53,8 @@ export interface SeedPackage {
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number;
status: 'open';
planeId: null;
@@ -116,12 +119,14 @@ export function rewardFor(km: number, weightKg: number): number {
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// force a near-term expiry). The RNG call order per package is fixed: origin,
// dest, cargoType, cargoCount, weight jitter, [deadline window].
// dest, cargoType, cargoCount, weight jitter, [deadline window], [special].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
// jitter draw, same pattern as forced endpoints.
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -131,6 +136,7 @@ export function generatePackage(input: {
originIcao?: string;
destIcao?: string;
weightKg?: number;
special?: string | null;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -165,17 +171,25 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
const deadlineMs =
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
// below AFTER the appended special draw so the fixed order above holds.
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
// forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
return {
id: input.id,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardFor(km, weightKg),
reward: Math.round(rewardFor(km, weightKg) * (mods?.rewardMultiplier ?? 1)),
cargoType,
cargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { mulberry32, type Rng } from './seed';
import { pickSpecial, SPECIALS, SPECIAL_SPAWN_CHANCE } from './specials';
describe('pickSpecial (v0.37.0, ISS-073)', () => {
it('returns null when the chance draw fails and consumes exactly one draw', () => {
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.9; }; // ≥ SPECIAL_SPAWN_CHANCE → no special
expect(pickSpecial(rng)).toBeNull();
expect(draws).toBe(1);
});
it('picks among the registry keys when the chance draw passes', () => {
const seen = new Set<string>();
for (let i = 0; i < 2_000; i++) {
let call = 0;
// First call is the chance draw (< chance → roll); later calls sweep [0, 1) as index draws.
const rng: Rng = () => (call++ === 0 ? 0.1 : i / 2_000);
const s = pickSpecial(rng);
if (s !== null) seen.add(s);
}
expect([...seen].sort()).toEqual(Object.keys(SPECIALS).sort()); // every key reachable
});
it('never returns a status whose entry was removed; an empty registry consumes no draws', () => {
const withoutPriority = Object.fromEntries(Object.entries(SPECIALS).filter(([k]) => k !== 'priority'));
for (let i = 0; i < 500; i++) {
expect(pickSpecial(mulberry32(i), withoutPriority)).not.toBe('priority'); // removing an entry disables it
}
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.0; }; // would pass the chance draw if consulted
expect(pickSpecial(rng, {})).toBeNull();
expect(draws).toBe(0);
});
it('rolls a special for roughly SPECIAL_SPAWN_CHANCE of packages (fixed RNG)', () => {
const rng = mulberry32(42);
let hits = 0;
for (let i = 0; i < 10_000; i++) if (pickSpecial(rng) !== null) hits++;
expect(hits / 10_000).toBeGreaterThan(SPECIAL_SPAWN_CHANCE - 0.05);
expect(hits / 10_000).toBeLessThan(SPECIAL_SPAWN_CHANCE + 0.05);
});
});
+34
View File
@@ -0,0 +1,34 @@
// Special-status registry (v0.37.0, ISS-073) — data-driven package modifiers: one entry per
// special status maps to reward/deadline/reputation tweaks applied at generation and delivery.
// Adding or removing a special is an edit of ONE entry here — no scattered branches elsewhere.
import type { Rng } from './seed';
export interface SpecialModifiers {
// Multiplier on the base reward (applied at package generation).
rewardMultiplier: number;
// Scale factor on the drawn deadline window (applied at generation; forced offsets skip it).
deadlineScale: number;
// Flat reputation bonus added to the distance-based gain at delivery.
repBonus: number;
}
export const SPECIALS: Record<string, SpecialModifiers> = {
priority: {
rewardMultiplier: 1.5, // ponytail: balance-tunable — a 50% premium for the tighter deadline below
deadlineScale: 0.5, // ponytail: balance-tunable — half the normal 6–72h window (3–36h)
repBonus: 0.5, // ponytail: balance-tunable — flat rep on top of the distance-based gain at delivery
},
};
// Fraction of generated packages that roll a special status (v0.37.0).
export const SPECIAL_SPAWN_CHANCE = 0.15;
// Pure spawn helper: one chance draw, then a uniform pick among the registry's keys. The registry
// is a PARAM defaulting to the real one so tests can pass a filtered/empty record — removing an
// entry cleanly disables that status (an empty registry consumes no draws at all).
export function pickSpecial(rng: Rng, registry: Record<string, SpecialModifiers> = SPECIALS): string | null {
const keys = Object.keys(registry);
if (keys.length === 0) return null;
if (rng() >= SPECIAL_SPAWN_CHANCE) return null;
return keys[Math.floor(rng() * keys.length)];
}
+17 -3
View File
@@ -1,9 +1,10 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -52,6 +53,8 @@ export interface PackageState {
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
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';
@@ -102,7 +105,17 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
};
return {
@@ -139,6 +152,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
+1
View File
@@ -139,6 +139,7 @@ describe('OpenAPI spec', () => {
'originName',
'planeId',
'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
+4 -3
View File
@@ -370,7 +370,7 @@ export const openApiSpec = {
post: {
summary: 'Refuel a grounded plane',
description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.',
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -488,7 +488,7 @@ export const openApiSpec = {
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
@@ -575,7 +575,7 @@ export const openApiSpec = {
mode: {
enum: ['full', 'forFlight'],
description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
'"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
},
packageId: {
type: 'string',
@@ -649,6 +649,7 @@ export const openApiSpec = {
cargoType: { type: 'string' },
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.' },
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.
+48 -17
View File
@@ -6,10 +6,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
clampTimeScale,
deliverPackages,
@@ -165,7 +167,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -184,8 +186,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form).
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
// (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -230,6 +235,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
); // rep per delivered package
});
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
const { inst, km } = await setupFlight('deliver-special.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
await inst.db.insert(schema.packages).values([
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9,
);
});
it('leaves planes without route info alone', async () => {
const inst = await freshDb('deliver-noroute.db');
// Flying plane whose dest_icao is missing — defensive skip, no crash.
@@ -241,14 +266,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
@@ -268,14 +298,14 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
const { inst, km, type } = await setupFlight('chain-nofuel.db');
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
await inst.db.update(schema.planes)
.set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']),
})
.where(eq(schema.planes.id, 'plane-starter'));
@@ -424,9 +454,10 @@ describe('spawnOpenPackages', () => {
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
expect(p.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time.
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
// Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale);
}
// Already at target → nothing more to spawn.
+30 -6
View File
@@ -3,8 +3,10 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state';
import {
generatePackage,
@@ -13,7 +15,7 @@ import {
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -156,8 +158,17 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043).
@@ -168,9 +179,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// Fresh account read per award so multiple deliveries in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await inst.db.update(schema.account).set({
cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
});
} else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot.
@@ -188,11 +202,21 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (queue.length > 0) {
const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
}
}
}
await inst.db.update(schema.planes)
.set(
chainedTo
+48
View File
@@ -10,6 +10,7 @@ import {
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -166,6 +167,53 @@ describe('weighted burn model (v0.16.0)', () => {
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
+9
View File
@@ -119,6 +119,15 @@ export function requiredFuelFor(
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
}
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
return 1 + headwindKt / 150;
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.35.0",
"version": "0.37.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",