Compare commits

...
6 Commits
Author SHA1 Message Date
kamilkrzywda a84bc7ed21 Merge pull request 'Right pane tabs (Jobs | Planes market) + 5 new plane types with tech-spec panels (ISS-047 part A, v0.28.0)' (#58) from feat/right-pane-tabs-plane-market into master
CI / build + unit + e2e (push) Failing after 9m20s
2026-09-20 16:11:34 +02:00
kamilkrzywda 7524f943d2 Right pane tabs Jobs/Planes market + 5 new plane types with tech-spec panels + sell-plane cargo guard (ISS-047 part A)
CI / build + unit + e2e (pull_request) Successful in 8m56s
2026-09-20 13:57:52 +00:00
kamilkrzywda 4f182a22b6 Merge pull request 'Right pane width matches left pane: 280→360px + toast offset (ISS-046)' (#56) from ui/right-pane-width into master
CI / build + unit + e2e (push) Successful in 7m56s
2026-09-20 15:29:41 +02:00
kamilkrzywda 34b38dd5d5 Right pane width matches left pane: 280→360px + toast offset (ISS-046)
CI / build + unit + e2e (pull_request) Successful in 8m8s
2026-09-20 13:17:33 +00:00
kamilkrzywda b2133fd25c Merge pull request 'Weight-based cargo capacity + weight/distance payout (ISS-045)' (#52) from feat/weight-capacity-payout into master
CI / build + unit + e2e (push) Successful in 7m52s
2026-09-20 14:54:54 +02:00
kamilkrzywda 179fad450d Package weight, payload capacity and per-kg payout (ISS-045)
CI / build + unit + e2e (pull_request) Successful in 8m2s
Packages carry a real weightKg (unit weight x count with +/-30% jitter),
plane capacity becomes maxPayloadKg instead of 6 slots, load validation
sums cargo weight against the payload, and the payout gains a per-kg part.
Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for
maxPayloadKg and adds PackageState.weightKg.
2026-09-20 12:42:03 +00:00
29 changed files with 525 additions and 124 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## v0.28.0 — 2026-09-20 13:55
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
- **Derived max range (ISS-047).** New pure `maxRangeKm(burnPerKm, emptyWeightKg, maxFuel)` in `lib/sim/movement.ts` — closed form of the weighted burn model solving `requiredFuelFor(d) = maxFuel`: `ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm)` (reuses `FUEL_DENSITY_KG_PER_L`, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion
- **Sell-plane cargo guard (ISS-047).** `sellPlane` (`lib/game/actions.ts`) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any `loaded`/`expired` packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as `loadPackage`; the last-plane and flying-plane guards are unchanged
- Tests: unit — new `lib/sim/movement.test.ts` block for `maxRangeKm` (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new `lib/game/catalog.test.ts` sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), `lib/game/actions.test.ts` sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — `e2e/right-pane.spec.ts`: the market test clicks `tab-planes-market` first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of `maxRangeKm`, and the Boeing 777 card with its €9M price; `e2e/toasts.spec.ts` clicks the tab before its market-buy flow; `e2e/panes.spec.ts` asserts both tab buttons render with Jobs default-active. No new endpoints — `lib/openapi.test.ts` untouched
- Verified: `tsc --noEmit` clean; unit suite green locally (318, was 311); `npx playwright test --list` = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack
## v0.27.1 — 2026-09-20 13:16
- Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046).
## v0.27.0 — 2026-09-20 12:27
- **Packages now have real weight (ISS-045).** New `weight_kg REAL NOT NULL DEFAULT 2000` column on `packages` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`; pre-existing rows backfill to the mid-range default). `generatePackage` draws it AFTER cargoCount: `UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng())`, rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table `UNIT_WEIGHT_KG` in `lib/game/seed.ts` (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
- **Plane capacity is now max payload kg, not 6 slots (ISS-045).** `capacity` REMOVED from `PlaneType`/`CatalogEntry`/snapshot catalog; replaced by `maxPayloadKg` — ATR 72-600 = 4800 kg (`lib/game/catalog.ts`). Server load validation (`loadPackage`, `lib/game/actions.ts`) now sums `weightKg` over the plane's loaded+expired packages and 409s with `no payload room: the ATR 72-600 carries X/Y kg` when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: `slotsUsedFor` → `cargoWeightFor(state, planeId)` (summed kg) in `components/RightPane.tsx`, and `loadEligiblePlanes` compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
- **Payout now includes a per-kg part (ISS-045).** New `rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg)` with `REWARD_PER_KG = 0.5` (`lib/game/seed.ts`) — replaces the distance-only `rewardForDistanceKm`, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
- **Loadout display shows tonnage (ISS-045).** Left-pane fleet rows now render `X.Xt / Y.Yt` (used/max payload, one decimal) instead of `n/6 pkgs`; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains `PackageState.weightKg` and swaps `CatalogEntry.capacity` → `maxPayloadKg`, so the e2e path list stays at twelve
- **Test hook gains a forced weight (ISS-045).** `generatePackage` accepts an optional `weightKg` that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and `POST /api/test/create-package` passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
- Tests: unit — `lib/game/seed.test.ts` (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), `lib/game/actions.test.ts` (loadPackage fill loop now inserts weights summing past maxPayloadKg → `/no payload room/`; expired-still-occupies reworked weight-based), `components/right-pane.test.ts` (`cargoWeightFor` sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), `lib/openapi.test.ts` (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in `deriveEvents`/`mapData` tests and the e2e interfaces. E2E — `e2e/right-pane.spec.ts` asserts `maxPayloadKg === 4800`; `e2e/panes.spec.ts` loadout regex → `/^\d+\.\dt \/ \d+\.\dt$/`; snapshot key lists in `app/api/state/route.test.ts` + `e2e/state.spec.ts` gain `weightKg`; the two multi-load tests in `e2e/actions.spec.ts` pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
- Verified: `tsc --noEmit` clean; unit suite green locally (311, was 308); `npx playwright test --list` = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack
## v0.26.0 — 2026-09-20 11:28
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
+5 -4
View File
@@ -1,10 +1,11 @@
import { loadPackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/load (v0.12.0) — body { planeId: string }. Loads an OPEN
// package onto a GROUNDED plane at the package's origin with a free slot. 400
// missing/non-string planeId or unknown fields; 404 unknown package/plane id; 409
// wrong package status, plane flying, plane not at the origin, no free slots. On
// success returns the full state snapshot (package now "loaded" on that plane).
// package onto a GROUNDED plane at the package's origin with enough payload room
// for its weight (v0.27.0). 400 missing/non-string planeId or unknown fields; 404
// unknown package/plane id; 409 wrong package status, plane flying, plane not at
// the origin, no payload room. On success returns the full state snapshot
// (package now "loaded" on that plane).
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
+2 -2
View File
@@ -58,7 +58,7 @@ describe('GET /api/state', () => {
const packages = body.packages as Array<Record<string, unknown>>;
expect(packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key);
}
});
@@ -86,7 +86,7 @@ describe('GET /api/state', () => {
expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh);
expect(entry.capacity).toBe(type.capacity); // v0.12.0 slot capacity (ATR 72-600: 6)
expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
}
// Economy constants — the single source of truth stays lib/game/catalog.ts.
+8 -2
View File
@@ -14,11 +14,13 @@ import { tickNow } from '@/lib/sim/engine';
// same gate, same conventions.
//
// POST /api/test/create-package
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number }
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generatePackage.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// 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.
// Returns 201 + the created package row.
export async function POST(req: Request) {
@@ -38,7 +40,7 @@ export async function POST(req: Request) {
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
@@ -50,6 +52,9 @@ export async function POST(req: Request) {
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
}
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 });
}
try {
const inst = await getGameDb();
@@ -79,6 +84,7 @@ export async function POST(req: Request) {
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
weightKg: input.weightKg as number | undefined,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+43 -5
View File
@@ -103,11 +103,11 @@ body {
}
/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market.
ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section), right
narrowed to 280px — total fixed width stays 640px, so the map column is unchanged. */
ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section).
ISS-046 (v0.27.1): right widened to match left at 360px — total fixed width 720px, map column shrinks by 80px. */
.app-shell {
display: grid;
grid-template-columns: 360px minmax(0, 1fr) 280px;
grid-template-columns: 360px minmax(0, 1fr) 360px;
height: 100%;
}
@@ -444,8 +444,8 @@ body {
.toasts {
position: fixed;
bottom: 12px;
/* 280px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
right: 292px;
/* 360px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
right: 372px;
z-index: 50; /* above map popups/controls and the app header */
display: flex;
flex-direction: column;
@@ -507,3 +507,41 @@ body {
opacity: 0.5;
cursor: default;
}
/* v0.28.0 (ISS-047) — right pane tabs (Jobs / Planes market) + per-type tech-spec cards */
.pane-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.tab-btn[aria-selected='true'] {
background: rgba(56, 189, 248, 0.25);
border-color: rgba(56, 189, 248, 0.7);
color: #f8fafc;
}
.plane-type-card {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.plane-type-name {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
+7 -8
View File
@@ -9,7 +9,7 @@ import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
import { slotsUsedFor } from './RightPane';
import { cargoWeightFor } from './RightPane';
import type { HoverTarget } from './hoverTypes';
interface LeftPaneProps {
@@ -39,13 +39,12 @@ function planeLocation(p: PlaneState): string {
return p.locationName;
}
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot.
// Cargo loadout "X.Xt / Y.Yt" (v0.27.0) — used/max payload in tonnes, one decimal;
// expired packages still occupy their payload.
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
const used = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
).length;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
return `${used}/${capacity} pkgs`;
const used = cargoWeightFor(state, p.id);
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? PLANE_TYPES[p.model]?.maxPayloadKg ?? 0;
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
}
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
@@ -207,7 +206,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
: [];
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
+84 -12
View File
@@ -2,7 +2,8 @@
import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
@@ -23,23 +24,25 @@ interface RightPaneProps {
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which
// keeps its slot until it is abandoned or the plane lands at its destination.
export function slotsUsedFor(state: StateSnapshot, planeId: string): number {
return state.packages.filter(
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'),
).length;
// Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
// cargo, which keeps its space until it is abandoned or the plane lands at its
// destination.
export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
return state.packages
.filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'))
.reduce((sum, p) => sum + p.weightKg, 0);
}
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a
// free slot (capacity from the snapshot catalog). The Load button is enabled iff
// this list is non-empty; exactly one entry loads directly, several show a picker.
// Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
// payload room for it (maxPayloadKg from the snapshot catalog). The Load button is
// enabled iff this list is non-empty; exactly one entry loads directly, several show a picker.
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
return state.planes.filter((p) => {
if (p.status !== 'available') return false;
if (p.locationIcao !== pkg.originIcao) return false;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0;
return slotsUsedFor(state, p.id) < capacity;
const maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
// Mirrors the server check in lib/game/actions.ts loadPackage.
return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg;
});
}
@@ -117,6 +120,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
// v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
// about our planes, not in the market. Its state + /api/airports fetch went with it.
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
@@ -148,6 +154,31 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
{/* v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. */}
<div className="pane-tabs" role="tablist">
<button
type="button"
data-testid="tab-jobs"
role="tab"
aria-selected={activeTab === 'jobs'}
className="tab-btn"
onClick={() => setActiveTab('jobs')}
>
Jobs
</button>
<button
type="button"
data-testid="tab-planes-market"
role="tab"
aria-selected={activeTab === 'planes'}
className="tab-btn"
onClick={() => setActiveTab('planes')}
>
Planes market
</button>
</div>
{activeTab === 'jobs' && (
<section className="pane-section">
<h3 className="pane-heading">Packages</h3>
{open.length === 0 ? (
@@ -193,9 +224,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</ul>
)}
</section>
)}
{/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
{activeTab === 'planes' && (
<section className="pane-section">
<h3 className="pane-heading">Market</h3>
@@ -259,7 +292,46 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
<h4 className="plane-type-name">{e.label}</h4>
<div className="stat-row">
<span>Buy</span>
<strong>{formatCash(e.price)}</strong>
</div>
<div className="stat-row">
<span>Sell</span>
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
</div>
<div className="stat-row">
<span>Payload</span>
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
</div>
<div className="stat-row">
<span>Cruise speed</span>
<strong>{e.speedKmh} km/h</strong>
</div>
<div className="stat-row">
<span>Fuel burn</span>
<strong>{e.burnPerKm} L/km</strong>
</div>
<div className="stat-row">
<span>Max fuel</span>
<strong>{e.maxFuel} L</strong>
</div>
<div className="stat-row">
<span>Max range</span>
<strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
))}
</section>
)}
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
+28 -22
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest';
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
// it is GROUNDED at the package's origin with a free slot (capacity from the
// snapshot catalog; expired packages still occupy theirs). The Load button acts
// directly on the single eligible plane, or shows a picker when several qualify.
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
// package only while it is GROUNDED at the package's origin with enough payload room for
// it (maxPayloadKg from the snapshot catalog; expired packages still occupy theirs). The
// Load button acts directly on the single eligible plane, or shows a picker when several qualify.
const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
];
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
@@ -38,6 +38,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
reward: 1000,
cargoType: 'mail',
cargoCount: 4,
weightKg: 1_000,
deadlineSimTime: 1_000_000,
status: 'open',
planeId: null,
@@ -58,27 +59,27 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
};
}
describe('slotsUsedFor', () => {
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
describe('cargoWeightFor', () => {
it('sums the weight of loaded AND expired packages bound to the plane (both occupy payload)', () => {
const s = snap(
[plane({ id: 'p1' })],
[
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
pkg({ id: 'a', status: 'loaded', planeId: 'p1', weightKg: 800 }),
pkg({ id: 'b', status: 'expired', planeId: 'p1', weightKg: 500 }),
pkg({ id: 'c', status: 'open', planeId: null, weightKg: 9_000 }), // not on any plane
pkg({ id: 'd', status: 'delivered', planeId: 'p1', weightKg: 700 }), // terminal — payload long freed
],
);
expect(slotsUsedFor(s, 'p1')).toBe(2);
expect(cargoWeightFor(s, 'p1')).toBe(1_300);
});
it('is 0 for a plane with no bound cargo', () => {
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
expect(cargoWeightFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
});
});
describe('loadEligiblePlanes (Load button eligibility)', () => {
it('includes only grounded planes at the package origin with a free slot', () => {
it('includes only grounded planes at the package origin with enough payload room', () => {
const s = snap([
plane({ id: 'at-origin' }), // eligible
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
@@ -87,18 +88,23 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
});
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
const packages = Array.from({ length: 6 }, (_, i) =>
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
it('a plane with partial payload can still load a package that fits', () => {
const s = snap([plane({ id: 'partial' })], [pkg({ id: 'onboard', status: 'loaded', planeId: 'partial', weightKg: 3_000 })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['partial']); // 3000 + 1000 ≤ 4800
});
it('excludes a plane whose payload is full (maxPayloadKg from the catalog)', () => {
const packages = Array.from({ length: 4 }, (_, i) =>
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 }), // 4 × 1200 = 4800
);
const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
it('a full plane with one slot freed by an expired package still counts as full', () => {
it('a plane at its limit with an expired package still counts as full', () => {
const packages = [
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 960 })),
pkg({ id: 'exp', status: 'expired', planeId: 'full', weightKg: 960 }), // occupies its payload — 5 × 960 = 4800
];
const s = snap([plane({ id: 'full' })], packages);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
@@ -114,7 +120,7 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
it('a plane with an unknown model has no capacity → never eligible', () => {
it('a plane with an unknown model has no maxPayloadKg in the catalog → never eligible', () => {
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
});
+16 -7
View File
@@ -53,8 +53,16 @@ async function getState(page: Page): Promise<StateBody> {
}
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
// weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
// several packages onto one plane stay under its payload deterministically.
async function createPackage(
page: Page,
from?: string,
to?: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } });
expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number];
}
@@ -240,9 +248,10 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
await flyHomeViaApi(page);
const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
// Two packages at the hub — load both, abandon one through the UI. Forced weights keep
// the pair under the starter's payload (v0.27.0) so both loads are deterministic.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
@@ -282,8 +291,8 @@ test('expired packages deliver and abandon with no reward and no penalty', async
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
// still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0)
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
+7 -2
View File
@@ -62,10 +62,10 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
await expect(row).toContainText(truth.locationName);
}
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
// v0.27.0 loadout: "X.Xt / Y.Yt" tonnage (used/max payload from the snapshot catalog — ATR 72-600 = 4.8t).
const loadout = page.getByTestId('plane-loadout-plane-starter');
await expect(loadout).toBeVisible();
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/);
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\.\dt \/ \d+\.\dt$/);
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
@@ -190,4 +190,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible();
await expect(right).toContainText('Jobs & Market');
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
});
+31 -2
View File
@@ -42,7 +42,7 @@ interface CatalogEntry {
burnPerKm: number;
emptyWeightKg: number; // v0.16.0 weighted burn input
speedKmh: number;
capacity: number;
maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
}
interface StateBody {
@@ -151,6 +151,12 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
}
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
const rho = FUEL_DENSITY_KG_PER_L;
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): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
@@ -309,9 +315,12 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
expect(body.catalog.length).toBeGreaterThan(0);
const entry = body.catalog.find((e) => e.model === 'atr72')!;
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts)
await page.goto('/');
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
// Buy: the model picker lists the catalog entry and the price preview is its full price.
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 });
@@ -425,3 +434,23 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Top the tank back up so later specs start with a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
expect(body.catalog.length).toBe(6);
await page.getByTestId('tab-planes-market').click();
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!;
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
const b777 = page.getByTestId('plane-type-b777');
await expect(b777).toBeVisible();
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
});
+1 -1
View File
@@ -36,7 +36,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(Array.isArray(body.packages)).toBe(true);
expect(body.packages.length).toBeGreaterThan(0);
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
}
});
+3
View File
@@ -25,6 +25,9 @@ async function getState(page: Page): Promise<StateBody> {
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
await page.goto('/');
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
+5
View File
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS packages (
reward REAL NOT NULL,
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
@@ -77,6 +78,10 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('queue')) {
raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`);
}
// v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default.
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
}
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
+3
View File
@@ -37,6 +37,9 @@ export const packages = sqliteTable('packages', {
reward: real('reward').notNull(),
cargoType: text('cargo_type').notNull(),
cargoCount: integer('cargo_count').notNull(),
// 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),
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.
+2 -1
View File
@@ -6,7 +6,7 @@ import { diffSnapshots } from './deriveEvents';
// feature, unit-tested in node like advanceSimClock/fetchState.
const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, capacity: 6 },
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, maxPayloadKg: 4800 },
];
const basePlane = (id: string): PlaneState => ({
@@ -34,6 +34,7 @@ const basePackage = (id: string): PackageState => ({
reward: 1234.5,
cargoType: 'mail',
cargoCount: 2,
weightKg: 600,
deadlineSimTime: 0,
status: 'open',
planeId: null,
+27 -14
View File
@@ -127,27 +127,27 @@ describe('loadPackage', () => {
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
});
it('rejects loading when the plane has no free slot (capacity from catalog)', async () => {
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
const inst = await freshDb('load-full.db');
await insertOpenPackage(inst, 'p-new');
// Fill all atr72 slots with loaded cargo…
for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Fill past maxPayloadKg with loaded cargo…
const chunk = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 3) + 1; // 3 chunks > max
for (let i = 0; i < 3; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
}
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
});
it('counts expired packages as occupying their slot too', async () => {
it('counts expired packages as occupying their payload too', async () => {
const inst = await freshDb('load-expiredslot.db');
await insertOpenPackage(inst, 'p-new');
// capacity − 1 loaded + 1 expired = full.
for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) {
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' });
}
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' });
await insertOpenPackage(inst, 'p-new', { weightKg: 1_000 });
// Half + half (expired) leaves no room — each alone would fit.
const half = Math.floor(PLANE_TYPES.atr72.maxPayloadKg / 2) + 1;
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i);
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
});
});
@@ -440,6 +440,19 @@ describe('sellPlane', () => {
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
});
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
const inst = await freshDb('sell-cargo.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
});
it('rejects selling the last remaining plane with 409', async () => {
const inst = await freshDb('sell-last.db'); // only the starter exists
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
+19 -10
View File
@@ -20,7 +20,7 @@ import { HUB_ICAO } from './seed';
// GameActionError carrying the HTTP status:
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error {
@@ -43,8 +43,9 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
// --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
// cargo keeps occupying its space until delivered/abandoned).
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
@@ -69,13 +70,12 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const occupied = (
await inst.db.select().from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
)
).length;
if (occupied >= type.capacity) {
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
const occupied = await inst.db.select({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
);
const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
if (usedKg + pkg.weightKg > type.maxPayloadKg) {
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
}
await inst.db.update(schema.packages)
@@ -255,6 +255,15 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
}
// v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay
// 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage).
const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where(
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
);
if (carrying.length > 0) {
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
it('every entry has positive specs and a unique id', () => {
const ids = Object.keys(PLANE_TYPES);
expect(new Set(ids).size).toBe(ids.length);
for (const t of Object.values(PLANE_TYPES)) {
expect(t.id, `${t.name} id`).toBeTruthy();
expect(t.price, `${t.name} price`).toBeGreaterThan(0);
expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0);
expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0);
expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0);
expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0);
expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0);
}
});
it('atr72 is the starter model', () => {
expect(STARTER_MODEL).toBe('atr72');
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
});
});
+64 -5
View File
@@ -16,8 +16,10 @@ export interface PlaneType {
// Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price.
price: number;
// Cargo slot capacity (v0.12.0): how many packages can be loaded on one plane.
capacity: number;
// Max payload in kg (v0.27.0, ISS-045 — replaces the v0.12.0 slot count): total
// package weight that can be loaded on one plane. ATR 72-600-class figure, tuned
// so every cargo type at max count × 1.3 jitter still fits alone (see UNIT_WEIGHT_KG).
maxPayloadKg: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
@@ -31,7 +33,63 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
// a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000,
capacity: 6,
// ponytail: balance-tunable payload (kg) — every cargo type at max count × 1.3 jitter
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
maxPayloadKg: 4_800,
},
// Progression ladder above the ATR starter (v0.28.0, ISS-047): bigger tanks, faster airframes,
// higher prices — everything downstream is data-driven via PLANE_TYPES[model]. Values are
// balance-tunable; keep maxPayloadKg ≥ every cargo type at max count × 1.3 jitter so any
// single package still fits alone (same rule as the ATR above).
crj900: {
id: 'crj900',
name: 'CRJ-900',
maxFuel: 8_000,
burnPerKm: 2.1,
emptyWeightKg: 17_500,
speedKmh: 830,
price: 450_000,
maxPayloadKg: 6_500,
},
e175: {
id: 'e175',
name: 'Embraer E175',
maxFuel: 9_500,
burnPerKm: 2.0,
emptyWeightKg: 23_000,
speedKmh: 840,
price: 900_000,
maxPayloadKg: 9_000,
},
a320: {
id: 'a320',
name: 'Airbus A320-200',
maxFuel: 12_000,
burnPerKm: 2.2,
emptyWeightKg: 41_000,
speedKmh: 845,
price: 1_800_000,
maxPayloadKg: 18_000,
},
a330: {
id: 'a330',
name: 'Airbus A330-200',
maxFuel: 20_000,
burnPerKm: 2.16,
emptyWeightKg: 95_000,
speedKmh: 910,
price: 4_500_000,
maxPayloadKg: 48_000,
},
b777: {
id: 'b777',
name: 'Boeing 777-300ER',
maxFuel: 30_000,
burnPerKm: 2.5,
emptyWeightKg: 130_000,
speedKmh: 920,
price: 9_000_000,
maxPayloadKg: 80_000,
},
};
@@ -57,7 +115,8 @@ export interface CatalogEntry {
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
emptyWeightKg: number;
speedKmh: number;
capacity: number;
// Max payload in kg (v0.27.0) — total package weight that can be loaded on one plane.
maxPayloadKg: number;
}
// The catalog as a flat array, one entry per plane type — the single source of
@@ -71,6 +130,6 @@ export function catalogEntries(): CatalogEntry[] {
burnPerKm: t.burnPerKm,
emptyWeightKg: t.emptyWeightKg,
speedKmh: t.speedKmh,
capacity: t.capacity,
maxPayloadKg: t.maxPayloadKg,
}));
}
+48 -9
View File
@@ -9,8 +9,9 @@ import {
generatePackage,
HUB_ICAO,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
rewardForDistanceKm,
rewardFor,
STARTING_CASH,
STARTING_REPUTATION,
type SeedAirport,
@@ -70,12 +71,26 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
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)));
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),
);
}
});
it('gives every package a positive integer weight that fits the starter plane alone (balance constraint)', () => {
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0);
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
}
}
});
@@ -106,9 +121,9 @@ describe('buildSeed', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
it('rewardForDistanceKm applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
});
});
@@ -127,7 +142,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)));
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),
);
// Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
@@ -173,7 +190,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)));
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),
);
});
it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -189,7 +208,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)));
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),
);
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -212,4 +233,22 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(a).toEqual(b);
});
});
describe('forced weight (v0.27.0 e2e hook)', () => {
it('weightKg forces the package weight and feeds the payout', () => {
const c = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(c.weightKg).toBe(1_234);
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));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
});
+32 -9
View File
@@ -50,6 +50,8 @@ export interface SeedPackage {
reward: number;
cargoType: string;
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number;
deadlineSimTime: number;
status: 'open';
planeId: null;
@@ -80,10 +82,12 @@ export const PACKAGE_COUNT_MAX = 12; // inclusive
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 — deliberately simple; real revenue/cost modeling is a
// later increment): reward = BASE_REWARD + REWARD_PER_KM * great-circle km.
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 0.5;
// Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
@@ -91,19 +95,33 @@ const DEADLINE_MAX_HOURS = 72;
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
// type at MAX count × max jitter (1.3×) still fits the ATR 72-600's payload alone, and
// typical packages land ~400–2500 kg — a normal flight carries roughly 3–6 by weight.
export const UNIT_WEIGHT_KG: Record<string, number> = {
Passengers: 50, // max 50 × 68 × 1.3 = 4420 < 4800
Mail: 60, // max 60 × 50 × 1.3 = 3900
Perishables: 70, // max 70 × 50 × 1.3 = 4550
Electronics: 40, // max 40 × 50 × 1.3 = 2600
'Auto parts': 72, // max 72 × 50 × 1.3 = 4680
};
export function rewardFor(km: number, weightKg: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg);
}
// Shared package generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned packages match seeded ones exactly: random
// distinct airports from the dataset, distance-based reward, cargo flavor,
// 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, [deadline window].
// distinct airports from the dataset, distance+weight-based reward, cargo flavor,
// 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].
// 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.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -112,6 +130,7 @@ export function generatePackage(input: {
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
weightKg?: number;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -143,6 +162,9 @@ export function generatePackage(input: {
// Passengers up to the ATR 72's ~68 seats; other cargo in pallets.
const cargoCount =
cargoType === 'Passengers' ? 1 + Math.floor(rng() * 68) : 1 + Math.floor(rng() * 50);
// 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;
@@ -150,9 +172,10 @@ export function generatePackage(input: {
id: input.id,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardForDistanceKm(km),
reward: rewardFor(km, weightKg),
cargoType,
cargoCount,
weightKg,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
+3
View File
@@ -50,6 +50,8 @@ export interface PackageState {
reward: number;
cargoType: string;
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
deadlineSimTime: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
@@ -135,6 +137,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
reward: p.reward,
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
+1 -1
View File
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
planes: [],
packages: [],
catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
],
fuelPricePerL: 0.8,
sellFraction: 0.5,
+3 -3
View File
@@ -94,18 +94,17 @@ describe('OpenAPI spec', () => {
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm',
'capacity',
'emptyWeightKg', // v0.16.0 weighted burn model input
'label',
'maxFuel',
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
'model',
'price',
'speedKmh',
]);
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'maxPayloadKg', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
@@ -140,6 +139,7 @@ describe('OpenAPI spec', () => {
'planeId',
'reward',
'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
const sim = openApiSpec.components.schemas.SimState.properties;
+6 -4
View File
@@ -106,7 +106,7 @@ export const openApiSpec = {
post: {
summary: 'Load a package onto a plane',
description:
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.',
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs enough payload room for the package weight — maxPayloadKg per catalog model; expired packages still occupy theirs (v0.27.0). The sim clock is ticked first, so the returned snapshot reflects current game time.',
operationId: 'loadPackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
@@ -461,9 +461,9 @@ export const openApiSpec = {
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
},
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'],
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'maxPayloadKg'],
},
AccountState: {
type: 'object',
@@ -498,7 +498,7 @@ export const openApiSpec = {
LoadPackageRequest: {
type: 'object',
properties: {
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' },
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with enough payload room for it (v0.27.0).' },
},
required: ['planeId'],
},
@@ -618,6 +618,7 @@ export const openApiSpec = {
reward: { type: 'number' },
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.' },
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.
@@ -634,6 +635,7 @@ export const openApiSpec = {
'reward',
'cargoType',
'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'deadlineSimTime',
'status',
'planeId',
+22
View File
@@ -5,6 +5,7 @@ import {
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
maxRangeKm,
positionAt,
progressAt,
requiredFuelFor,
@@ -164,3 +165,24 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
expect(r).toBeGreaterThan(4439 * 0.95);
expect(r).toBeLessThan(4439 * 1.05);
});
it('increases with tank size and decreases with burn rate', () => {
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
});
it('zero fuel → zero range', () => {
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
});
it('rejects a non-positive empty weight', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
+10
View File
@@ -118,3 +118,13 @@ export function requiredFuelFor(
const rho = FUEL_DENSITY_KG_PER_L;
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
// itself does not model range exhaustion (see fuelAtWeighted).
export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0');
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.26.0",
"version": "0.28.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",