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.
This commit is contained in:
2026-09-20 12:42:03 +00:00
parent 85b7b07f53
commit 179fad450d
24 changed files with 231 additions and 118 deletions
+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([]);
});