diff --git a/CHANGELOG.md b/CHANGELOG.md index cc76051..182befa 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/{packageId}/ignore` (`app/api/packages/[packageId]/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/[packageId]/ignore/route.ts b/app/api/packages/[packageId]/ignore/route.ts new file mode 100644 index 0000000..ea4fc87 --- /dev/null +++ b/app/api/packages/[packageId]/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<{ packageId: string }> }) { + const { packageId } = await ctx.params; + return performAction((inst) => ignorePackage(inst, packageId)); +} 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..673c789 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'); @@ -27,6 +27,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package '/api/market/sell-plane', '/api/packages/{id}/abandon', '/api/packages/{id}/load', + '/api/packages/{packageId}/ignore', // v0.29.0 (ISS-047 part B) '/api/planes/{planeId}/fly', '/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue/cancel', 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..07d1b5a 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,13 +3,14 @@ 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}/load', + '/api/packages/{packageId}/ignore', // v0.29.0 (ISS-047 part B) '/api/planes/{planeId}/fly', '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue/cancel', // 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..690de91 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -165,6 +165,31 @@ export const openApiSpec = { }, }, }, + '/api/packages/{packageId}/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: 'packageId', 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",