Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
CI / build + unit + e2e (pull_request) Successful in 9m34s
This commit is contained in:
@@ -10,12 +10,15 @@ 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,
|
||||
MAX_PLANE_DISCOUNT,
|
||||
PLANE_DISCOUNT_PER_REP,
|
||||
PLANE_TYPES,
|
||||
REPAIR_COST,
|
||||
REPAIR_DURATION_MS,
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
repairPlane,
|
||||
sellPlane,
|
||||
} from './actions';
|
||||
|
||||
@@ -233,6 +237,30 @@ describe('flyPlane', () => {
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', 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 needs more than that.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
|
||||
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||||
const inst2 = await freshDb('fly-broken-healthy.db');
|
||||
await inst2.db.update(schema.planes)
|
||||
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
@@ -779,3 +807,83 @@ describe('refuelPlane', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
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.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);
|
||||
});
|
||||
|
||||
it('is a free no-op when health is already full', async () => {
|
||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // not grounded…
|
||||
expect(plane.health).toBe(100);
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('repair-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('rejects a plane that is already repairing with 409', async () => {
|
||||
const inst = await freshDb('repair-busy.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
|
||||
const inst = await freshDb('repair-blocks.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await repairPlane(inst, 'plane-starter');
|
||||
|
||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
});
|
||||
|
||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||
const inst = await freshDb('repair-poor.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('repair-unknown.db');
|
||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user