Auto-refuel on dispatch; remove manual refueling UI/API/action (ISS-082, v0.42.0)
CI / build + unit + e2e (pull_request) Successful in 17m25s
CI / build + unit + e2e (pull_request) Successful in 17m25s
This commit is contained in:
+96
-214
@@ -32,7 +32,6 @@ import {
|
||||
ignorePackage,
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
repairPlane,
|
||||
sellPlane,
|
||||
} from './actions';
|
||||
@@ -231,14 +230,7 @@ describe('flyPlane', () => {
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
|
||||
const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
|
||||
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
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 () => {
|
||||
it('uses the ×1.5 broken burn for the dispatch auto-top-up 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;
|
||||
@@ -247,19 +239,106 @@ describe('flyPlane', () => {
|
||||
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.
|
||||
// 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: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.set({ health: 0, fuel: healthyRequired })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // auto-refuel covers the extra burn — no 409
|
||||
|
||||
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||||
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: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
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) ---
|
||||
|
||||
// Wind-adjusted requirement for the hub → LEMD leg — computed with the SAME helpers flyPlane uses,
|
||||
// so the expectations stay exact without hardcoded literals.
|
||||
async function adjustedRequiredForHubToLemd() {
|
||||
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);
|
||||
return requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
km,
|
||||
);
|
||||
}
|
||||
|
||||
it('auto-refuels on dispatch: tops up to min(maxFuel, max(fuel, 1.5 × required)) and deducts the rep-discounted cost', async () => {
|
||||
const inst = await freshDb('fly-autorefuel.db'); // EDDF → LEMD
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
const startFuel = Math.round(required / 2); // partial tank below the requirement
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
|
||||
await inst.db.update(schema.account).set({ reputation: 30 });
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
||||
const target = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||
const expectedCost = Math.round((target - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||
expect(expectedCost).toBeGreaterThan(0); // the top-up genuinely costs something
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||
});
|
||||
|
||||
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => {
|
||||
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free
|
||||
});
|
||||
|
||||
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
|
||||
const inst = await freshDb('fly-autorefuel-poor.db'); // EDDF → LEMD
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.update(schema.account).set({ cash: 0 });
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // still grounded…
|
||||
expect(plane.fuel).toBe(startFuel); // …with the fuel UNCHANGED — nothing was mutated
|
||||
});
|
||||
|
||||
it('still rejects a leg that does not fit even a full tank with 409 (the auto-refuel backstop)', async () => {
|
||||
const inst = await freshDb('fly-autorefuel-nofit.db');
|
||||
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -612,202 +691,6 @@ describe('sellPlane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refuelPlane', () => {
|
||||
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
|
||||
const inst = await freshDb('refuel-ok.db');
|
||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
});
|
||||
|
||||
it('is a free no-op when the tank is already full', async () => {
|
||||
const inst = await freshDb('refuel-full.db'); // seeded starter is full
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('refuel-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
|
||||
});
|
||||
|
||||
it('rejects the fill-up when cash is below the cost with 409', async () => {
|
||||
const inst = await freshDb('refuel-poor.db');
|
||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960
|
||||
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
|
||||
const inst = await freshDb('refuel-rep.db');
|
||||
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
|
||||
await inst.db.update(schema.account).set({ reputation: 30 });
|
||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
expect(rate).toBeGreaterThan(0);
|
||||
|
||||
// 'full': exact discounted cost, tank math untouched.
|
||||
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||||
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
|
||||
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
|
||||
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||
await loadPackage(inst, 'p-ff-r', 'plane-starter');
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
|
||||
|
||||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||||
const plane2 = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('refuel-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
|
||||
// --- forFlight mode (v0.16.0) ---
|
||||
|
||||
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
|
||||
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
|
||||
async function adjustedRequiredForHubToLemd() {
|
||||
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);
|
||||
return requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
km,
|
||||
);
|
||||
}
|
||||
|
||||
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
|
||||
const inst = await freshDb('refuel-forflight-ok.db');
|
||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
||||
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
|
||||
// Partial tank below the requirement so L > 0.
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
||||
|
||||
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
|
||||
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
});
|
||||
|
||||
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
|
||||
const inst = await freshDb('refuel-ff-flyafter.db');
|
||||
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||
await loadPackage(inst, 'p-fa', 'plane-starter');
|
||||
|
||||
const required = await adjustedRequiredForHubToLemd();
|
||||
|
||||
// Empty-ish tank: well below the leg requirement.
|
||||
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
||||
|
||||
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('LEMD');
|
||||
});
|
||||
|
||||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
||||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
||||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
||||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
|
||||
await insertOpenPackage(inst, 'p-x');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
|
||||
|
||||
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
|
||||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
||||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an expired package with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-expired.db');
|
||||
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an unknown package id with 404', async () => {
|
||||
const inst = await freshDb('refuel-ff-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
|
||||
});
|
||||
|
||||
it('forFlight rejects a missing packageId with 400', async () => {
|
||||
const inst = await freshDb('refuel-ff-nopkg.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
|
||||
});
|
||||
|
||||
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-nofit.db');
|
||||
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
|
||||
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
|
||||
await loadPackage(inst, 'p-far', 'plane-starter');
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(
|
||||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
||||
409,
|
||||
/does not fit/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -857,7 +740,7 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||||
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 () => {
|
||||
it('blocks load/fly/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');
|
||||
@@ -865,7 +748,6 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||||
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,
|
||||
|
||||
+29
-86
@@ -25,9 +25,9 @@ import { abandonPenalty } from './penalty';
|
||||
import { HUB_ICAO } from './seed';
|
||||
|
||||
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
||||
// load a package onto a grounded plane at its origin, fly to any airport, deliver
|
||||
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
|
||||
// refuel. Each function validates and mutates ONE throwaway-safe GameDb instance
|
||||
// load a package onto a grounded plane at its origin, fly to any airport (dispatch
|
||||
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
|
||||
// grounded; plus buy/sell planes. Each function validates and mutates ONE throwaway-safe GameDb instance
|
||||
// (persisted at the end); routes wrap them in engine.runAction() so tick +
|
||||
// validate + mutate are atomic under the sim lock. Validation failures throw
|
||||
// GameActionError carrying the HTTP status:
|
||||
@@ -102,7 +102,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
||||
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
|
||||
// loaded packages' destinations or pure repositioning. On arrival the engine
|
||||
// delivers whatever is bound for that airport. Validation: plane 'available',
|
||||
// destination in the airport dataset, enough fuel for the leg.
|
||||
// destination in the airport dataset, enough fuel for the leg. ISS-082 (v0.42.0):
|
||||
// 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.
|
||||
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];
|
||||
@@ -125,8 +129,22 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
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 requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||
if (requiredL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
|
||||
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
||||
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
|
||||
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
|
||||
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||
const liters = Math.max(0, target - plane.fuel);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost > 0 && account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
|
||||
if (requiredL > target) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} L`);
|
||||
}
|
||||
|
||||
// runAction() ticked first, so this is current game time.
|
||||
@@ -140,8 +158,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
departureSimTime: sim.clockMs,
|
||||
lat: origin.lat,
|
||||
lon: origin.lon,
|
||||
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||
@@ -331,91 +353,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
||||
return { payout };
|
||||
}
|
||||
|
||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
||||
|
||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
|
||||
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
|
||||
// requirement per the weighted burn model, capped at tank capacity, independent of how
|
||||
// much is already in the tank.
|
||||
export interface RefuelOptions {
|
||||
mode?: 'full' | 'forFlight';
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
|
||||
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
|
||||
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
|
||||
// leg doesn't fit a full tank).
|
||||
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
|
||||
}
|
||||
|
||||
const mode = opts.mode ?? 'full';
|
||||
let targetFuel: number; // tank level after the refuel
|
||||
if (mode === 'forFlight') {
|
||||
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
|
||||
|
||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
|
||||
const pkg = pkgRows[0];
|
||||
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
|
||||
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
|
||||
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
|
||||
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
|
||||
}
|
||||
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const origin = await airportByIcao(plane.locationIcao);
|
||||
const dest = await airportByIcao(pkg.destIcao);
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
|
||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
|
||||
const w = weatherFor(plane.locationIcao);
|
||||
const requiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
);
|
||||
if (requiredL > plane.maxFuel) {
|
||||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
||||
}
|
||||
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
|
||||
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
|
||||
// → free no-op, tank untouched.
|
||||
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||
} else {
|
||||
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
||||
targetFuel = plane.maxFuel;
|
||||
}
|
||||
|
||||
const liters = Math.max(0, targetFuel - plane.fuel);
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost > 0 && account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
await persist(inst);
|
||||
return { cost };
|
||||
}
|
||||
|
||||
// --- 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 (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
|
||||
// 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];
|
||||
|
||||
+4
-3
@@ -100,7 +100,8 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
// The plane the player starts with.
|
||||
export const STARTER_MODEL = 'atr72';
|
||||
|
||||
// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this.
|
||||
// Jet-A1 fuel price in €/liter (v0.8.0): top-up cost = liters × this — applied to the automatic
|
||||
// dispatch top-ups since ISS-082 (manual refuel removed).
|
||||
export const FUEL_PRICE_PER_L = 0.8;
|
||||
|
||||
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
||||
@@ -131,7 +132,7 @@ export const WEAR_PER_KM = 0.005;
|
||||
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 feasibility, refuel forFlight, engine landed fuel + auto-chain, live display).
|
||||
// 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;
|
||||
}
|
||||
@@ -143,7 +144,7 @@ export const REPAIR_DURATION_MS = 3 * 86_400_000;
|
||||
|
||||
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
||||
// id used by buy/dispatch, `label` the display name — clients compute all market
|
||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
||||
// previews (buy price, sell payout) from these + the economy constants.
|
||||
export interface CatalogEntry {
|
||||
model: string;
|
||||
label: string;
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ export interface StateSnapshot {
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||
// the client can compute buy/sell/refuel previews without a separate endpoint.
|
||||
// the client can compute buy/sell previews without a separate endpoint.
|
||||
catalog: CatalogEntry[];
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
|
||||
Reference in New Issue
Block a user