diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3d6df..95068ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 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 diff --git a/app/api/packages/[id]/load/route.ts b/app/api/packages/[id]/load/route.ts index 3597140..79e2d17 100644 --- a/app/api/packages/[id]/load/route.ts +++ b/app/api/packages/[id]/load/route.ts @@ -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; diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index c3e7791..9555c75 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -58,7 +58,7 @@ describe('GET /api/state', () => { const packages = body.packages as Array>; 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. diff --git a/app/api/test/create-package/route.ts b/app/api/test/create-package/route.ts index ebdf567..d8c986f 100644 --- a/app/api/test/create-package/route.ts +++ b/app/api/test/create-package/route.ts @@ -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; 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); diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 5bf48ad..8280a82 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -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 diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 8f0ac0b..03b32de 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -23,23 +23,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; }); } diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index f08f846..4a8cc44 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -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 & Pick): PlaneState { @@ -38,6 +38,7 @@ function pkg(over: Partial & Pick): 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([]); }); diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 60dd141..e416d25 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -53,8 +53,16 @@ async function getState(page: Page): Promise { } // 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 { - 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 { + 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(); diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index efe108a..b003b2c 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -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/); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index bce694c..2df7688 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -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 { @@ -309,7 +309,7 @@ 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('/'); // Buy: the model picker lists the catalog entry and the price preview is its full price. diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index bfb9f6b..a236897 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -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); } }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 3d179a3..cb9ff97 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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 diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 4ea1a20..b5aa7cd 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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. diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts index 8d52892..f7ef9ac 100644 --- a/lib/deriveEvents.test.ts +++ b/lib/deriveEvents.test.ts @@ -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, diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 140e43c..d682787 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -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); }); }); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 8744644..08c711e 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -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 { 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) diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index c644963..26bc27e 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -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 = { @@ -31,7 +33,9 @@ export const PLANE_TYPES: Record = { // €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, }, }; @@ -57,7 +61,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 +76,6 @@ export function catalogEntries(): CatalogEntry[] { burnPerKm: t.burnPerKm, emptyWeightKg: t.emptyWeightKg, speedKmh: t.speedKmh, - capacity: t.capacity, + maxPayloadKg: t.maxPayloadKg, })); } diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 4d5ddfb..de8d109 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -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); + }); + }); }); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index cef39a5..1038556 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -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 = { + 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, diff --git a/lib/game/state.ts b/lib/game/state.ts index 0218db6..ae2a185 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -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 { reward: p.reward, cargoType: p.cargoType, cargoCount: p.cargoCount, + weightKg: p.weightKg, deadlineSimTime: p.deadlineSimTime, status: p.status, planeId: p.planeId, diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index eac448e..3e8e58f 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -17,7 +17,7 @@ function snapshot(overrides: Partial = {}): 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, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 2167f66..3651a62 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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; diff --git a/lib/openapi.ts b/lib/openapi.ts index 71a63ae..29e044a 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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', diff --git a/package.json b/package.json index 3618029..8954ca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.26.0", + "version": "0.27.0", "private": true, "scripts": { "dev": "next dev -p 3000",