Ignore a job: dismiss an open package so it leaves the market (ISS-047 part B, v0.29.0)
CI / build + unit + e2e (pull_request) Failing after 12m34s
CI / build + unit + e2e (pull_request) Failing after 12m34s
New terminal status 'ignored' + ignorePackage action + POST /api/packages/{packageId}/ignore
(no body, no penalty; top-up logic refills the market). Ignore button per open-package row in
the Jobs tab. OpenAPI gains the 13th endpoint; unit tests for the action (happy/409x5/404),
engine terminal-status coverage, and a new e2e spec test.
This commit is contained in:
+2
-1
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<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' }).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
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
-2
@@ -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', () => {
|
||||
|
||||
+27
-1
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user