diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc76051..60a64c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## v0.29.0 — 2026-09-20 14:27
+- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
+- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
+- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
+- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
+- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
+- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
+
## v0.28.0 — 2026-09-20 13:55
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
diff --git a/app/api/packages/[id]/ignore/route.ts b/app/api/packages/[id]/ignore/route.ts
new file mode 100644
index 0000000..8c1a108
--- /dev/null
+++ b/app/api/packages/[id]/ignore/route.ts
@@ -0,0 +1,10 @@
+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));
+}
diff --git a/components/RightPane.tsx b/components/RightPane.tsx
index 63c7d0f..a535c31 100644
--- a/components/RightPane.tsx
+++ b/components/RightPane.tsx
@@ -210,6 +210,16 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load
)}
+ {/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
+
{formatCash(pkg.reward)}
diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts
index 414e094..fa90f28 100644
--- a/e2e/api.spec.ts
+++ b/e2e/api.spec.ts
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version);
});
-test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
+test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
@@ -26,6 +26,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/packages/{id}/abandon',
+ '/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue',
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts
index 7d62091..dc1463f 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -28,7 +28,7 @@ interface PackageState {
originName: string;
destName: string;
reward: number;
- status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
+ status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
@@ -435,6 +435,38 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
expect((await page.request.post('/api/planes/plane-starter/refuel')).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 }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index b5aa7cd..0a8a7f3 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -43,7 +43,8 @@ export const packages = sqliteTable('packages', {
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.
- status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
+ // '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', 'ignored'] }).notNull(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
});
diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts
index 2b3e805..542f720 100644
--- a/lib/game/actions.test.ts
+++ b/lib/game/actions.test.ts
@@ -16,6 +16,7 @@ import {
cancelQueuedLeg,
flyPlane,
GameActionError,
+ ignorePackage,
loadPackage,
queueLeg,
refuelPlane,
@@ -374,6 +375,36 @@ 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', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db');
diff --git a/lib/game/actions.ts b/lib/game/actions.ts
index 55ad92a..1c83a0e 100644
--- a/lib/game/actions.ts
+++ b/lib/game/actions.ts
@@ -207,6 +207,23 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
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 top-up logic (spawnOpenPackages counts 'open') refills the market with
+// a new random package on the next tick. 404 unknown id; 409 unless open.
+export async function ignorePackage(inst: GameDb, packageId: string): Promise {
+ 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' }).where(eq(schema.packages.id, packageId));
+ await persist(inst);
+}
+
// --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at
diff --git a/lib/game/state.ts b/lib/game/state.ts
index ae2a185..f5fc67b 100644
--- a/lib/game/state.ts
+++ b/lib/game/state.ts
@@ -53,7 +53,8 @@ export interface PackageState {
// 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';
+ // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
+ status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
planeId: string | null;
createdSimTime: number;
}
diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts
index 3651a62..cb9a7e7 100644
--- a/lib/openapi.test.ts
+++ b/lib/openapi.test.ts
@@ -3,12 +3,13 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
- it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
+ it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/packages/{id}/abandon',
+ '/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
@@ -146,7 +147,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
- expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
+ expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
});
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
diff --git a/lib/openapi.ts b/lib/openapi.ts
index 29e044a..300a31b 100644
--- a/lib/openapi.ts
+++ b/lib/openapi.ts
@@ -165,6 +165,31 @@ 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': {
post: {
summary: 'Fly a plane to an airport',
@@ -622,7 +647,8 @@ export const openApiSpec = {
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.
- status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
+ // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
+ status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
},
diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts
index 317ea88..b1d70c3 100644
--- a/lib/sim/engine.test.ts
+++ b/lib/sim/engine.test.ts
@@ -105,6 +105,7 @@ describe('expirePackages', () => {
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
+ { id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
]);
const n = await expirePackages(inst, clock);
@@ -116,6 +117,7 @@ describe('expirePackages', () => {
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
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-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 () => {
diff --git a/package.json b/package.json
index 1900acb..0caf2a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.28.0",
+ "version": "0.29.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",