Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11fc6f9d5c | ||
|
|
8f32d5bf81 | ||
|
|
a8233829c3 | ||
|
|
bf6f1b3c95 | ||
|
|
9ce8660fd2 | ||
|
|
3dbac9c549 | ||
|
|
b76acbacac | ||
|
|
479b993c09 | ||
|
|
76f5b6f8ce | ||
|
|
1401a2f6eb | ||
|
|
0de0aaffd9 |
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.49.0 — 2026-09-22 07:25
|
||||||
|
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
|
||||||
|
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
|
||||||
|
|
||||||
|
## v0.48.0 — 2026-09-22 06:55
|
||||||
|
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
||||||
|
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
||||||
|
|
||||||
|
## v0.47.0 — 2026-09-21 23:01
|
||||||
|
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
|
||||||
|
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
|
||||||
|
|
||||||
|
## v0.46.0 — 2026-09-21 22:32
|
||||||
|
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
|
||||||
|
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
|
||||||
|
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
|
||||||
|
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
|
||||||
|
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
|
||||||
|
|
||||||
|
## v0.45.0 — 2026-09-21 21:12
|
||||||
|
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
|
||||||
|
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
|
||||||
|
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
|
||||||
|
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
|
||||||
|
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
|
||||||
|
|
||||||
## v0.44.0 — 2026-09-21 17:10
|
## v0.44.0 — 2026-09-21 17:10
|
||||||
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
|
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
|
||||||
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
|
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { performAction, repairPlane } from '@/lib/game/actions';
|
import { performAction, repairPlane } from '@/lib/game/actions';
|
||||||
|
|
||||||
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to
|
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
|
||||||
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days
|
// "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
|
||||||
// pass, when the engine's tick flips it back to "available". A full-health repair is a free
|
// the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
|
||||||
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success
|
// repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
|
||||||
// returns the full state snapshot.
|
// On success returns the full state snapshot.
|
||||||
|
|
||||||
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
const { planeId } = await ctx.params;
|
const { planeId } = await ctx.params;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { GET } from './route';
|
import { GET } from './route';
|
||||||
|
|
||||||
@@ -58,9 +60,20 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const packages = body.packages as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(packages.length).toBeGreaterThan(0);
|
expect(packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
||||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||||
|
// fixture airports' coordinates for every package in the snapshot.
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const from = coords.get(String(pkg.originIcao));
|
||||||
|
const to = coords.get(String(pkg.destIcao));
|
||||||
|
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
|
||||||
|
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
|
||||||
|
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
|
||||||
|
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
|
||||||
|
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
|
||||||
|
// stays for relative grants, this one is exact.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-cash
|
||||||
|
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
|
||||||
|
// account.cash to exactly that value and returns the full state snapshot after ticking.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'amount') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const amount = input.amount;
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||||
|
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
|||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
@@ -441,6 +441,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||||
{pkg.originIcao} → {pkg.destIcao}
|
{pkg.originIcao} → {pkg.destIcao}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
|
||||||
|
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||||
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||||
{pkg.special && (
|
{pkg.special && (
|
||||||
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||||
|
|||||||
+14
-9
@@ -231,21 +231,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||||
const w = weatherFor(p.icao);
|
const w = weatherFor(p.icao);
|
||||||
const localTime = approxLocalTime(p.lon, Date.now());
|
const localTime = approxLocalTime(p.lon, Date.now());
|
||||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
|
||||||
|
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
|
||||||
const st = stateRef.current;
|
const st = stateRef.current;
|
||||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
const planes = st ? st.planes : [];
|
||||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||||
const relocateHtml =
|
const relocateHtml =
|
||||||
groundedPlanes.length > 0
|
planes.length > 0
|
||||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||||
`<option value="">Select a plane\u2026</option>` +
|
`<option value="">Select a plane\u2026</option>` +
|
||||||
groundedPlanes.map(
|
planes.map(
|
||||||
(pl) =>
|
(pl) =>
|
||||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||||
).join('') +
|
).join('') +
|
||||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||||
const popup = new maplibregl.Popup({ offset: 10 })
|
const popup = new maplibregl.Popup({ offset: 10 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
@@ -263,7 +264,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const el = popup.getElement();
|
const el = popup.getElement();
|
||||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
// The status span is always in the popup HTML (even with no planes), so it
|
||||||
// exists whenever the select/button do.
|
// exists whenever the select/button do.
|
||||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||||
if (select && button && statusEl) {
|
if (select && button && statusEl) {
|
||||||
@@ -276,7 +277,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
statusEl.textContent = 'Relocating\u2026';
|
statusEl.textContent = 'Relocating\u2026';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||||
|
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||||
|
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||||
|
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||||
|
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destIcao: p.icao }),
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
@@ -285,7 +290,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||||
statusEl.textContent = 'Relocation started.';
|
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||||
button.disabled = false; // allow retry / picking another plane
|
button.disabled = false; // allow retry / picking another plane
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { maxRangeKm } from '@/lib/sim/movement';
|
import { maxRangeKm } from '@/lib/sim/movement';
|
||||||
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -234,6 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
|
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
|
||||||
|
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||||
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||||
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||||
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
weightKg: 1_000,
|
weightKg: 1_000,
|
||||||
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
|
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||||
deadlineSimTime: 1_000_000,
|
deadlineSimTime: 1_000_000,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||||
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||||
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||||
|
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
|
||||||
|
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||||
|
|
||||||
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
|
|||||||
+23
-52
@@ -1,18 +1,14 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } 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
|
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
|
||||||
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
import { REPAIR_COST } from '../lib/game/catalog';
|
||||||
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 visible flow against the live origin: health is shown per plane in
|
// v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
|
||||||
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short
|
// origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
|
||||||
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts
|
// the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
|
||||||
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate
|
// exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
|
||||||
// passes; shortening the gate via the hook brings it back to service on the next tick.
|
// 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 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
|
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;
|
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).
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
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)));
|
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.
|
// Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
|
||||||
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
|
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
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 {
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
@@ -173,8 +160,8 @@ test.afterEach(async ({ page }) => {
|
|||||||
await restoreStarter(page);
|
await restoreStarter(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => {
|
test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait
|
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
|
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')
|
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||||
.toContainText('42%');
|
.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 });
|
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||||
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||||
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||||
.toHaveText('broken', { timeout: 15_000 });
|
.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)…
|
// The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
|
||||||
await setFuelFull(page, STARTER);
|
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);
|
s = await getState(page);
|
||||||
const starter = s.planes.find((p) => p.id === STARTER)!;
|
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).
|
// (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
|
||||||
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.
|
|
||||||
const cashBefore = s.account.cash;
|
const cashBefore = s.account.cash;
|
||||||
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||||
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
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
|
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||||
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||||
expect(repaired.status).toBe('repairing');
|
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)
|
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')
|
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
|
||||||
|
import { loadAirportsGeoJson } from '../lib/airports';
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
|
||||||
|
import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||||
|
|
||||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||||
@@ -14,7 +21,9 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
|
|||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
|
model: string;
|
||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
@@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
|||||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
|
||||||
|
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
|
||||||
|
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
|
||||||
|
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
|
||||||
|
async function setCash(page: Page, amount: number): Promise<void> {
|
||||||
|
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
@@ -450,3 +470,139 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
|||||||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
|
||||||
|
|
||||||
|
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
|
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
|
||||||
|
for (const icao of [DEST, QUEUE, HUB]) {
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p0.status).toBe('flying');
|
||||||
|
expect(p0.destIcao).toBe(DEST);
|
||||||
|
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
|
||||||
|
|
||||||
|
// Landing at DEST auto-chains EHAM (leg 2)…
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
// …and landing at EHAM chains the return to HUB (leg 3).
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === HUB;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||||
|
|
||||||
|
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two legs at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
|
||||||
|
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
|
||||||
|
|
||||||
|
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
|
||||||
|
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
const STAGED_CASH = 20_000;
|
||||||
|
await setCash(page, STAGED_CASH);
|
||||||
|
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q.status()).toBe(200);
|
||||||
|
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
|
||||||
|
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
|
||||||
|
|
||||||
|
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
|
||||||
|
await setFuel(page, 'plane-starter', 0);
|
||||||
|
|
||||||
|
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
|
||||||
|
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
|
||||||
|
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
|
||||||
|
|
||||||
|
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
|
||||||
|
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
|
||||||
|
expect(landedFuel).toBeCloseTo(0, 6);
|
||||||
|
|
||||||
|
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
|
||||||
|
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
|
||||||
|
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
|
||||||
|
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
|
||||||
|
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
|
||||||
|
|
||||||
|
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
|
||||||
|
const liters = Math.max(0, target - landedFuel);
|
||||||
|
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||||
|
|
||||||
|
// Landing at DEST auto-chains QUEUE with the top-up…
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||||
|
|
||||||
|
const s = await getState(page);
|
||||||
|
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
|
||||||
|
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||||||
|
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
||||||
|
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
|
||||||
|
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
|
||||||
|
|
||||||
|
// Let leg 2 finish so the next test starts from a known airport.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
|
||||||
|
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one leg at scale 1000
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||||
|
expect(q.status()).toBe(200);
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
|
||||||
|
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(cancel.status()).toBe(200);
|
||||||
|
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||||
|
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p.destIcao).toBeNull(); // no chained leg started…
|
||||||
|
expect(p.queue).toEqual([]); // …and the queue is empty
|
||||||
|
});
|
||||||
|
|||||||
+83
-3
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||||||
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
|
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||||
// the existing e2e/map.spec.ts click tests, not here.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
|||||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
@@ -195,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
||||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
|
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||||||
|
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||||||
|
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||||||
|
// deterministic regardless of how fast the UI moves.
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||||||
|
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m || !m.getSource('planes')) return false;
|
||||||
|
try {
|
||||||
|
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
|
||||||
|
} catch {
|
||||||
|
return false; // source not ready yet — keep polling
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const pt = await page.evaluate(({ lon, lat }) => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([lon, lat]);
|
||||||
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
|
}, POPUP_AIRPORT);
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText(POPUP_AIRPORT.icao);
|
||||||
|
|
||||||
|
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||||||
|
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||||||
|
const planeSel = page.getByTestId('airport-popup-plane');
|
||||||
|
await expect(planeSel).toBeVisible();
|
||||||
|
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
|
||||||
|
const goBtn = page.getByTestId('airport-popup-relocate');
|
||||||
|
await expect(goBtn).toBeDisabled(); // placeholder selected
|
||||||
|
|
||||||
|
await planeSel.selectOption('plane-starter');
|
||||||
|
await expect(goBtn).toBeEnabled();
|
||||||
|
await goBtn.click();
|
||||||
|
|
||||||
|
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||||||
|
// the flight queue while the plane keeps flying its original destination.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { initialBearingDeg } from '../lib/geo';
|
|||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
|
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.
|
// 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';
|
import { STARTING_REPUTATION } from '../lib/game/seed';
|
||||||
|
|
||||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||||
@@ -27,7 +27,7 @@ interface PlaneState {
|
|||||||
status: 'available' | 'flying';
|
status: 'available' | 'flying';
|
||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: 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;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(rows.first()).toContainText(/→/);
|
await expect(rows.first()).toContainText(/→/);
|
||||||
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||||
|
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
|
||||||
|
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
|
||||||
|
|
||||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
@@ -220,6 +222,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||||||
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
||||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||||
|
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
|
||||||
|
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
await expect(loadBtn).toBeEnabled();
|
await expect(loadBtn).toBeEnabled();
|
||||||
|
|
||||||
@@ -389,11 +393,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
|
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
|
||||||
|
|
||||||
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
|
// 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 km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||||
const wHub = weatherFor(HUB);
|
const wHub = weatherFor(HUB);
|
||||||
const required = requiredFuelFor(
|
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.emptyWeightKg,
|
||||||
entry.maxFuel,
|
entry.maxFuel,
|
||||||
km,
|
km,
|
||||||
@@ -424,7 +428,7 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
|
|||||||
|
|
||||||
const landed = await getState(page);
|
const landed = await getState(page);
|
||||||
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
|
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,
|
6,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||||
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
|
||||||
|
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() !== 200) return 'down';
|
||||||
|
const body = (await res.json()) as StateBody;
|
||||||
|
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
|
||||||
|
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
|
||||||
|
.toBe('available');
|
||||||
|
|
||||||
|
// Stage a two-leg queue on the starter…
|
||||||
|
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
|
||||||
|
expect(q1.status()).toBe(200);
|
||||||
|
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
|
||||||
|
expect(q2.status()).toBe(200);
|
||||||
|
|
||||||
|
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Wait for the app to come back, capturing the post-restart snapshot.
|
||||||
|
let after: StateBody | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() === 200) after = (await res.json()) as StateBody;
|
||||||
|
return !!after;
|
||||||
|
} catch {
|
||||||
|
return false; // connection refused / 502 while the container starts
|
||||||
|
}
|
||||||
|
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
|
||||||
|
|
||||||
|
// Cleanup: cancel both legs so the starter is left with an empty queue.
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
|
expect(c.status()).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+12
-2
@@ -495,20 +495,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||||||
|
|
||||||
// Fly the STARTER to its first queued destination…
|
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
|
||||||
|
// response snapshot must already show only [b] left (previously the leg stayed queued and the
|
||||||
|
// landing chained a zero-distance self-hop a→a before reaching b).
|
||||||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||||
expect(flyRes.status()).toBe(200);
|
expect(flyRes.status()).toBe(200);
|
||||||
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
|
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||||
|
|
||||||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||||||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
|
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
|
||||||
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||||
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
|
||||||
|
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||||
|
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||||
|
|
||||||
// …then restore speed so the chained leg fast-forwards too.
|
// …then restore speed so the chained leg fast-forwards too.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ export const planes = sqliteTable('planes', {
|
|||||||
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
|
||||||
queue: text('queue').notNull().default('[]'),
|
queue: text('queue').notNull().default('[]'),
|
||||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
||||||
// landed legs; at 0 the plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel.
|
// landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
|
||||||
health: real('health').notNull().default(100),
|
health: real('health').notNull().default(100),
|
||||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
||||||
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
weightKg: 600,
|
weightKg: 600,
|
||||||
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
|
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||||
deadlineSimTime: 0,
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
+9
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format';
|
||||||
|
|
||||||
describe('formatCash', () => {
|
describe('formatCash', () => {
|
||||||
it('formats EUR with thousands separators and two decimals', () => {
|
it('formats EUR with thousands separators and two decimals', () => {
|
||||||
@@ -30,6 +30,14 @@ describe('formatCargo', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatKm', () => {
|
||||||
|
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
|
||||||
|
expect(formatKm(554)).toBe('554km');
|
||||||
|
expect(formatKm(214.6)).toBe('215km');
|
||||||
|
expect(formatKm(0)).toBe('0km');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatSimClock', () => {
|
describe('formatSimClock', () => {
|
||||||
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
||||||
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
|
|||||||
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
|
||||||
|
export function formatKm(km: number): string {
|
||||||
|
return `${Math.round(km)}km`;
|
||||||
|
}
|
||||||
|
|
||||||
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
|
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
|
||||||
export function formatSpecialLabel(special: string): string {
|
export function formatSpecialLabel(special: string): string {
|
||||||
return special.charAt(0).toUpperCase() + special.slice(1);
|
return special.charAt(0).toUpperCase() + special.slice(1);
|
||||||
|
|||||||
+61
-29
@@ -10,7 +10,6 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
import {
|
import {
|
||||||
BROKEN_BURN_MULTIPLIER,
|
|
||||||
FUEL_DISCOUNT_PER_REP,
|
FUEL_DISCOUNT_PER_REP,
|
||||||
FUEL_PRICE_PER_L,
|
FUEL_PRICE_PER_L,
|
||||||
MAX_FUEL_DISCOUNT,
|
MAX_FUEL_DISCOUNT,
|
||||||
@@ -230,36 +229,28 @@ describe('flyPlane', () => {
|
|||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the ×1.5 broken burn for the dispatch auto-top-up when health is 0 (v0.39.0, ISS-069)', async () => {
|
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
||||||
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||||
const fc = await loadAirportsGeoJson();
|
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
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'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: healthyRequired })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // auto-refuel covers the extra burn — no 409
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
|
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
|
||||||
|
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
||||||
|
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
|
||||||
|
|
||||||
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
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: 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) ---
|
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
|
||||||
@@ -340,6 +331,34 @@ describe('flyPlane', () => {
|
|||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||||
|
|
||||||
|
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||||
|
const inst = await freshDb('fly-consume.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||||
|
const inst = await freshDb('fly-skipahead.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||||
|
|
||||||
|
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('flying');
|
||||||
|
expect(plane.destIcao).toBe('EHAM');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||||
@@ -680,6 +699,19 @@ describe('sellPlane', () => {
|
|||||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||||
|
const inst = await freshDb('sell-queue.db');
|
||||||
|
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 queueLeg(inst, 'plane-extra', 'LEMD');
|
||||||
|
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects selling the last remaining plane with 409', async () => {
|
it('rejects selling the last remaining plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||||||
@@ -691,8 +723,8 @@ describe('sellPlane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('repairPlane (v0.39.0, ISS-069)', () => {
|
describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
|
||||||
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
|
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
|
||||||
const inst = await freshDb('repair-ok.db');
|
const inst = await freshDb('repair-ok.db');
|
||||||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
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 cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
@@ -702,7 +734,7 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
|
|||||||
expect(cost).toBe(REPAIR_COST);
|
expect(cost).toBe(REPAIR_COST);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('repairing');
|
expect(plane.status).toBe('repairing');
|
||||||
expect(plane.health).toBe(100); // restored to full…
|
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
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];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||||
|
|||||||
+26
-9
@@ -9,7 +9,6 @@ import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
|||||||
import { runAction } from '@/lib/sim/engine';
|
import { runAction } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||||
import {
|
import {
|
||||||
conditionBurnMultiplier,
|
|
||||||
FUEL_DISCOUNT_PER_REP,
|
FUEL_DISCOUNT_PER_REP,
|
||||||
FUEL_PRICE_PER_L,
|
FUEL_PRICE_PER_L,
|
||||||
MAX_FUEL_DISCOUNT,
|
MAX_FUEL_DISCOUNT,
|
||||||
@@ -106,7 +105,8 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
|
|||||||
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
|
// 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
|
// 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
|
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
|
||||||
// with nothing mutated.
|
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
||||||
|
// 409s before any refuel math, so fuel and cash stay untouched.
|
||||||
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
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 planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
@@ -114,6 +114,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
if (plane.status !== 'available') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
||||||
}
|
}
|
||||||
|
if (plane.health <= 0) {
|
||||||
|
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
|
||||||
|
}
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
||||||
@@ -125,9 +128,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
||||||
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||||||
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
|
|
||||||
const w = weatherFor(plane.locationIcao);
|
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 burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
|
||||||
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||||
|
|
||||||
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
||||||
@@ -151,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!sim) throw new Error('sim_state row missing');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
|
||||||
|
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
|
||||||
|
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
|
||||||
|
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
|
||||||
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
|
||||||
|
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({
|
.set({
|
||||||
status: 'flying',
|
status: 'flying',
|
||||||
@@ -159,6 +167,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
lat: origin.lat,
|
lat: origin.lat,
|
||||||
lon: origin.lon,
|
lon: origin.lon,
|
||||||
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||||||
|
queue: JSON.stringify(queue),
|
||||||
})
|
})
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
@@ -338,6 +347,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
|
||||||
|
if (parseIcaoQueue(plane.queue).length > 0) {
|
||||||
|
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||||
|
}
|
||||||
|
|
||||||
const all = await inst.db.select().from(schema.planes);
|
const all = await inst.db.select().from(schema.planes);
|
||||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||||
|
|
||||||
@@ -355,10 +369,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
|
|
||||||
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
// --- 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'
|
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
|
||||||
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
|
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
|
||||||
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
|
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
|
||||||
// repair is a free no-op. 404 unknown plane; 409 not grounded / insufficient funds.
|
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||||||
|
// planes can be repaired (409 otherwise); an already-full-health 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 }> {
|
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 rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
@@ -377,8 +393,9 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
|
|||||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, planeId));
|
.where(eq(schema.planes.id, planeId));
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
import { STARTING_REPUTATION } from './seed';
|
import { STARTING_REPUTATION } from './seed';
|
||||||
|
|
||||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||||
@@ -43,21 +43,3 @@ describe('repRate', () => {
|
|||||||
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
|
|
||||||
describe('conditionBurnMultiplier', () => {
|
|
||||||
it('is exactly 1 for any positive health', () => {
|
|
||||||
expect(conditionBurnMultiplier(100)).toBe(1);
|
|
||||||
expect(conditionBurnMultiplier(42)).toBe(1);
|
|
||||||
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
|
|
||||||
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
|
|
||||||
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pins the broken multiplier at ×1.5', () => {
|
|
||||||
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -128,15 +128,6 @@ export const MAX_FUEL_DISCOUNT = 0.2;
|
|||||||
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
|
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
|
||||||
export const WEAR_PER_KM = 0.0005;
|
export const WEAR_PER_KM = 0.0005;
|
||||||
|
|
||||||
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
|
|
||||||
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 dispatch top-up + backstop, engine landed fuel + auto-chain, live display).
|
|
||||||
export function conditionBurnMultiplier(health: number): number {
|
|
||||||
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
||||||
export const REPAIR_COST = 1_500;
|
export const REPAIR_COST = 1_500;
|
||||||
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe('buildSeed', () => {
|
|||||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
|
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
@@ -134,6 +134,9 @@ describe('buildSeed', () => {
|
|||||||
it('rewardFor applies the documented formula', () => {
|
it('rewardFor applies the documented formula', () => {
|
||||||
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
||||||
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
|
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
|
||||||
|
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
|
||||||
|
expect(rewardFor(1000, 500)).toBe(4_000);
|
||||||
|
expect(rewardFor(1000, 4_000)).toBe(11_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -90,9 +90,12 @@ export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
|||||||
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
||||||
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
||||||
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
||||||
|
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
|
||||||
|
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
|
||||||
|
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
|
||||||
export const BASE_REWARD = 500;
|
export const BASE_REWARD = 500;
|
||||||
export const REWARD_PER_KM = 2.5;
|
export const REWARD_PER_KM = 2.5;
|
||||||
export const REWARD_PER_KG = 0.5;
|
export const REWARD_PER_KG = 2.0;
|
||||||
|
|
||||||
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||||||
// higher reputation, exactly ×1 at the starting value.
|
// higher reputation, exactly ×1 at the starting value.
|
||||||
|
|||||||
+13
-5
@@ -3,7 +3,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
|||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||||
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||||
|
|
||||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||||
@@ -30,7 +30,8 @@ export interface PlaneState {
|
|||||||
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||||
queue: string[];
|
queue: string[];
|
||||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
||||||
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
|
// plane is broken and cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is
|
||||||
|
// also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time.
|
||||||
health: number;
|
health: number;
|
||||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||||
repairUntilSimTime: number | null;
|
repairUntilSimTime: number | null;
|
||||||
@@ -61,6 +62,8 @@ export interface PackageState {
|
|||||||
weightKg: number;
|
weightKg: number;
|
||||||
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||||
special: string | null;
|
special: string | null;
|
||||||
|
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
|
||||||
|
distanceKm: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||||
@@ -99,6 +102,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
||||||
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
|
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
|
||||||
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||||
|
// v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing.
|
||||||
|
const legDistanceKm = (originIcao: string, destIcao: string) => {
|
||||||
|
const from = coordsByIcao.get(originIcao);
|
||||||
|
const to = coordsByIcao.get(destIcao);
|
||||||
|
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||||
// departure values — live position and fuel are pure functions of the clock.
|
// departure values — live position and fuel are pure functions of the clock.
|
||||||
@@ -113,12 +122,10 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||||
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
||||||
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
|
|
||||||
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
|
|
||||||
const w = weatherFor(p.locationIcao);
|
const w = weatherFor(p.locationIcao);
|
||||||
return { ...pos, fuel: fuelAtWeighted(
|
return { ...pos, fuel: fuelAtWeighted(
|
||||||
p.fuel,
|
p.fuel,
|
||||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
type.emptyWeightKg,
|
type.emptyWeightKg,
|
||||||
type.maxFuel,
|
type.maxFuel,
|
||||||
distanceKm,
|
distanceKm,
|
||||||
@@ -163,6 +170,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
cargoCount: p.cargoCount,
|
cargoCount: p.cargoCount,
|
||||||
weightKg: p.weightKg,
|
weightKg: p.weightKg,
|
||||||
special: p.special,
|
special: p.special,
|
||||||
|
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
|
||||||
deadlineSimTime: p.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'deadlineSimTime',
|
'deadlineSimTime',
|
||||||
'destIcao',
|
'destIcao',
|
||||||
'destName',
|
'destName',
|
||||||
|
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||||||
'id',
|
'id',
|
||||||
'originIcao',
|
'originIcao',
|
||||||
'originName',
|
'originName',
|
||||||
|
|||||||
+7
-5
@@ -223,7 +223,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description:
|
description:
|
||||||
'Plane not grounded, or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
|
'Plane not grounded, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,7 +233,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Queue a flight leg for a plane',
|
summary: 'Queue a flight leg for a plane',
|
||||||
description:
|
description:
|
||||||
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up grounds the plane with the queue kept intact for a later dispatch.',
|
'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later dispatch.',
|
||||||
operationId: 'queueLeg',
|
operationId: 'queueLeg',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -370,14 +370,14 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Repair a grounded plane',
|
summary: 'Repair a grounded plane',
|
||||||
description:
|
description:
|
||||||
'Restore a worn-out plane to full health for the flat repair cost (v0.39.0, ISS-069): the plane is grounded in "repairing" status until 3 sim days pass, when the engine\'s tick flips it back to "available". While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) still flies but burns 50% more fuel. A full-health repair is a free no-op (cost 0). No body.',
|
'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
|
||||||
operationId: 'repairPlane',
|
operationId: 'repairPlane',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Repair started (or free no-op) — full state snapshot (health 100, status "repairing" with the gate set, cash reduced by the flat cost)',
|
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
@@ -594,7 +594,7 @@ export const openApiSpec = {
|
|||||||
health: {
|
health: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
description:
|
description:
|
||||||
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and burns 50% more fuel until repaired.',
|
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
|
||||||
},
|
},
|
||||||
repairUntilSimTime: {
|
repairUntilSimTime: {
|
||||||
type: ['integer', 'null'],
|
type: ['integer', 'null'],
|
||||||
@@ -633,6 +633,7 @@ export const openApiSpec = {
|
|||||||
cargoCount: { type: 'integer' },
|
cargoCount: { type: 'integer' },
|
||||||
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
||||||
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
|
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
|
||||||
|
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
|
||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
@@ -651,6 +652,7 @@ export const openApiSpec = {
|
|||||||
'cargoType',
|
'cargoType',
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
|
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||||||
'deadlineSimTime',
|
'deadlineSimTime',
|
||||||
'status',
|
'status',
|
||||||
'planeId',
|
'planeId',
|
||||||
|
|||||||
+94
-23
@@ -8,7 +8,7 @@ import * as schema from '@/lib/db/schema';
|
|||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
import { SPECIALS } from '@/lib/game/specials';
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
@@ -313,17 +313,16 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), post-wear health,
|
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
|
||||||
// wind-adjusted requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
|
// requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
|
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
|
||||||
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
|
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
|
||||||
const newHealth = Math.max(0, 100 - WEAR_PER_KM * km);
|
|
||||||
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
|
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
|
||||||
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
|
||||||
const nextRequiredL = requiredFuelFor(
|
const nextRequiredL = requiredFuelFor(
|
||||||
type.burnPerKm * fNext * conditionBurnMultiplier(newHealth),
|
type.burnPerKm * fNext,
|
||||||
type.emptyWeightKg,
|
type.emptyWeightKg,
|
||||||
type.maxFuel,
|
type.maxFuel,
|
||||||
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
|
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
|
||||||
@@ -379,9 +378,55 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||||
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
|
||||||
|
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
|
||||||
|
|
||||||
|
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
|
||||||
|
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
|
||||||
|
const wTo = weatherFor(TO);
|
||||||
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
|
||||||
|
const nextRequiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * fNext,
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
|
||||||
|
);
|
||||||
|
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['FGSL']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
|
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
|
||||||
|
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
|
||||||
|
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
|
||||||
|
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
|
||||||
|
expect(plane.destIcao).toBeNull();
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||||
const FROM = 'EDDF';
|
const FROM = 'EDDF';
|
||||||
const TO = 'LEMD';
|
const TO = 'LEMD';
|
||||||
|
|
||||||
@@ -433,27 +478,22 @@ describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
|
|||||||
expect(plane.health).toBe(0); // still clamped after the second leg
|
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 () => {
|
it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
|
||||||
const { inst, from, to, km, type } = await setupFlight('wear-broken.db');
|
const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
|
||||||
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
// Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
|
||||||
|
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
|
||||||
|
// guard can explain why it doesn't chain.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
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];
|
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.health).toBe(0); // broken by this landing's wear…
|
||||||
expect(plane.fuel).toBeCloseTo(
|
expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
|
||||||
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1),
|
expect(plane.destIcao).toBeNull();
|
||||||
6,
|
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
|
||||||
);
|
|
||||||
|
|
||||||
// …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
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -487,6 +527,37 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
|||||||
expect(plane.status).toBe('available');
|
expect(plane.status).toBe('available');
|
||||||
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
|
||||||
|
const inst = await freshDb('repair-curve.db');
|
||||||
|
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
|
||||||
|
const gate = T0 + REPAIR_DURATION_MS;
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
|
||||||
|
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
expect(plane.health).toBe(50);
|
||||||
|
|
||||||
|
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
|
||||||
|
const inst2 = await freshDb('repair-curve-floor.db');
|
||||||
|
await inst2.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
|
||||||
|
const plane2 = (await inst2.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
|
||||||
|
|
||||||
|
// At the gate: exactly 100 and back in service.
|
||||||
|
expect(await finishRepairs(inst, gate)).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.health).toBe(100);
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||||
|
|||||||
+42
-18
@@ -5,7 +5,7 @@ import * as schema from '@/lib/db/schema';
|
|||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
import { SPECIALS } from '@/lib/game/specials';
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { parseIcaoQueue } from '@/lib/game/state';
|
import { parseIcaoQueue } from '@/lib/game/state';
|
||||||
import {
|
import {
|
||||||
@@ -130,18 +130,32 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
|
// Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
|
||||||
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
|
// round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
|
||||||
// `<=` inclusive at the clock. Returns the count flipped.
|
// derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
|
||||||
|
// back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as
|
||||||
|
// expirePackages: one guarded pass over the rows. Returns the count flipped.
|
||||||
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
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'));
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const p of rows) {
|
for (const p of rows) {
|
||||||
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
|
if (p.repairUntilSimTime === null) continue; // defensive — repairing always carries a gate
|
||||||
|
// ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a
|
||||||
|
// plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the
|
||||||
|
// start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive,
|
||||||
|
// as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane
|
||||||
|
// is still 'repairing' and grounded until the gate passes.
|
||||||
|
const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS));
|
||||||
|
if (p.repairUntilSimTime <= clockMs) {
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'available', repairUntilSimTime: null })
|
.set({ status: 'available', health: 100, repairUntilSimTime: null })
|
||||||
.where(eq(schema.planes.id, p.id));
|
.where(eq(schema.planes.id, p.id));
|
||||||
n++;
|
n++;
|
||||||
|
} else {
|
||||||
|
const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100)));
|
||||||
|
if (newHealth !== p.health) {
|
||||||
|
await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
@@ -152,8 +166,9 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
|
|||||||
// ones still inside their deadline are delivered (cash += reward + reputation
|
// ones still inside their deadline are delivered (cash += reward + reputation
|
||||||
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
// gain per package), 'expired' ones just unbind (no reward). Packages destined
|
||||||
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
|
||||||
// auto-chain: if the queue holds legs and fuel covers the first one, start it
|
// auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
|
||||||
// immediately from the arrival airport; with a short tank, ISS-082 (v0.42.0) tops
|
// broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
|
||||||
|
// airport; with a short tank, ISS-082 (v0.42.0) tops
|
||||||
// up to 150% of the requirement (capped at tank capacity) when cash allows and
|
// up to 150% of the requirement (capped at tank capacity) when cash allows and
|
||||||
// chains anyway — only an unaffordable top-up grounds the plane there with its
|
// chains anyway — only an unaffordable top-up grounds the plane there with its
|
||||||
// remaining fuel and the queue kept intact for a later dispatch.
|
// remaining fuel and the queue kept intact for a later dispatch.
|
||||||
@@ -183,12 +198,10 @@ 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 —
|
// 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.
|
// 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 w = weatherFor(p.locationIcao);
|
||||||
const landedFuel = fuelAtWeighted(
|
const landedFuel = fuelAtWeighted(
|
||||||
p.fuel,
|
p.fuel,
|
||||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
type.emptyWeightKg,
|
type.emptyWeightKg,
|
||||||
type.maxFuel,
|
type.maxFuel,
|
||||||
distanceKm,
|
distanceKm,
|
||||||
@@ -225,25 +238,35 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
|
||||||
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the
|
||||||
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
|
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
|
||||||
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way.
|
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0
|
||||||
|
// (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with
|
||||||
|
// the queue intact until repaired, exactly like flyPlane's broken guard.
|
||||||
const queue = parseIcaoQueue(p.queue);
|
const queue = parseIcaoQueue(p.queue);
|
||||||
let chainedTo: string | null = null;
|
let chainedTo: string | null = null;
|
||||||
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0 && newHealth > 0) {
|
||||||
const nextDest = queue[0];
|
const nextDest = queue[0];
|
||||||
|
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
|
||||||
|
// zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
|
||||||
|
// since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
|
||||||
|
if (nextDest === to.icao) {
|
||||||
|
queue.shift();
|
||||||
|
} else {
|
||||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||||
if (next) {
|
if (next) {
|
||||||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069):
|
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
|
||||||
// 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 wNext = weatherFor(to.icao);
|
||||||
const nextRequiredL = requiredFuelFor(
|
const nextRequiredL = requiredFuelFor(
|
||||||
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
|
||||||
type.emptyWeightKg,
|
type.emptyWeightKg,
|
||||||
type.maxFuel,
|
type.maxFuel,
|
||||||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||||
);
|
);
|
||||||
if (nextRequiredL <= landedFuel) {
|
// v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
|
||||||
|
// intact, mirroring flyPlane's 409 without throwing in the engine.
|
||||||
|
if (nextRequiredL > type.maxFuel) {
|
||||||
|
// no chain — the grounded update below keeps the queue for a later dispatch
|
||||||
|
} else if (nextRequiredL <= landedFuel) {
|
||||||
queue.shift();
|
queue.shift();
|
||||||
chainedTo = nextDest;
|
chainedTo = nextDest;
|
||||||
} else {
|
} else {
|
||||||
@@ -263,6 +286,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set(
|
.set(
|
||||||
chainedTo
|
chainedTo
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.44.0",
|
"version": "0.49.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user