diff --git a/CHANGELOG.md b/CHANGELOG.md index c5bbc17..b98dc8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/api/planes/[planeId]/repair/route.ts b/app/api/planes/[planeId]/repair/route.ts index 0e55838..07f3506 100644 --- a/app/api/planes/[planeId]/repair/route.ts +++ b/app/api/planes/[planeId]/repair/route.ts @@ -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; diff --git a/e2e/maintenance.spec.ts b/e2e/maintenance.spec.ts index 0fcd787..cc19300 100644 --- a/e2e/maintenance.spec.ts +++ b/e2e/maintenance.spec.ts @@ -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 { - 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') diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 84fecd3..1344a67 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -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, ); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 7a44726..1c72dc3 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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. diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index ac32408..122d026 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -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); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index bf89738..98e2832 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -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 { 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); diff --git a/lib/game/catalog.test.ts b/lib/game/catalog.test.ts index 82a5334..6d8aebc 100644 --- a/lib/game/catalog.test.ts +++ b/lib/game/catalog.test.ts @@ -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); - }); -}); diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index efc7bc1..5ee5753 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -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). diff --git a/lib/game/state.ts b/lib/game/state.ts index 7ad2aec..f71ed11 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -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 { 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, diff --git a/lib/openapi.ts b/lib/openapi.ts index 4d9697e..ff92ac5 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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'], diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index b985e66..a99082c 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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)', () => { diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index dfe9ec8..881172a 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -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 { 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 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), diff --git a/package.json b/package.json index 82e5703..1c40e3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.44.0", + "version": "0.45.0", "private": true, "scripts": { "dev": "next dev -p 3000",