Compare commits

...
2 Commits
Author SHA1 Message Date
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
kamilkrzywda 1401a2f6eb 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
2026-09-21 21:17:51 +00:00
14 changed files with 164 additions and 180 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
## v0.44.0 — 2026-09-21 17:10
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
+5 -5
View File
@@ -1,10 +1,10 @@
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.
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
// "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
// the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). 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;
+23 -52
View File
@@ -1,18 +1,14 @@
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: expectations use the real balance constants.
import { 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.
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — 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 blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
// passes.
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
@@ -69,14 +65,6 @@ async function setHealth(
return (await res.json()) as StateBody;
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// 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;
@@ -88,8 +76,7 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
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.
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
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 } }>;
@@ -173,8 +160,8 @@ test.afterEach(async ({ page }) => {
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
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
@@ -188,41 +175,25 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
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.
// (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
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 (ISS-082 hook — dispatch would only top up to 150% of the leg, not full)…
await setFuelFull(page, STARTER);
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
const nearest = await nearestToHub(page);
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!;
expect(starter.fuel).toBe(starter.maxFuel);
expect(starter.status).toBe('available'); // still grounded…
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
// …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.
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
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 });
@@ -237,7 +208,7 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
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.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
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')
+5 -5
View File
@@ -6,7 +6,7 @@ import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
@@ -27,7 +27,7 @@ interface PlaneState {
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
health: number; // v0.39.0 (ISS-069) — feeds conditionBurnMultiplier in the auto-refuel math
health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
locationIcao: string;
destIcao: string | null;
}
@@ -389,11 +389,11 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
// wind-adjusted requirement × condition multiplier, 150% margin capped at tank capacity.
// wind-adjusted requirement, 150% margin capped at tank capacity.
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health),
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
@@ -424,7 +424,7 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
const landed = await getState(page);
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health), entry.emptyWeightKg, entry.maxFuel, km, 1),
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
6,
);
+1 -1
View File
@@ -27,7 +27,7 @@ export const planes = sqliteTable('planes', {
// 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.
// landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
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.
+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,
+5 -5
View File
@@ -223,7 +223,7 @@ export const openApiSpec = {
},
409: {
description:
'Plane not grounded, or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
'Plane not grounded, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
@@ -233,7 +233,7 @@ export const openApiSpec = {
post: {
summary: 'Queue a flight leg for a plane',
description:
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up grounds the plane with the queue kept intact for a later dispatch.',
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later dispatch.',
operationId: 'queueLeg',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -370,14 +370,14 @@ export const openApiSpec = {
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 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.',
'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. 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)',
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
@@ -594,7 +594,7 @@ export const openApiSpec = {
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.',
'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 cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
},
repairUntilSimTime: {
type: ['integer', 'null'],
+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)', () => {
+30 -17
View File
@@ -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),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.44.0",
"version": "0.45.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",