feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+21 -21
View File
@@ -23,7 +23,7 @@ export function mulberry32(seed: number): Rng {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
t = (t + Math.imul(t ^ (a >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
@@ -35,12 +35,12 @@ export interface SeedPlane {
fuel: number;
maxFuel: number;
locationIcao: string;
contractId: null;
destIcao: null;
lat: number;
lon: number;
}
export interface SeedContract {
export interface SeedPackage {
id: string;
originIcao: string;
destIcao: string;
@@ -55,7 +55,7 @@ export interface SeedContract {
export interface SeedData {
plane: SeedPlane;
contracts: SeedContract[];
packages: SeedPackage[];
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
}
@@ -70,19 +70,19 @@ export const STARTING_REPUTATION = 10;
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
export const HUB_ICAO = 'EDDF'; // Frankfurt Main
export const CONTRACT_COUNT_MIN = 8;
export const CONTRACT_COUNT_MAX = 12; // inclusive
export const PACKAGE_COUNT_MIN = 8;
export const PACKAGE_COUNT_MAX = 12; // inclusive
// Market target (v0.7.0): the sim engine tops open contracts back up to this
// Market target (v0.7.0): the sim engine tops open packages back up to this
// count after expiries, so the job board never runs dry.
export const MARKET_TARGET_OPEN_CONTRACTS = 10;
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.
export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
// Contract deadlines: now + a plausible game-time window of 6–72 hours.
// Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72;
@@ -92,16 +92,16 @@ export function rewardForDistanceKm(km: number): number {
return Math.round(BASE_REWARD + REWARD_PER_KM * km);
}
// Shared contract generator, used by BOTH the first-run seed and the runtime
// market spawn (v0.7.0) so spawned contracts match seeded ones exactly: random
// 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 contract
// the e2e test hook to force a near-term expiry). The RNG call order per package
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-contract hook); a forced endpoint consumes no RNG draws, so
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
export function generateContract(input: {
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
rng: Rng;
@@ -109,9 +109,9 @@ export function generateContract(input: {
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
}): SeedContract {
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
if (airports.length < 2) throw new Error('package generation needs at least two airports');
let oi: number;
if (input.originIcao !== undefined) {
@@ -179,22 +179,22 @@ export function buildSeed(input: {
fuel: type.maxFuel, // starts full
maxFuel: type.maxFuel,
locationIcao: hub.icao,
contractId: null,
destIcao: null,
lat: hub.lat,
lon: hub.lon,
};
const count = CONTRACT_COUNT_MIN + Math.floor(rng() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
// Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these.
const contracts: SeedContract[] = [];
const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) {
contracts.push(generateContract({ id: `contract-${i + 1}`, airports, rng, nowMs }));
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
}
return {
plane,
contracts,
packages,
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
// by ~0 instead of treating "time since install" as elapsed play time.