Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s
CI / build + unit + e2e (pull_request) Successful in 8m22s
- flyPlane 409s a broken plane (health <= 0) before any refuel math — fuel/cash untouched - engine auto-chain skips chaining when a landing's wear breaks the plane (queue kept intact) - finishRepairs heals along round(f x 100) each tick; flip to available at exactly 100 on gate pass - repairPlane no longer jumps health to 100 — it starts the gradual heal - REMOVED BROKEN_BURN_MULTIPLIER / conditionBurnMultiplier and all burn sites (unreachable now) - OpenAPI + route comments reworded; unit tests reworked; e2e maintenance.spec.ts per issue - v0.45.0
This commit is contained in:
+23
-52
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user