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:
+20
-29
@@ -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
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user