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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -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<void> {
|
||||
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)
|
||||
|
||||
+10
-5
@@ -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<string, PlaneType> = {
|
||||
@@ -31,7 +33,9 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
// €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,
|
||||
}));
|
||||
}
|
||||
|
||||
+48
-9
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+32
-9
@@ -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<string, number> = {
|
||||
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,
|
||||
|
||||
@@ -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<StateSnapshot> {
|
||||
reward: p.reward,
|
||||
cargoType: p.cargoType,
|
||||
cargoCount: p.cargoCount,
|
||||
weightKg: p.weightKg,
|
||||
deadlineSimTime: p.deadlineSimTime,
|
||||
status: p.status,
|
||||
planeId: p.planeId,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): 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,
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+6
-4
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user