Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5f3ae23e | ||
|
|
8ec485b93e | ||
|
|
ea740fc378 |
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 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
|
- **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)
|
- **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)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { tickNow } from '@/lib/sim/engine';
|
|||||||
// hook deadline of expire-package) — pass an explicit value to override.
|
// 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
|
// - 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.
|
// 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.
|
// Returns 201 + the created package row.
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -40,7 +43,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const input = body as Record<string, unknown>;
|
const input = body as Record<string, unknown>;
|
||||||
for (const key of Object.keys(input)) {
|
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 });
|
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)) {
|
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 });
|
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 {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
@@ -85,6 +91,8 @@ export async function POST(req: Request) {
|
|||||||
originIcao: input.originIcao as string | undefined,
|
originIcao: input.originIcao as string | undefined,
|
||||||
destIcao: input.destIcao as string | undefined,
|
destIcao: input.destIcao as string | undefined,
|
||||||
weightKg: input.weightKg as number | 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 inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
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 inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(created, { status: 201 });
|
return Response.json(created, { status: 201 });
|
||||||
|
|||||||
@@ -214,6 +214,15 @@ body {
|
|||||||
color: #bfdbfe;
|
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 {
|
.fuel-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
|||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
@@ -424,6 +424,12 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
<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>
|
<span>{formatCash(pkg.reward)}</span>
|
||||||
{pkg.status === 'expired' ? (
|
{pkg.status === 'expired' ? (
|
||||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { maxRangeKm } from '@/lib/sim/movement';
|
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 { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -200,6 +200,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
>
|
>
|
||||||
<div className="contract-main">
|
<div className="contract-main">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
<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 ? (
|
{eligible.length > 0 ? (
|
||||||
<LoadControls
|
<LoadControls
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
weightKg: 1_000,
|
weightKg: 1_000,
|
||||||
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
deadlineSimTime: 1_000_000,
|
deadlineSimTime: 1_000_000,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
+37
-5
@@ -37,6 +37,7 @@ interface PackageState {
|
|||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
|
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogEntry {
|
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));
|
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.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
|
||||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number): Promise<PackageState> {
|
// 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', {
|
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);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
@@ -197,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
|
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||||
// fast-forwards instead of taking real-time hours.
|
// fast-forwards instead of taking real-time hours.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
let flyHome = 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)
|
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
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -206,6 +213,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.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 }) => {
|
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||||
@@ -245,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 });
|
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('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
|
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
|
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
let flyHome = 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)
|
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);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
cargo_type TEXT NOT NULL,
|
cargo_type TEXT NOT NULL,
|
||||||
cargo_count INTEGER NOT NULL,
|
cargo_count INTEGER NOT NULL,
|
||||||
weight_kg REAL NOT NULL DEFAULT 2000,
|
weight_kg REAL NOT NULL DEFAULT 2000,
|
||||||
|
special TEXT,
|
||||||
deadline_sim_time INTEGER NOT NULL,
|
deadline_sim_time INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
plane_id TEXT,
|
plane_id TEXT,
|
||||||
@@ -83,6 +84,10 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
|||||||
if (!cols('packages').has('weight_kg')) {
|
if (!cols('packages').has('weight_kg')) {
|
||||||
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
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
|
// 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.
|
// 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')) {
|
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const packages = sqliteTable('packages', {
|
|||||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
|
// 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.
|
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
|
||||||
weightKg: real('weight_kg').notNull().default(2000),
|
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(),
|
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
// '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.
|
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
weightKg: 600,
|
weightKg: 600,
|
||||||
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
deadlineSimTime: 0,
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
|
|||||||
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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.
|
// seconds so the display is deterministic regardless of the viewer's timezone.
|
||||||
export function formatSimClock(clockMs: number): string {
|
export function formatSimClock(clockMs: number): string {
|
||||||
|
|||||||
+66
-20
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||||
|
import { SPECIALS } from './specials';
|
||||||
import {
|
import {
|
||||||
BASE_REWARD,
|
BASE_REWARD,
|
||||||
buildSeed,
|
buildSeed,
|
||||||
@@ -71,14 +72,14 @@ describe('buildSeed', () => {
|
|||||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
|
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
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 o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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.
|
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
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', () => {
|
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 o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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', () => {
|
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 o = AIRPORTS[0]; // EDDF
|
||||||
const d = AIRPORTS[1]; // EHAM
|
const d = AIRPORTS[1]; // EHAM
|
||||||
expect(c.reward).toBe(
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
|
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', () => {
|
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 o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
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…
|
// Deterministic for a fixed RNG…
|
||||||
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
|
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);
|
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
@@ -1,5 +1,6 @@
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
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)
|
// 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
|
// so it is fully deterministic and unit-testable: the RNG is injected, never
|
||||||
@@ -52,6 +53,8 @@ export interface SeedPackage {
|
|||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
|
||||||
weightKg: number;
|
weightKg: number;
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||||
|
special: string | null;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open';
|
status: 'open';
|
||||||
planeId: null;
|
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
|
// 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
|
// 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,
|
// 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 +
|
// 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
|
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
||||||
// callers that omit both get byte-identical behavior to before.
|
// callers that omit both get byte-identical behavior to before.
|
||||||
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
||||||
// jitter draw, same pattern as forced endpoints.
|
// 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: {
|
export function generatePackage(input: {
|
||||||
id: string;
|
id: string;
|
||||||
airports: SeedAirport[];
|
airports: SeedAirport[];
|
||||||
@@ -131,6 +136,7 @@ export function generatePackage(input: {
|
|||||||
originIcao?: string;
|
originIcao?: string;
|
||||||
destIcao?: string;
|
destIcao?: string;
|
||||||
weightKg?: number;
|
weightKg?: number;
|
||||||
|
special?: string | null;
|
||||||
}): SeedPackage {
|
}): SeedPackage {
|
||||||
const { airports, rng, nowMs } = input;
|
const { airports, rng, nowMs } = input;
|
||||||
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
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
|
// 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.
|
// 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 weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
|
||||||
const deadlineMs =
|
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
|
||||||
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
// 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 {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
originIcao: origin.icao,
|
originIcao: origin.icao,
|
||||||
destIcao: dest.icao,
|
destIcao: dest.icao,
|
||||||
reward: rewardFor(km, weightKg),
|
reward: Math.round(rewardFor(km, weightKg) * (mods?.rewardMultiplier ?? 1)),
|
||||||
cargoType,
|
cargoType,
|
||||||
cargoCount,
|
cargoCount,
|
||||||
weightKg,
|
weightKg,
|
||||||
|
special,
|
||||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ export interface PackageState {
|
|||||||
cargoCount: number;
|
cargoCount: number;
|
||||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
||||||
weightKg: number;
|
weightKg: number;
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||||
|
special: string | null;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||||
@@ -150,6 +152,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
cargoType: p.cargoType,
|
cargoType: p.cargoType,
|
||||||
cargoCount: p.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
weightKg: p.weightKg,
|
weightKg: p.weightKg,
|
||||||
|
special: p.special,
|
||||||
deadlineSimTime: p.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'originName',
|
'originName',
|
||||||
'planeId',
|
'planeId',
|
||||||
'reward',
|
'reward',
|
||||||
|
'special', // v0.37.0 (ISS-073) — special status from the specials registry
|
||||||
'status',
|
'status',
|
||||||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ export const openApiSpec = {
|
|||||||
cargoType: { type: 'string' },
|
cargoType: { type: 'string' },
|
||||||
cargoCount: { type: 'integer' },
|
cargoCount: { type: 'integer' },
|
||||||
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
||||||
|
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
|
||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
|
|||||||
+25
-3
@@ -9,6 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
@@ -234,6 +235,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
); // rep per delivered package
|
); // 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 () => {
|
it('leaves planes without route info alone', async () => {
|
||||||
const inst = await freshDb('deliver-noroute.db');
|
const inst = await freshDb('deliver-noroute.db');
|
||||||
// Flying plane whose dest_icao is missing — defensive skip, no crash.
|
// 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(icaoSet.has(p.destIcao)).toBe(true);
|
||||||
expect(p.originIcao).not.toBe(p.destIcao);
|
expect(p.originIcao).not.toBe(p.destIcao);
|
||||||
expect(p.planeId).toBeNull();
|
expect(p.planeId).toBeNull();
|
||||||
// Same deadline window as the seed: now + 6–72h game time.
|
// Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
|
||||||
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
|
||||||
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
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.
|
// Already at target → nothing more to spawn.
|
||||||
|
|||||||
+5
-1
@@ -6,6 +6,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
|
|||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { parseIcaoQueue } from '@/lib/game/state';
|
import { parseIcaoQueue } from '@/lib/game/state';
|
||||||
import {
|
import {
|
||||||
generatePackage,
|
generatePackage,
|
||||||
@@ -178,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.
|
// Fresh account read per award so multiple deliveries in one tick all count.
|
||||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!fresh) throw new Error('account row missing');
|
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({
|
await inst.db.update(schema.account).set({
|
||||||
cash: fresh.cash + pk.reward,
|
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') {
|
} else if (pk.status === 'expired') {
|
||||||
// Expired cargo keeps no value — just free the slot.
|
// Expired cargo keeps no value — just free the slot.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.36.0",
|
"version": "0.37.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user