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
+48 -23
View File
@@ -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 { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, 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';
@@ -313,17 +313,16 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), post-wear health,
// wind-adjusted requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
// requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
const newHealth = Math.max(0, 100 - WEAR_PER_KM * km);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext * conditionBurnMultiplier(newHealth),
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
@@ -381,7 +380,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
});
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
@@ -433,27 +432,22 @@ describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
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'));
it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
// Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
// guard can explain why it doesn't chain.
await inst.db.update(schema.planes)
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
.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
expect(plane.health).toBe(0); // broken by this landing's wear…
expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
});
});
@@ -487,6 +481,37 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('repair-curve.db');
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
const gate = T0 + REPAIR_DURATION_MS;
await inst.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(50);
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
const inst2 = await freshDb('repair-curve-floor.db');
await inst2.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
// At the gate: exactly 100 and back in service.
expect(await finishRepairs(inst, gate)).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
});
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {