Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d92b5edaa | ||
|
|
4a6a8592e0 | ||
|
|
b8f321dfd5 | ||
|
|
336ed4ee7c |
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.40.0 — 2026-09-21 13:06
|
||||
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
||||
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
||||
|
||||
## v0.39.0 — 2026-09-21 12:02
|
||||
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
|
||||
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
|
||||
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
|
||||
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
|
||||
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
|
||||
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
|
||||
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
|
||||
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
|
||||
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
|
||||
|
||||
## v0.38.0 — 2026-09-21 10:47
|
||||
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
|
||||
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { performAction, repairPlane } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to
|
||||
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days
|
||||
// pass, when the engine's tick flips it back to "available". A full-health repair is a free
|
||||
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success
|
||||
// returns the full state snapshot.
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||
const { planeId } = await ctx.params;
|
||||
return performAction((inst) => repairPlane(inst, planeId));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an
|
||||
// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless
|
||||
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other
|
||||
// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions.
|
||||
//
|
||||
// POST /api/test/set-health
|
||||
// body { planeId: string, health?: number, repairUntilMs?: number }
|
||||
// - planeId is required and must name an existing plane (404 otherwise);
|
||||
// - at least one of health / repairUntilMs must be present;
|
||||
// - health must be a finite number in [0, 100];
|
||||
// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package
|
||||
// near-term-gate precedent) so the next advancing tick flips a shortened repair back.
|
||||
// Returns the full state snapshot after ticking.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const planeId = input.planeId;
|
||||
if (typeof planeId !== 'string' || planeId.length === 0) {
|
||||
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
let health: number | undefined;
|
||||
if (input.health !== undefined) {
|
||||
if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) {
|
||||
return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 });
|
||||
}
|
||||
health = input.health;
|
||||
}
|
||||
let repairUntilMs: number | undefined;
|
||||
if (input.repairUntilMs !== undefined) {
|
||||
if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) {
|
||||
return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 });
|
||||
}
|
||||
repairUntilMs = input.repairUntilMs;
|
||||
}
|
||||
if (health === undefined && repairUntilMs === undefined) {
|
||||
return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
const sim = await tickNow(); // clock is current before we set the gate (same as siblings)
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
if (!rows[0]) {
|
||||
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
|
||||
}
|
||||
|
||||
const sets: Partial<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
|
||||
if (health !== undefined) sets.health = health;
|
||||
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
||||
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
||||
await persist(inst);
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,16 @@ body {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ISS-080 (v0.40.0): the version tag links to the repo's CHANGELOG.md — keep it subtle, like the rest of the header. */
|
||||
.app-header a {
|
||||
color: #7dd3fc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-header a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Style switcher — kept subtle, like the version tag. */
|
||||
.style-switch {
|
||||
font-size: 12px;
|
||||
@@ -214,6 +224,17 @@ body {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
|
||||
.status-broken {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.status-repairing {
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
|
||||
.special-badge {
|
||||
font-size: 11px;
|
||||
@@ -237,6 +258,26 @@ body {
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */
|
||||
.health-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
margin: 2px 0 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.health-bar > div {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */
|
||||
.repair-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.plane-sub {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
+35
-4
@@ -368,17 +368,44 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||
<>
|
||||
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
|
||||
{p.status === 'repairing' && (
|
||||
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
|
||||
)}
|
||||
{p.health <= 0 && (
|
||||
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="fuel-bar" aria-hidden="true">
|
||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||
</div>
|
||||
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
|
||||
<div className="health-bar" aria-hidden="true">
|
||||
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
|
||||
</div>
|
||||
<div className="plane-sub">
|
||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
|
||||
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
|
||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||
<span className="plane-loc">{planeLocation(p)}</span>
|
||||
</div>
|
||||
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
|
||||
{p.status === 'available' && p.health < 100 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`repair-${p.id}`}
|
||||
className="pane-btn repair-btn"
|
||||
disabled={actionsBusy}
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
|
||||
>
|
||||
Repair
|
||||
</button>
|
||||
)}
|
||||
{cargo.map((pkg) => {
|
||||
const penalty = abandonPenalty(pkg, clockMs);
|
||||
return (
|
||||
@@ -402,8 +429,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||
>
|
||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
||||
Fly to {pkg.destName}
|
||||
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||||
Fly to {pkg.destIcao}
|
||||
</span>
|
||||
</button>
|
||||
<div className="onboard-actions">
|
||||
@@ -428,7 +456,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||
{pkg.originIcao} → {pkg.destIcao}
|
||||
</span>
|
||||
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||
{pkg.special && (
|
||||
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||
|
||||
+3
-1
@@ -537,7 +537,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
<div className="map-wrap">
|
||||
<header data-testid="app-header" className="app-header">
|
||||
<h1>Flight Ops</h1>
|
||||
<small>v{pkg.version}</small>
|
||||
<a data-testid="app-version" href="https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md" target="_blank" rel="noreferrer">
|
||||
<small>v{pkg.version}</small>
|
||||
</a>
|
||||
<select
|
||||
data-testid="style-switch"
|
||||
className="style-switch"
|
||||
|
||||
@@ -24,6 +24,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
departureSimTime: null,
|
||||
lat: 50,
|
||||
lon: 8.5,
|
||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
...over,
|
||||
};
|
||||
|
||||
+8
-2
@@ -118,7 +118,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
|
||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before the steps below act on the starter, so the FAR
|
||||
// round-trip feasibility pin never sees a worn or broken plane.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
@@ -188,8 +192,10 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
||||
// package shows as loaded on the starter in the on-board section.
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||
|
||||
// FLY via the UI: click the destination name button in the on-board list.
|
||||
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
await expect(flyBtn).toBeEnabled();
|
||||
await flyBtn.click();
|
||||
|
||||
+2
-1
@@ -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 thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all fourteen 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');
|
||||
@@ -32,6 +32,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
||||
'/api/planes/{planeId}/queue',
|
||||
'/api/planes/{planeId}/queue/cancel',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
// Real helpers (v0.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves
|
||||
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||
import { initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
|
||||
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers.
|
||||
import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog';
|
||||
|
||||
// v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in
|
||||
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short
|
||||
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts
|
||||
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate
|
||||
// passes; shortening the gate via the hook brings it back to service on the next tick.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const STARTER = 'plane-starter';
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
model: string;
|
||||
burnPerKm: number;
|
||||
emptyWeightKg: number;
|
||||
maxFuel: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
catalog: CatalogEntry[];
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||
async function setHealth(
|
||||
page: Page,
|
||||
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||
): Promise<StateBody> {
|
||||
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Closest airport to EDDF from /api/airports — the short fixed leg for the broken-burn flight.
|
||||
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
|
||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||
for (const f of fc.features) {
|
||||
if (f.properties.icao === HUB) continue;
|
||||
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||
nearest = f.properties;
|
||||
}
|
||||
}
|
||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||
async function healStarterHome(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
if (starter.status === 'repairing') {
|
||||
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 30_000,
|
||||
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||
})
|
||||
.toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
}
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
let flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
if (!flyHome.ok()) {
|
||||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
}
|
||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||
async function restoreStarter(page: Page): Promise<void> {
|
||||
const s = await getState(page);
|
||||
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||
await setHealth(page, body);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||
// and leave a clean starter — full health, no active repair gate.
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
await restoreStarter(page);
|
||||
});
|
||||
|
||||
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait
|
||||
|
||||
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
// (a) Force health to 42 — snapshot AND UI show it.
|
||||
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||
|
||||
await page.goto('/');
|
||||
const row = page.getByTestId(`plane-${STARTER}`);
|
||||
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||
.toContainText('42%');
|
||||
|
||||
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel.
|
||||
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||
.toHaveText('broken', { timeout: 15_000 });
|
||||
|
||||
// Full tank for the flight (free no-op if already full)…
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||
s = await getState(page);
|
||||
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||
expect(starter.fuel).toBe(starter.maxFuel);
|
||||
|
||||
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch).
|
||||
const nearest = await nearestToHub(page);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for the broken plane to land at the nearest airport',
|
||||
})
|
||||
.toBe(nearest.icao);
|
||||
|
||||
// Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts).
|
||||
s = await getState(page);
|
||||
const landed = s.planes.find((p) => p.id === STARTER)!;
|
||||
const entry = s.catalog.find((e) => e.model === starter.model)!;
|
||||
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||
const wHub = weatherFor(HUB);
|
||||
const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon));
|
||||
expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6);
|
||||
// Wear clamped at 0 — the broken plane stays broken.
|
||||
expect(landed.health).toBe(0);
|
||||
|
||||
// (c) Repair through the UI: exact cash drop + "in repair" pill.
|
||||
const cashBefore = s.account.cash;
|
||||
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||
// checked against the exact game clock the server used (a later GET would tick further).
|
||||
const [res] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||
repairBtn.click(),
|
||||
]);
|
||||
expect(res.status()).toBe(200);
|
||||
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||
expect(repaired.status).toBe('repairing');
|
||||
expect(repaired.health).toBe(100); // restored to full…
|
||||
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||
|
||||
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||
.toHaveText('in repair', { timeout: 15_000 });
|
||||
|
||||
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
return p.status === 'available' && p.health === 100 ? true : false;
|
||||
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||
.toBe(true);
|
||||
});
|
||||
@@ -33,6 +33,11 @@ test('header shows the app version sourced from package.json', async ({ page })
|
||||
await page.goto(APP_URL);
|
||||
const header = page.getByTestId('app-header');
|
||||
await expect(header).toContainText(`v${pkg.version}`);
|
||||
// ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md.
|
||||
await expect(page.getByTestId('app-version')).toHaveAttribute(
|
||||
'href',
|
||||
'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||
|
||||
@@ -184,7 +184,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
||||
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
const s0 = await getState(page);
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
|
||||
@@ -275,8 +275,11 @@ async function sellNonStarters(page: Page): Promise<void> {
|
||||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||
// no cargo on board — even after an interrupted previous run.
|
||||
async function resetFleet(page: Page): Promise<void> {
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
await sellNonStarters(page);
|
||||
await healPlaneHome(page, STARTER);
|
||||
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||
await abandonLeftoverCargo(page, STARTER);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS planes (
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
departure_sim_time INTEGER,
|
||||
queue TEXT NOT NULL DEFAULT '[]'
|
||||
queue TEXT NOT NULL DEFAULT '[]',
|
||||
health REAL NOT NULL DEFAULT 100,
|
||||
repair_until_sim_time INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -88,6 +90,14 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
if (!cols('packages').has('special')) {
|
||||
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
||||
}
|
||||
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
|
||||
if (!cols('planes').has('health')) {
|
||||
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
|
||||
}
|
||||
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
|
||||
if (!cols('planes').has('repair_until_sim_time')) {
|
||||
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
||||
}
|
||||
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||
|
||||
+9
-1
@@ -7,7 +7,9 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
export const planes = sqliteTable('planes', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
model: text('model').notNull(),
|
||||
status: text('status', { enum: ['available', 'flying'] }).notNull(),
|
||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
||||
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||
status: text('status', { enum: ['available', 'flying', 'repairing'] }).notNull(),
|
||||
fuel: real('fuel').notNull(),
|
||||
maxFuel: real('max_fuel').notNull(),
|
||||
locationIcao: text('location_icao').notNull(),
|
||||
@@ -24,6 +26,12 @@ export const planes = sqliteTable('planes', {
|
||||
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
|
||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||
queue: text('queue').notNull().default('[]'),
|
||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
||||
// landed legs; at 0 the plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel.
|
||||
health: real('health').notNull().default(100),
|
||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
||||
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
||||
repairUntilSimTime: integer('repair_until_sim_time'),
|
||||
});
|
||||
|
||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||
|
||||
@@ -22,6 +22,8 @@ const basePlane = (id: string): PlaneState => ({
|
||||
departureSimTime: null,
|
||||
lat: 50.03,
|
||||
lon: 8.56,
|
||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import {
|
||||
BROKEN_BURN_MULTIPLIER,
|
||||
FUEL_DISCOUNT_PER_REP,
|
||||
FUEL_PRICE_PER_L,
|
||||
MAX_FUEL_DISCOUNT,
|
||||
MAX_PLANE_DISCOUNT,
|
||||
PLANE_DISCOUNT_PER_REP,
|
||||
PLANE_TYPES,
|
||||
REPAIR_COST,
|
||||
REPAIR_DURATION_MS,
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
repairPlane,
|
||||
sellPlane,
|
||||
} from './actions';
|
||||
|
||||
@@ -233,6 +237,30 @@ describe('flyPlane', () => {
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', async () => {
|
||||
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
const w = weatherFor(HUB_ICAO);
|
||||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||
// The tank holds EXACTLY the healthy requirement — a broken plane needs more than that.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
|
||||
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||||
const inst2 = await freshDb('fly-broken-healthy.db');
|
||||
await inst2.db.update(schema.planes)
|
||||
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
@@ -779,3 +807,83 @@ describe('refuelPlane', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||||
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
|
||||
const inst = await freshDb('repair-ok.db');
|
||||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(REPAIR_COST);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
expect(plane.health).toBe(100); // restored to full…
|
||||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||
});
|
||||
|
||||
it('is a free no-op when health is already full', async () => {
|
||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // not grounded…
|
||||
expect(plane.health).toBe(100);
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('repair-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('rejects a plane that is already repairing with 409', async () => {
|
||||
const inst = await freshDb('repair-busy.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
|
||||
const inst = await freshDb('repair-blocks.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await repairPlane(inst, 'plane-starter');
|
||||
|
||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
});
|
||||
|
||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||
const inst = await freshDb('repair-poor.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('repair-unknown.db');
|
||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
+41
-3
@@ -9,12 +9,15 @@ import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||
import {
|
||||
conditionBurnMultiplier,
|
||||
FUEL_DISCOUNT_PER_REP,
|
||||
FUEL_PRICE_PER_L,
|
||||
MAX_FUEL_DISCOUNT,
|
||||
MAX_PLANE_DISCOUNT,
|
||||
PLANE_DISCOUNT_PER_REP,
|
||||
PLANE_TYPES,
|
||||
REPAIR_COST,
|
||||
REPAIR_DURATION_MS,
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
@@ -118,8 +121,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
||||
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||||
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
|
||||
const w = weatherFor(plane.locationIcao);
|
||||
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
|
||||
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health);
|
||||
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||
if (requiredL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
@@ -284,6 +288,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
||||
lat: home.lat,
|
||||
lon: home.lon,
|
||||
departureSimTime: null,
|
||||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||
});
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
await persist(inst);
|
||||
@@ -367,10 +372,11 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
||||
const origin = await airportByIcao(plane.locationIcao);
|
||||
const dest = await airportByIcao(pkg.destIcao);
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
|
||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
|
||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
|
||||
const w = weatherFor(plane.locationIcao);
|
||||
const requiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
@@ -404,6 +410,38 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
||||
return { cost };
|
||||
}
|
||||
|
||||
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||||
|
||||
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing'
|
||||
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
|
||||
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
|
||||
// repair is a free no-op (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
|
||||
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||
}
|
||||
// Free no-op when the plane is already at full health — nothing to repair.
|
||||
if (plane.health >= 100) return { cost: 0 };
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (account.cash < REPAIR_COST) {
|
||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { cost: REPAIR_COST };
|
||||
}
|
||||
|
||||
// --- Route helper (response convention, v0.8.0) ---
|
||||
|
||||
// Run an action under the sim lock — tick FIRST so validation/mutation see current
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { STARTING_REPUTATION } from './seed';
|
||||
|
||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||
@@ -43,3 +43,21 @@ describe('repRate', () => {
|
||||
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
|
||||
describe('conditionBurnMultiplier', () => {
|
||||
it('is exactly 1 for any positive health', () => {
|
||||
expect(conditionBurnMultiplier(100)).toBe(1);
|
||||
expect(conditionBurnMultiplier(42)).toBe(1);
|
||||
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
|
||||
});
|
||||
|
||||
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
|
||||
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
|
||||
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
|
||||
});
|
||||
|
||||
it('pins the broken multiplier at ×1.5', () => {
|
||||
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,25 @@ export const FUEL_DISCOUNT_PER_REP = 0.005;
|
||||
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
|
||||
export const MAX_FUEL_DISCOUNT = 0.2;
|
||||
|
||||
// --- Maintenance (v0.39.0, ISS-069) ---
|
||||
|
||||
// ponytail: balance-tunable — health lost per km of landed legs (~13 mid-range EDDF↔LEMD hops to break from full).
|
||||
export const WEAR_PER_KM = 0.005;
|
||||
|
||||
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
|
||||
export const BROKEN_BURN_MULTIPLIER = 1.5;
|
||||
|
||||
// Broken-state burn factor for one plane's health — the third multiplicative term alongside wind at every
|
||||
// per-plane burn site (flyPlane feasibility, refuel forFlight, engine landed fuel + auto-chain, live display).
|
||||
export function conditionBurnMultiplier(health: number): number {
|
||||
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
|
||||
}
|
||||
|
||||
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
||||
export const REPAIR_COST = 1_500;
|
||||
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
||||
export const REPAIR_DURATION_MS = 3 * 86_400_000;
|
||||
|
||||
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
||||
// id used by buy/dispatch, `label` the display name — clients compute all market
|
||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('buildSeed', () => {
|
||||
lat: 50.0333,
|
||||
lon: 8.5706,
|
||||
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||
health: 100, // v0.39.0 (ISS-069) — full health on first run
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SeedPlane {
|
||||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||||
// column as JSON text on insert; defaults to an empty queue.
|
||||
queue?: string[];
|
||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
|
||||
health: number;
|
||||
}
|
||||
|
||||
export interface SeedPackage {
|
||||
@@ -237,6 +239,7 @@ export function buildSeed(input: {
|
||||
lat: hub.lat,
|
||||
lon: hub.lon,
|
||||
queue: [], // no queued legs on first run (v0.25.0)
|
||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||
};
|
||||
|
||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||
|
||||
+13
-3
@@ -3,7 +3,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
@@ -11,7 +11,8 @@ import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/si
|
||||
export interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
@@ -28,6 +29,11 @@ export interface PlaneState {
|
||||
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
||||
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||
queue: string[];
|
||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
||||
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
|
||||
health: number;
|
||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||
repairUntilSimTime: number | null;
|
||||
}
|
||||
|
||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||
@@ -107,10 +113,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
||||
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
|
||||
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
|
||||
const w = weatherFor(p.locationIcao);
|
||||
return { ...pos, fuel: fuelAtWeighted(
|
||||
p.fuel,
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
@@ -140,6 +148,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
queue: parseIcaoQueue(p.queue),
|
||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||
repairUntilSimTime: p.repairUntilSimTime,
|
||||
};
|
||||
}),
|
||||
packages: packageRows.map((p) => ({
|
||||
|
||||
+17
-17
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
|
||||
snapshot({
|
||||
catalog: [],
|
||||
planes: [
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -144,7 +144,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -154,7 +154,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
it('draws nothing for a flying plane with an empty queue', () => {
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -165,7 +165,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -182,8 +182,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
@@ -193,9 +193,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
|
||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
|
||||
export interface PlaneFeatureProps {
|
||||
id: string;
|
||||
modelLabel: string;
|
||||
status: 'available' | 'flying';
|
||||
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuelText: string; // "1234 / 6300 L"
|
||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||
flying: boolean;
|
||||
|
||||
+9
-2
@@ -3,7 +3,7 @@ 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 fourteen public endpoints (4 read + 10 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/market/buy-plane',
|
||||
@@ -15,6 +15,7 @@ describe('OpenAPI spec', () => {
|
||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
@@ -115,6 +116,7 @@ describe('OpenAPI spec', () => {
|
||||
'destIcao',
|
||||
'destName',
|
||||
'fuel',
|
||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
'id',
|
||||
'lat',
|
||||
'locationIcao',
|
||||
@@ -123,6 +125,7 @@ describe('OpenAPI spec', () => {
|
||||
'maxFuel',
|
||||
'model',
|
||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||
'status',
|
||||
]);
|
||||
|
||||
@@ -174,7 +177,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(error)).toEqual(['error']);
|
||||
});
|
||||
|
||||
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
it('documents the nine action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
// The spec object is a literal type; these helpers view it as plain JSON.
|
||||
const post = (p: string) =>
|
||||
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||
@@ -193,6 +196,7 @@ describe('OpenAPI spec', () => {
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||
]) {
|
||||
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||
@@ -208,6 +212,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
||||
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||
|
||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
||||
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
||||
@@ -233,6 +238,7 @@ describe('OpenAPI spec', () => {
|
||||
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
||||
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
||||
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
||||
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
|
||||
|
||||
// Path parameters are declared on the parameterized routes.
|
||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||
@@ -242,6 +248,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
|
||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
||||
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
|
||||
|
||||
// Request schema shapes.
|
||||
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
||||
|
||||
+38
-1
@@ -404,6 +404,31 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/repair': {
|
||||
post: {
|
||||
summary: 'Repair a grounded plane',
|
||||
description:
|
||||
'Restore a worn-out plane to full health for the flat repair cost (v0.39.0, ISS-069): the plane is grounded in "repairing" status until 3 sim days pass, when the engine\'s tick flips it back to "available". While repairing it cannot load, fly, refuel or be sold; a broken plane (health ≤ 0) still flies but burns 50% more fuel. A full-health repair is a free no-op (cost 0). No body.',
|
||||
operationId: 'repairPlane',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Repair started (or free no-op) — full state snapshot (health 100, status "repairing" with the gate set, cash reduced by the flat cost)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -595,7 +620,7 @@ export const openApiSpec = {
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
status: { enum: ['available', 'flying'] },
|
||||
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
|
||||
fuel: { type: 'number' },
|
||||
maxFuel: { type: 'number' },
|
||||
locationIcao: { type: 'string' },
|
||||
@@ -620,6 +645,16 @@ export const openApiSpec = {
|
||||
description:
|
||||
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
|
||||
},
|
||||
health: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and burns 50% more fuel until repaired.',
|
||||
},
|
||||
repairUntilSimTime: {
|
||||
type: ['integer', 'null'],
|
||||
description:
|
||||
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
@@ -635,6 +670,8 @@ export const openApiSpec = {
|
||||
'lat',
|
||||
'lon',
|
||||
'queue',
|
||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||
],
|
||||
},
|
||||
PackageState: {
|
||||
|
||||
+110
-1
@@ -8,7 +8,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { BROKEN_BURN_MULTIPLIER, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
clampTimeScale,
|
||||
deliverPackages,
|
||||
expirePackages,
|
||||
finishRepairs,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
MAX_TIME_SCALE,
|
||||
MIN_TIME_SCALE,
|
||||
@@ -331,6 +332,114 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
|
||||
async function setupFlight(name: string) {
|
||||
const inst = await freshDb(name);
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
return { inst, from, to, km, type };
|
||||
}
|
||||
|
||||
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||
const { inst, km } = await setupFlight('wear-leg.db');
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −9.8 health
|
||||
});
|
||||
|
||||
it('clamps at 0 across multiple legs', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
|
||||
// Start below one leg's wear so the first landing clamps…
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: WEAR_PER_KM * km / 2 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // clamped — never negative…
|
||||
|
||||
// …and a second leg keeps it pinned at 0.
|
||||
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||
});
|
||||
|
||||
it('a broken plane burns ×1.5 more fuel on the leg (landed fuel)', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('wear-broken.db');
|
||||
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
|
||||
const w = weatherFor(FROM);
|
||||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
// The ×1.5-adjusted closed form — the same factor livePlane() shows in flight…
|
||||
expect(plane.fuel).toBeCloseTo(
|
||||
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1),
|
||||
6,
|
||||
);
|
||||
|
||||
// …and strictly less than the healthy control on the identical leg.
|
||||
const control = await setupFlight('wear-broken-control.db');
|
||||
expect(await deliverPackages(control.inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
const healthy = (await control.inst.db.select().from(schema.planes))[0];
|
||||
expect(healthy.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
|
||||
expect(plane.fuel).toBeLessThan(healthy.fuel); // the broken plane genuinely burned more
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
|
||||
const inst = await freshDb('repair-finish.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
|
||||
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
|
||||
});
|
||||
|
||||
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||
const inst = await freshDb('repair-tick.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
||||
await tickOnce(inst);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
+33
-4
@@ -5,7 +5,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { parseIcaoQueue } from '@/lib/game/state';
|
||||
import {
|
||||
@@ -79,6 +79,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await expirePackages(inst, clockMs);
|
||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||
landedThisTick = await deliverPackages(inst, clockMs);
|
||||
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||
await finishRepairs(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
// at its new location within the same tick.
|
||||
@@ -126,6 +129,23 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
||||
return n;
|
||||
}
|
||||
|
||||
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
|
||||
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
|
||||
// `<=` inclusive at the clock. Returns the count flipped.
|
||||
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||||
let n = 0;
|
||||
for (const p of rows) {
|
||||
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'available', repairUntilSimTime: null })
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||
@@ -160,16 +180,21 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
|
||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
||||
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||||
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
|
||||
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
|
||||
const w = weatherFor(p.locationIcao);
|
||||
const landedFuel = fuelAtWeighted(
|
||||
p.fuel,
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
1,
|
||||
);
|
||||
|
||||
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
|
||||
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
|
||||
|
||||
// Deliver every bound package whose destination is the airport just reached —
|
||||
// runs whether or not a queued leg chains next (ISS-043).
|
||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||
@@ -203,10 +228,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
const nextDest = queue[0];
|
||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||
if (next) {
|
||||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
|
||||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069):
|
||||
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what
|
||||
// livePlane()/the next landing will compute for that flight.
|
||||
const wNext = weatherFor(to.icao);
|
||||
const nextRequiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||
@@ -226,6 +253,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||||
destIcao: chainedTo,
|
||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||
queue: JSON.stringify(queue),
|
||||
@@ -236,6 +264,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||||
departureSimTime: null,
|
||||
destIcao: null,
|
||||
queue: JSON.stringify(queue),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.38.0",
|
||||
"version": "0.40.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user