Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s

- flyPlane 409s a broken plane (health <= 0) before any refuel math — fuel/cash untouched
- engine auto-chain skips chaining when a landing's wear breaks the plane (queue kept intact)
- finishRepairs heals along round(f x 100) each tick; flip to available at exactly 100 on gate pass
- repairPlane no longer jumps health to 100 — it starts the gradual heal
- REMOVED BROKEN_BURN_MULTIPLIER / conditionBurnMultiplier and all burn sites (unreachable now)
- OpenAPI + route comments reworded; unit tests reworked; e2e maintenance.spec.ts per issue
- v0.45.0
This commit is contained in:
2026-09-21 21:17:51 +00:00
parent 0de0aaffd9
commit 1401a2f6eb
14 changed files with 164 additions and 180 deletions
+20 -29
View File
@@ -10,7 +10,6 @@ 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,
@@ -230,36 +229,28 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
it('uses the ×1.5 broken burn for the dispatch auto-top-up when health is 0 (v0.39.0, ISS-069)', async () => {
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', 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's 150% top-up target is higher.
const healthyRequired = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes)
.set({ health: 0, fuel: healthyRequired })
.where(eq(schema.planes.id, 'plane-starter'));
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await flyPlane(inst, 'plane-starter', 'LEMD'); // auto-refuel covers the extra burn — no 409
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
});
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
const brokenRequired = requiredFuelFor(type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km);
expect(brokenRequired).toBeGreaterThan(healthyRequired); // sanity: the multiplier genuinely raises the requirement
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, brokenRequired * 1.5)), 6); // topped up for the BROKEN burn…
// …while the same tank at full health tops up to the LOWER healthy target — the multiplier is what raised it.
const inst2 = await freshDb('fly-broken-healthy.db');
await inst2.db.update(schema.planes).set({ fuel: healthyRequired }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, healthyRequired * 1.5)), 6);
expect(plane.fuel).toBeGreaterThan(plane2.fuel); // the broken plane genuinely got more fuel
});
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
@@ -691,8 +682,8 @@ describe('sellPlane', () => {
});
});
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 () => {
describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', 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;
@@ -702,7 +693,7 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
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.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
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);
+14 -9
View File
@@ -9,7 +9,6 @@ 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,
@@ -106,7 +105,8 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated.
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
@@ -114,6 +114,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
}
if (plane.health <= 0) {
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
}
const fc = await loadAirportsGeoJson();
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
@@ -125,9 +128,8 @@ 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)) * conditionBurnMultiplier(plane.health);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
@@ -355,10 +357,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- 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. 404 unknown plane; 409 not grounded / insufficient funds.
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 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];
@@ -377,8 +381,9 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
}
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes)
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.set({ 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);
+1 -19
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
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 { 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,21 +43,3 @@ 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);
});
});
-9
View File
@@ -128,15 +128,6 @@ export const MAX_FUEL_DISCOUNT = 0.2;
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
export const WEAR_PER_KM = 0.0005;
// 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 dispatch top-up + backstop, 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).
+4 -5
View File
@@ -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 { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { 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).
@@ -30,7 +30,8 @@ export interface PlaneState {
// 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.
// plane is broken and cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is
// also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time.
health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null;
@@ -113,12 +114,10 @@ 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)) * conditionBurnMultiplier(p.health),
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,