From ea740fc3782cc1afddf3b552200983c6b84ee65f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 21 Sep 2026 09:30:36 +0000 Subject: [PATCH 1/2] Special jobs: data-driven special-status registry with priority status (ISS-073, v0.37.0) --- CHANGELOG.md | 9 +++ app/api/test/create-package/route.ts | 10 +++- app/api/test/expire-package/route.ts | 3 +- app/globals.css | 9 +++ components/LeftPane.tsx | 8 ++- components/RightPane.tsx | 8 ++- components/right-pane.test.ts | 1 + e2e/right-pane.spec.ts | 25 +++++++- lib/db/client.ts | 5 ++ lib/db/schema.ts | 2 + lib/deriveEvents.test.ts | 1 + lib/format.ts | 5 ++ lib/game/seed.test.ts | 86 +++++++++++++++++++++------- lib/game/seed.ts | 22 +++++-- lib/game/specials.test.ts | 43 ++++++++++++++ lib/game/specials.ts | 34 +++++++++++ lib/game/state.ts | 3 + lib/openapi.test.ts | 1 + lib/openapi.ts | 1 + lib/sim/engine.test.ts | 28 ++++++++- lib/sim/engine.ts | 6 +- package.json | 2 +- 22 files changed, 276 insertions(+), 36 deletions(-) create mode 100644 lib/game/specials.test.ts create mode 100644 lib/game/specials.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58485..42c093a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 + ## 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) diff --git a/app/api/test/create-package/route.ts b/app/api/test/create-package/route.ts index d8c986f..85e6de1 100644 --- a/app/api/test/create-package/route.ts +++ b/app/api/test/create-package/route.ts @@ -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; 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); diff --git a/app/api/test/expire-package/route.ts b/app/api/test/expire-package/route.ts index e125ddb..3aeb936 100644 --- a/app/api/test/expire-package/route.ts +++ b/app/api/test/expire-package/route.ts @@ -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 }); diff --git a/app/globals.css b/app/globals.css index 55636d5..4df7ef7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 89b4bf1..e1716de 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; -import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } 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
{pkg.originName} → {pkg.destName} + {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */} + {pkg.special && ( + + {formatSpecialLabel(pkg.special)} + + )} {formatCash(pkg.reward)} {pkg.status === 'expired' ? ( expired diff --git a/components/RightPane.tsx b/components/RightPane.tsx index f956815..72eeb6b 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { 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 >
{pkg.originName} → {pkg.destName} + {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */} + {pkg.special && ( + + {formatSpecialLabel(pkg.special)} + + )} {eligible.length > 0 ? ( & Pick): 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, diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index b73eeb8..3e10b43 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -37,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 { @@ -168,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 { +// 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 { 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; @@ -245,6 +247,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 diff --git a/lib/db/client.ts b/lib/db/client.ts index 987903e..e09111e 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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')) { diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ab339d2..ad037f5 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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. diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts index f7ef9ac..6969275 100644 --- a/lib/deriveEvents.test.ts +++ b/lib/deriveEvents.test.ts @@ -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, diff --git a/lib/format.ts b/lib/format.ts index 20d78bb..f485c1a 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string { return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`; } +// 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 { diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 9d3585f..3834b4b 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -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 + }); + }); }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index 4772edb..0ec83c0 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -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, diff --git a/lib/game/specials.test.ts b/lib/game/specials.test.ts new file mode 100644 index 0000000..2be4a57 --- /dev/null +++ b/lib/game/specials.test.ts @@ -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(); + 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); + }); +}); diff --git a/lib/game/specials.ts b/lib/game/specials.ts new file mode 100644 index 0000000..57dd8cc --- /dev/null +++ b/lib/game/specials.ts @@ -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 = { + 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 = 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)]; +} diff --git a/lib/game/state.ts b/lib/game/state.ts index 3a51568..f203409 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -53,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'; @@ -150,6 +152,7 @@ export async function getStateSnapshot(): Promise { cargoType: p.cargoType, cargoCount: p.cargoCount, weightKg: p.weightKg, + special: p.special, deadlineSimTime: p.deadlineSimTime, status: p.status, planeId: p.planeId, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index fbc0b36..5df7df3 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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 ]); diff --git a/lib/openapi.ts b/lib/openapi.ts index f4e5e00..1043813 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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. diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 37ef534..388da44 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -9,6 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports'; 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, windBurnFactor } from './movement'; import { @@ -234,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. @@ -433,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. diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 3b52aa6..950e45e 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -6,6 +6,7 @@ import { loadAirportsGeoJson } from '@/lib/airports'; 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, @@ -178,9 +179,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise Date: Mon, 21 Sep 2026 10:08:33 +0000 Subject: [PATCH 2/2] Harden right-pane/slow-on-landing e2e self-heal: clear leftover cargo, refuel-and-retry repositioning --- CHANGELOG.md | 1 + e2e/right-pane.spec.ts | 17 +++++++++++++++-- e2e/slow-on-landing.spec.ts | 9 +++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c093a..061482c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **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 diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 3e10b43..8263f96 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -199,8 +199,13 @@ async function flyHomeViaApi(page: Page): Promise { // 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, @@ -208,6 +213,14 @@ async function flyHomeViaApi(page: Page): Promise { }) .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 }) => { diff --git a/e2e/slow-on-landing.spec.ts b/e2e/slow-on-landing.spec.ts index ceab3df..c0004c8 100644 --- a/e2e/slow-on-landing.spec.ts +++ b/e2e/slow-on-landing.spec.ts @@ -80,8 +80,13 @@ async function flyHomeViaApi(page: Page): Promise { // 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); } } -- 2.54.0