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
+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();
+2 -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/);
+2 -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 {
@@ -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.
+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);
}
});