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:
+110
-1
@@ -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 { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { BROKEN_BURN_MULTIPLIER, PLANE_TYPES, 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';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
clampTimeScale,
|
||||
deliverPackages,
|
||||
expirePackages,
|
||||
finishRepairs,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
MAX_TIME_SCALE,
|
||||
MIN_TIME_SCALE,
|
||||
@@ -331,6 +332,114 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
|
||||
async function setupFlight(name: string) {
|
||||
const inst = await freshDb(name);
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
return { inst, from, to, km, type };
|
||||
}
|
||||
|
||||
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||
const { inst, km } = await setupFlight('wear-leg.db');
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −9.8 health
|
||||
});
|
||||
|
||||
it('clamps at 0 across multiple legs', async () => {
|
||||
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
|
||||
// Start below one leg's wear so the first landing clamps…
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: WEAR_PER_KM * km / 2 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // clamped — never negative…
|
||||
|
||||
// …and a second leg keeps it pinned at 0.
|
||||
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
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'));
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
|
||||
const inst = await freshDb('repair-finish.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
|
||||
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
|
||||
});
|
||||
|
||||
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||
const inst = await freshDb('repair-tick.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
||||
await tickOnce(inst);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
+33
-4
@@ -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 { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { parseIcaoQueue } from '@/lib/game/state';
|
||||
import {
|
||||
@@ -79,6 +79,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await expirePackages(inst, clockMs);
|
||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||
landedThisTick = await deliverPackages(inst, clockMs);
|
||||
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||
await finishRepairs(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
// at its new location within the same tick.
|
||||
@@ -126,6 +129,23 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
||||
return n;
|
||||
}
|
||||
|
||||
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
|
||||
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
|
||||
// `<=` inclusive at the clock. Returns the count flipped.
|
||||
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||||
let n = 0;
|
||||
for (const p of rows) {
|
||||
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'available', repairUntilSimTime: null })
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||
// for every package bound to the plane and destined for its dest_icao — 'loaded'
|
||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||
@@ -160,16 +180,21 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
|
||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
||||
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||||
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
|
||||
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
|
||||
const w = weatherFor(p.locationIcao);
|
||||
const landedFuel = fuelAtWeighted(
|
||||
p.fuel,
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
1,
|
||||
);
|
||||
|
||||
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
|
||||
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
|
||||
|
||||
// Deliver every bound package whose destination is the airport just reached —
|
||||
// runs whether or not a queued leg chains next (ISS-043).
|
||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||
@@ -203,10 +228,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
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).
|
||||
// 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.
|
||||
const wNext = weatherFor(to.icao);
|
||||
const nextRequiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
|
||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||
@@ -226,6 +253,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||||
destIcao: chainedTo,
|
||||
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
|
||||
queue: JSON.stringify(queue),
|
||||
@@ -236,6 +264,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
lat: to.lat,
|
||||
lon: to.lon,
|
||||
fuel: landedFuel,
|
||||
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||||
departureSimTime: null,
|
||||
destIcao: null,
|
||||
queue: JSON.stringify(queue),
|
||||
|
||||
Reference in New Issue
Block a user