v0.69.0: remove job ignore feature
CI / build + unit + e2e (pull_request) Successful in 14m31s

This commit is contained in:
2026-09-23 22:01:39 +00:00
parent 7d117f6809
commit d2defe4cd3
20 changed files with 48 additions and 171 deletions
+2 -3
View File
@@ -53,15 +53,14 @@ 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.
// '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(),
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
planeId: text('plane_id'),
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
// 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.
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
// jobs set nothing: an untaken job's slot respawns immediately.
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
-31
View File
@@ -28,7 +28,6 @@ import {
cancelQueuedLeg,
flyPlane,
GameActionError,
ignorePackage,
loadPackage,
queueLeg,
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', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
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 };
}
// --- 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}) ---
// 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
// 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 —
// 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
-1
View File
@@ -40,7 +40,6 @@ describe('sseFrame', () => {
pkg('loaded', 'p-loaded'),
pkg('abandoned', 'p-abandoned'),
pkg('expired', 'p-expired'),
pkg('ignored', 'p-ignored'),
];
const frame = sseFrame(snapshot(all));
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.
distanceKm: 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' | 'ignored';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
// 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
// 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.
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';
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([
'/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/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)
@@ -153,7 +152,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
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', () => {
+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': {
post: {
summary: 'Fly a plane to an airport',
@@ -604,8 +579,7 @@ 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.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
planeId: { type: ['string', 'null'] },
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.' },
+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-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-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);
@@ -163,7 +162,6 @@ describe('expireLoadedPackages (sim clock)', () => {
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-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 () => {
@@ -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 () => {
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).
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS.
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
// 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)) {
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 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: '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.
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 { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
@@ -111,7 +111,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await finishRepairs(inst, clockMs);
}
// 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
// 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.
@@ -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
// 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
// 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> {
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;
for (const p of rows) {
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
// 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
// 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
// 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.
@@ -385,8 +389,8 @@ export async function spawnPoolPackages(
let n = 0;
for (const originIcao of pool) {
// Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter:
// terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp.
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
const openByBand = [0, 0, 0, 0];
let blockedUntil: number | null = null;
for (const p of packageRows) {