Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s

This commit is contained in:
2026-09-21 12:06:45 +00:00
parent 929452ca33
commit 336ed4ee7c
28 changed files with 868 additions and 41 deletions
+33 -4
View File
@@ -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),