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
+19 -19
View File
@@ -1,14 +1,14 @@
import { test, expect, type Page } from '@playwright/test';
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
// raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
// hook enabled in docker-compose.yml), and market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 60;
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60
// Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts.
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
interface SimState {
@@ -21,7 +21,7 @@ interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
planes: Array<Record<string, unknown>>;
contracts: Array<{ id: string; status: string; deadlineSimTime: number }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -74,16 +74,16 @@ test('paused freezes the clock exactly across polls', async ({ page }) => {
}
});
test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => {
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000);
const before = await getState(page);
const victim = before.contracts.find((c) => c.status === 'open');
expect(victim).toBeDefined(); // steady state always has open contracts
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
// contract's deadline ~10 game-seconds out so expiry is observable at scale 60.
const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } });
// package's deadline ~10 game-seconds out so expiry is observable at scale 60.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
@@ -94,24 +94,24 @@ test('an open contract past its short deadline expires and the market stays topp
await expect
.poll(async () => {
const body = await getState(page);
if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body;
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' })
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
const after = expiredBody!;
expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired');
// Expired contracts leave the available set…
expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false);
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.contracts.filter((c) => c.status === 'open').length;
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
});
test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => {
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000);
const created = await page.request.post('/api/test/expire-contract');
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
@@ -122,12 +122,12 @@ test('the hook can also create a fresh short-deadline contract, which then expir
await expect
.poll(async () => {
const body = await getState(page);
if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body;
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' })
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length;
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
});