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
+37 -37
View File
@@ -4,9 +4,9 @@ import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import {
BASE_REWARD,
buildSeed,
CONTRACT_COUNT_MAX,
CONTRACT_COUNT_MIN,
generateContract,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
HUB_ICAO,
mulberry32,
REWARD_PER_KM,
@@ -41,7 +41,7 @@ describe('buildSeed', () => {
fuel: type.maxFuel,
maxFuel: type.maxFuel,
locationIcao: HUB_ICAO,
contractId: null,
destIcao: null,
lat: 50.0333,
lon: 8.5706,
});
@@ -52,13 +52,13 @@ describe('buildSeed', () => {
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds 8–12 open contracts between distinct fixture airports', () => {
it('seeds 8–12 open packages between distinct fixture airports', () => {
const seed = seedWith(42);
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
for (const c of seed.contracts) {
for (const c of seed.packages) {
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
@@ -66,12 +66,12 @@ describe('buildSeed', () => {
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
}
expect(new Set(seed.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
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)', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
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)));
@@ -80,7 +80,7 @@ describe('buildSeed', () => {
it('sets deadlines inside the 6–72h game-time window after now', () => {
const seed = seedWith(42);
for (const c of seed.contracts) {
for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
}
@@ -97,8 +97,8 @@ describe('buildSeed', () => {
expect(seedWith(42)).toEqual(seedWith(42));
});
it('produces different contracts for a different RNG seed', () => {
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
it('produces different packages for a different RNG seed', () => {
expect(seedWith(1).packages).not.toEqual(seedWith(2).packages);
});
it('rejects an airport list too small to form a route', () => {
@@ -111,9 +111,9 @@ describe('buildSeed', () => {
});
});
describe('generateContract (shared seed/spawn builder)', () => {
it('builds an open contract between distinct fixture airports with the reward formula', () => {
const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
describe('generatePackage (shared seed/spawn builder)', () => {
it('builds an open package between distinct fixture airports with the reward formula', () => {
const c = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.id).toBe('c-x');
expect(c.status).toBe('open');
@@ -134,39 +134,39 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
expect(a).toEqual(b);
const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
expect(c.id).toBe('c-2');
expect(c.createdSimTime).toBe(NOW + 1000);
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different package
});
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
const c = generatePackage({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
it('buildSeed packages are exactly the shared builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = [];
for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
});
it('rejects an airport list too small to form a route', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
it('originIcao forces the origin; dest stays random but distinct', () => {
const c = generateContract({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
const c = generatePackage({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).not.toBe('EDDF');
@@ -176,13 +176,13 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('destIcao forces the destination; origin stays random but distinct', () => {
const c = generateContract({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
const c = generatePackage({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
expect(c.destIcao).toBe('LEBC');
expect(c.originIcao).not.toBe('LEBC');
});
it('both forced → the exact requested route with the distance-based reward', () => {
const c = generateContract({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
const c = generatePackage({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).toBe('EHAM');
@@ -192,22 +192,22 @@ describe('generateContract (shared seed/spawn builder)', () => {
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
expect(a).toEqual(b);
});
it('throws on an unknown forced ICAO or identical endpoints', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
});
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
// Same seed as a historical unforced call → same contract, proving forced
// Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
expect(a).toEqual(b);
});
});