Package weight, payload capacity and per-kg payout (ISS-045)
CI / build + unit + e2e (pull_request) Successful in 8m2s
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:
@@ -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
|
||||
|
||||
+13
-11
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user