Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s
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:
+48
-23
@@ -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)', () => {
|
||||
|
||||
+30
-17
@@ -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 { 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 { parseIcaoQueue } from '@/lib/game/state';
|
||||
import {
|
||||
@@ -130,18 +130,32 @@ 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.
|
||||
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
|
||||
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
|
||||
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
|
||||
// back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as
|
||||
// expirePackages: one guarded pass over the rows. 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) {
|
||||
if (p.repairUntilSimTime === null) continue; // defensive — repairing always carries a gate
|
||||
// ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a
|
||||
// plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the
|
||||
// start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive,
|
||||
// as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane
|
||||
// is still 'repairing' and grounded until the gate passes.
|
||||
const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS));
|
||||
if (p.repairUntilSimTime <= clockMs) {
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'available', repairUntilSimTime: null })
|
||||
.set({ status: 'available', health: 100, repairUntilSimTime: null })
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
n++;
|
||||
} else {
|
||||
const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100)));
|
||||
if (newHealth !== p.health) {
|
||||
await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
@@ -152,8 +166,9 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
|
||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
||||
// immediately from the arrival airport; with a short tank, ISS-082 (v0.42.0) tops
|
||||
// auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
|
||||
// broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
|
||||
// airport; with a short tank, ISS-082 (v0.42.0) tops
|
||||
// up to 150% of the requirement (capped at tank capacity) when cash allows and
|
||||
// chains anyway — only an unaffordable top-up grounds the plane there with its
|
||||
// remaining fuel and the queue kept intact for a later dispatch.
|
||||
@@ -183,12 +198,10 @@ 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)) * conditionBurnMultiplier(p.health),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
@@ -225,20 +238,20 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
||||
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
||||
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
|
||||
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way.
|
||||
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0
|
||||
// (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with
|
||||
// the queue intact until repaired, exactly like flyPlane's broken guard.
|
||||
const queue = parseIcaoQueue(p.queue);
|
||||
let chainedTo: string | null = null;
|
||||
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||
if (queue.length > 0) {
|
||||
if (queue.length > 0 && newHealth > 0) {
|
||||
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). 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.
|
||||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
|
||||
const wNext = weatherFor(to.icao);
|
||||
const nextRequiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||
|
||||
Reference in New Issue
Block a user