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
+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);