Compare commits

..
Author SHA1 Message Date
kamilkrzywda d2defe4cd3 v0.69.0: remove job ignore feature
CI / build + unit + e2e (pull_request) Successful in 14m31s
2026-09-23 22:01:39 +00:00
kamilkrzywda 7d117f6809 Merge pull request 'Special airports re-curation — one per ~1000 km region (ISS-144)' (#145) from fix/special-airports-curation into master
CI / build + unit + e2e (push) Successful in 13m14s
Special airports re-curation — one per ~1000 km region (ISS-144)
2026-09-23 22:47:08 +02:00
20 changed files with 48 additions and 171 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## v0.69.0 — 2026-09-23 21:54
- **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
- **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway.
- **Tests.** Unit: the `ignorePackage` suite is deleted; terminal-status fixtures drop their redundant `'ignored'` rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to `'expired'` mirroring `expireOpenPackages`; the taken-slot cooldown ladder keeps two stamped slots, both `'loaded'`. E2E: the Ignore UI test is deleted; `sim.spec.ts`'s soft-lock regression drains via the existing `/api/test/expire-package` hook (loading can't drain a pool — the starter's payload fits only the lightest band) and `expiry.spec.ts` cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.
## v0.68.0 — 2026-09-23 20:32 ## v0.68.0 — 2026-09-23 20:32
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km). - **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`. - **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.ts`.
-10
View File
@@ -1,10 +0,0 @@
import { ignorePackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => ignorePackage(inst, id));
}
-10
View File
@@ -221,16 +221,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load Load
</button> </button>
)} )}
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
<button
type="button"
data-testid={`ignore-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
>
Ignore
</button>
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
+1 -2
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
@@ -27,7 +27,6 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue',
+1 -1
View File
@@ -12,7 +12,7 @@ interface StateBody {
} }
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history // ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history
// (delivered/abandoned/ignored) is unbounded in the DB and must never leak into a pushed frame. // (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']); const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
test('GET /api/events streams state snapshots over SSE', async () => { test('GET /api/events streams state snapshots over SSE', async () => {
+3 -3
View File
@@ -63,10 +63,10 @@ test('long-leg priority packages always expire after travel time at max speed +
created.push(pkg); created.push(pkg);
} }
} finally { } finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board. // Cleanup: age out every package this test created so it leaves no trace on the shared board.
for (const pkg of created) { for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`); const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy(); expect(r.ok(), `expiring ${pkg.id}`).toBeTruthy();
} }
} }
}); });
+1 -33
View File
@@ -40,7 +40,7 @@ interface PackageState {
originName: string; originName: string;
destName: string; destName: string;
reward: number; reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B) status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null; planeId: string | null;
createdSimTime: number; createdSimTime: number;
deadlineSimTime: number; deadlineSimTime: number;
@@ -447,38 +447,6 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
}); });
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
await ignoreBtn.click();
// Server truth: the package is 'ignored'…
await expect
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
timeout: 15_000,
message: 'waiting for the ignored status in /api/state',
})
.toBe('ignored');
// …and the row is gone from the open list (the section filters status==='open').
await expect(row).not.toBeVisible({ timeout: 15_000 });
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(again.status()).toBe(409);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => { test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
+11 -10
View File
@@ -84,7 +84,7 @@ test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) =
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0); expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
}); });
test('a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)', async ({ page }) => { test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and // Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements). // the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
await expect await expect
@@ -100,23 +100,24 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true); expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const clockBefore = before.simState.clockMs; const clockBefore = before.simState.clockMs;
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never // Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every // ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to // only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
// bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring // reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
// starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board // its removal, so the market can never empty; cap the passes to bound runtime either way (a
// comes back a few seconds after the drain, not instantly. // zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
// board comes back on the very next tick.
for (let pass = 0; pass < 3; pass++) { for (let pass = 0; pass < 3; pass++) {
const s = await getState(page); const s = await getState(page);
const open = s.packages.filter((p) => p.status === 'open'); const open = s.packages.filter((p) => p.status === 'open');
if (open.length === 0) break; // drained — the pre-fix soft-lock state if (open.length === 0) break; // drained — the pre-fix soft-lock state
for (const p of open) { for (const p of open) {
expect((await page.request.post(`/api/packages/${p.id}/ignore`)).status()).toBe(200); expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
} }
} }
// The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real // Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
// time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)… // pool right away — within ~15 s of real time, with headroom for dind slowness…
let topped: StateBody | undefined; let topped: StateBody | undefined;
await expect await expect
.poll(async () => { .poll(async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ interface PackageState {
id: string; id: string;
originIcao: string; originIcao: string;
destIcao: string; destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null; planeId: string | null;
} }
+2 -3
View File
@@ -53,15 +53,14 @@ export const packages = sqliteTable('packages', {
deadlineSimTime: integer('deadline_sim_time').notNull(), deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded // '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. // expired packages keep occupying their slot, deliver no reward, and abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
planeId: text('plane_id'), planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(), createdSimTime: integer('created_sim_time').notNull(),
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim // Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows // clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick. // backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
createdRealMs: integer('created_real_ms').notNull().default(0), createdRealMs: integer('created_real_ms').notNull().default(0),
// Real-time epoch ms when this (origin, band) slot was last taken — loaded or ignored (v0.65.0). The pool // Real-time epoch ms when this (origin, band) slot was last taken — loaded (v0.65.0). The pool
// spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open // spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open
// jobs set nothing: an untaken job's slot respawns immediately. // jobs set nothing: an untaken job's slot respawns immediately.
slotFreeAtRealMs: integer('slot_free_at_real_ms'), slotFreeAtRealMs: integer('slot_free_at_real_ms'),
-31
View File
@@ -28,7 +28,6 @@ import {
cancelQueuedLeg, cancelQueuedLeg,
flyPlane, flyPlane,
GameActionError, GameActionError,
ignorePackage,
loadPackage, loadPackage,
queueLeg, queueLeg,
repairPlane, repairPlane,
@@ -530,36 +529,6 @@ describe('abandonPackage', () => {
}); });
}); });
describe('ignorePackage', () => {
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
const inst = await freshDb('ig-open.db');
await insertOpenPackage(inst, 'p-ig');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await ignorePackage(inst, 'p-ig');
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
expect(row.status).toBe('ignored');
expect(row.planeId).toBeNull(); // was never bound
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
});
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`ig-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
},
);
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ig-unknown.db');
await expectActionError(ignorePackage(inst, 'nope'), 404);
});
});
describe('buyPlane', () => { describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db'); const inst = await freshDb('buy-ok.db');
-17
View File
@@ -263,23 +263,6 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
return { penalty }; return { penalty };
} }
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
// changes. The pool spawner refills this (origin, band) slot after its refill delay —
// ignoring starts that cooldown via slotFreeAtRealMs (v0.65.0). 404 unknown id; 409 unless open.
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
}
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: Date.now() }).where(eq(schema.packages.id, packageId));
await persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) --- // --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with // Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
+1 -1
View File
@@ -122,7 +122,7 @@ function envMs(name: string, fallback: number): number {
} }
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
// Refill cooldown after a job is TAKEN (loaded or ignored): the slot stays empty this long so the // Refill cooldown after a job is TAKEN (loaded): the slot stays empty this long so the
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block — // board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common. // an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
-1
View File
@@ -40,7 +40,6 @@ describe('sseFrame', () => {
pkg('loaded', 'p-loaded'), pkg('loaded', 'p-loaded'),
pkg('abandoned', 'p-abandoned'), pkg('abandoned', 'p-abandoned'),
pkg('expired', 'p-expired'), pkg('expired', 'p-expired'),
pkg('ignored', 'p-ignored'),
]; ];
const frame = sseFrame(snapshot(all)); const frame = sseFrame(snapshot(all));
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']); expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
+2 -3
View File
@@ -81,8 +81,7 @@ export interface PackageState {
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored. // Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number; distanceKm: number;
deadlineSimTime: number; deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null; planeId: string | null;
createdSimTime: number; createdSimTime: number;
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live // Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
@@ -106,7 +105,7 @@ export interface StateSnapshot {
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component // ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot). // renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
// Terminal history (delivered/abandoned/ignored) is unbounded in the DB and never read by the // Terminal history (delivered/abandoned) is unbounded in the DB and never read by the
// client, so it stays out of every pushed frame; /api/state and action responses keep the full set. // client, so it stays out of every pushed frame; /api/state and action responses keep the full set.
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const; export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
+2 -3
View File
@@ -3,14 +3,13 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => { it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load', '/api/packages/{id}/load',
'/api/planes/{planeId}/fly', '/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
@@ -153,7 +152,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs']); expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B) expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
}); });
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => { it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
+1 -27
View File
@@ -147,31 +147,6 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/packages/{id}/ignore': {
post: {
summary: 'Ignore an open job',
description:
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
operationId: 'ignorePackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Package not "open"',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/fly': { '/api/planes/{planeId}/fly': {
post: { post: {
summary: 'Fly a plane to an airport', summary: 'Fly a plane to an airport',
@@ -604,8 +579,7 @@ export const openApiSpec = {
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick; // 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free. // loaded expired packages keep their slot, deliver no reward, abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] }, planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' }, createdSimTime: { type: 'integer' },
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' }, expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
+5 -7
View File
@@ -152,7 +152,6 @@ describe('expireLoadedPackages (sim clock)', () => {
{ id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS }, { id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS }, { id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS }, { id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
]); ]);
const n = await expireLoadedPackages(inst, clock); const n = await expireLoadedPackages(inst, clock);
@@ -163,7 +162,6 @@ describe('expireLoadedPackages (sim clock)', () => {
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
}); });
it('a deadline exactly at the clock counts as expired', async () => { it('a deadline exactly at the clock counts as expired', async () => {
@@ -673,12 +671,12 @@ describe('grounded fleet freezes the world clock (#135)', () => {
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => { it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen). // Drain the open market the way wall-clock expiry would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real // Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS. // empty", not "taken" — a real load stamps the cooldown and would block this refill for REFILL_DELAY_MS.
for (const p of await inst.db.select().from(schema.packages)) { for (const p of await inst.db.select().from(schema.packages)) {
if (p.status === 'open') { if (p.status === 'open') {
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id)); await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
} }
} }
@@ -807,7 +805,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!; const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!; const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id)); await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id)); await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty. // While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0); expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
+11 -7
View File
@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
@@ -111,7 +111,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await finishRepairs(inst, clockMs); await finishRepairs(inst, clockMs);
} }
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) — // v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
// because while grounded nothing else creates packages but removal paths (ignore) still run; without // because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the // this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board; // WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before. // both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
@@ -147,9 +147,13 @@ export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promi
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire // v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs // regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no // every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). Returns the count flipped. // slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
// lifetime, and no new ones can ever be created. Returns the count flipped.
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> { export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')); // Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
let n = 0; let n = 0;
for (const p of rows) { for (const p of rows) {
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) { if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
@@ -352,7 +356,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for // (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each // it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are // band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded/ignored) stays empty for the band's refill delay after // indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on // slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup. // the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created. // RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
@@ -385,8 +389,8 @@ export async function spawnPoolPackages(
let n = 0; let n = 0;
for (const originIcao of pool) { for (const originIcao of pool) {
// Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter: // Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
// terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp. // terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
const openByBand = [0, 0, 0, 0]; const openByBand = [0, 0, 0, 0];
let blockedUntil: number | null = null; let blockedUntil: number | null = null;
for (const p of packageRows) { for (const p of packageRows) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.68.0", "version": "0.69.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",