Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff432da97e | ||
|
|
37bdf902cc | ||
|
|
b683c4e546 | ||
|
|
82e4c64206 | ||
|
|
db5ad639fb | ||
|
|
7f28666f2e | ||
|
|
5093fba82e | ||
|
|
cde4334946 | ||
|
|
127d8f97f0 | ||
|
|
479c5448f7 | ||
|
|
d2b799e572 | ||
|
|
664906b8ce | ||
|
|
b60006a949 | ||
|
|
fe84d9a0bb |
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v1.12.0 — 2026-09-28 06:59
|
||||
- **Takeoff & landing phases on the marker.** Every leg now has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the destination, on top of the pure cruise time (`lib/sim/movement.ts`): new `BASE_PHASE_MS`, `phaseFactor(weather)` ∈ [1, 2] (monotone over Clear < Cloudy < Rain < Fog — clear stays at 1×, Fog is exactly 2×), and `legPhases(distanceKm, speedKmh, originIcao, destIcao) → {takeoffMs, cruiseMs, landingMs, totalMs}` as the single composition. Takeoff scales on ORIGIN weather, landing on DESTINATION weather — both deterministic via `weatherFor(icao)`, so no stored state. The engine's arrival gate (`lib/sim/engine.ts`), the client's live position/fuel recompute (`lib/game/state.ts`) and the job expiry floor (`lib/game/seed.ts`) all route through it; `progressAt` is now piecewise — 0 while holding at the origin during takeoff, linear over cruise, 1 while holding at the destination during landing — so markers park at each gate instead of crawling along the whole route. The phases burn no fuel (pure time); the weighted-burn model is untouched.
|
||||
- **Marker phase label.** `lib/mapData.ts` derives a phase label client-side from `departureSimTime + clockMs + legPhases` (all already in the snapshot) and the plane popup shows it next to the status — "flying · taking off" / "flying · cruising" / "flying · landing" (`components/Map.tsx`, new `data-testid="plane-phase"` sibling; `plane-status` keeps the raw status so existing assertions stay exact).
|
||||
- **Tests.** Unit: `lib/sim/movement.test.ts` — pinned cruise duration unchanged, new suites for `phaseFactor` bounds/monotonicity (with `MAX_PHASE_FACTOR` pinned to the factor function like `MAX_WIND_BURN_FACTOR`), origin-vs-dest weather composition on a non-vacuous Rain/Cloudy pair, and piecewise `progressAt` holds; `lib/sim/engine.test.ts` — deleted the local `travelMs` mirror, all ~18 "exactly at arrival" fixture clocks now use the real `arrivalTimeFor(...)` composition (the wear-clamp second leg chains off leg 1's exact phase-inclusive arrival); `lib/game/seed.test.ts` expiry-floor mirrors updated to the phase-inclusive floor. E2E: new `e2e/phases.spec.ts` — dispatches the starter on EDDF→LEMD and, using the same `legPhases` boundaries as the engine, asserts the popup shows "taking off" while the plane holds at the origin gate, "landing" while it holds at the destination gate (scale dropped to 30× so each 600–1200 game-s window is observable), then "available" at Madrid; relocates home and restores the world rate.
|
||||
|
||||
## v1.11.0 — 2026-09-27 22:55
|
||||
- **Do not show jobs for planes that are in air (ISS-159).** The right pane's open-package list now hides a row while no GROUNDED plane sits at the package's origin (`components/RightPane.tsx`): new exported `visibleOpenPackages(state)` replaces the bare `status === 'open'` filter — a job whose only claimant is still en route disappears from the list and reappears on landing. Payload-blocked rows (a grounded plane at the origin with no room) stay visible with their disabled Load button, exactly as before. Display-only: `poolAirports` and the spawner are untouched, so world state — jobs waiting at a flying plane's destination — is unchanged; this only stops rendering rows the player cannot dispatch yet. Known pre-existing gap (NOT fixed here): `loadEligiblePlanes` ignores plane ownership per the v1.4.0 shared-market design, so another player's grounded plane at an origin can make Load enabled for you → 403 on click.
|
||||
- **Tests.** Unit: new `visibleOpenPackages` suite in `components/right-pane.test.ts` — grounded plane at origin → visible; only a flying plane there → hidden; grounded elsewhere + one flying to the origin → hidden; payload-blocked grounded plane at origin → still visible with Load disabled. E2E: new test in `e2e/right-pane.spec.ts` dispatches the only plane toward an airport, creates a job AT that destination (in the pool while airborne, so it reaches `/api/state`), and asserts no row renders until landing — pinning the client-side filter rather than the v0.65.0 server-side pool visibility.
|
||||
|
||||
## v1.10.0 — 2026-09-27 22:31
|
||||
- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn).
|
||||
- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button.
|
||||
- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion).
|
||||
|
||||
## v1.9.0 — 2026-09-27 20:57
|
||||
- **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is.
|
||||
|
||||
## v1.8.0 — 2026-09-27 20:13
|
||||
- **E2E speedup (test-only, no game code changed).** Every Playwright spec that fast-forwards legs now uses the fastest rate its assertions allow instead of a blanket `SCALE = 1000`: pure arrival waits (no mid-leg observation) run at `FAST` — a lagged tick can only land a plane sooner, never skip an observed state. Expected CI wall time for the affected tests drops ~5–6× overall (e.g. the antimeridian test's relocation chains go from ~3 min to ~15 s per direction; two-planes' parallel-flight test loses its ~24 s afterEach heal leg).
|
||||
- **Per-test rates.** `actions.spec.ts` + `hover.spec.ts` + `two-planes.spec.ts`: new local `FAST = 10_000` for expiry fast-forwards, landing waits and fly-home cleanups. `queue.spec.ts`: same `FAST`, plus `CHAIN = 4_000` for the auto-chain observation (max per-tick advance 2 s × S stays below the LEMD-length leg's ~1e7 sim-ms duration so one lagged tick can't complete it before a poll sees the chained state); its refuel-before-chained-leg test moves to an explicit `2_000` with the fuel lower bound relaxed from `> 1.3×required` to `> required` (at 2_000× the snapshot can land ~0.4×required into leg 2's burn under dind load; a wrong top-up level still lands far below the bound, and the exact level stays pinned by the unchanged cash assertion). `map-interactions.spec.ts`: `FAST = 50_000` for chain hops (≤3400 km each) and arrival waits.
|
||||
- **Kept at SCALE = 1000 where mid-leg observation is real.** The antimeridian crossing leg itself (freshness gate ~700 km ≈ 5 s of flight + >3 fresh samples assume ~14 s of real-time flight), the queue spec's final-hop chain window (EHAM→EDDF is only 2_587 sim-s — at higher rates a `/api/state` 500-retry gap could advance past it), and map-interactions' mid-flight click window. The antimeridian crossing dispatch now sets SCALE BEFORE the fly POST: with FAST-rate chains, a leftover fast rate would let the dispatch tick itself consume most of the leg before the setScale lands (flake path).
|
||||
- **hover.spec.ts fleet-row test restructured.** It no longer speeds up to 1000× and then freezes mid-leg; it asserts `flying` from the fly endpoint's OWN post-action snapshot (no poll race) and freezes at scale 1 immediately — same pattern as its package-hover sibling, so the ~10 s leg can't land under the assertions.
|
||||
- **two-planes parallel-flight test.** Landing waits move to FAST, and a new both-grounded poll after the cleanup dispatches lets the range-edge return legs finish while the clock is still at FAST — previously afterEach's `healPlaneHome` paid that ~24 s leg back at SCALE.
|
||||
|
||||
## v1.7.0 — 2026-09-26 20:24
|
||||
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
|
||||
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
|
||||
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
|
||||
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
|
||||
|
||||
## v1.6.0 — 2026-09-26 19:35
|
||||
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
|
||||
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
|
||||
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
|
||||
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
|
||||
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
|
||||
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
|
||||
// same gate, same conventions.
|
||||
//
|
||||
// POST /api/test/delete-plane
|
||||
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
|
||||
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
|
||||
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
|
||||
|
||||
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 !== 'username') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const username = input.username;
|
||||
if (typeof username !== 'string' || username.length === 0) {
|
||||
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
|
||||
let deleted = 0;
|
||||
if (players[0]) {
|
||||
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
|
||||
for (const plane of owned) {
|
||||
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
|
||||
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
|
||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await persist(inst);
|
||||
}
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -19,6 +19,11 @@ export default function Home() {
|
||||
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
||||
// feed never polls an unauthenticated /api/state.
|
||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||
// v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map.
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
// Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must
|
||||
// track whoever just signed in, not the user probed on mount (or before the last logout).
|
||||
const [probeKey, setProbeKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = storedSession();
|
||||
@@ -28,9 +33,10 @@ export default function Home() {
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
||||
else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158)
|
||||
setAuthed(res.ok);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -39,7 +45,7 @@ export default function Home() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [probeKey]);
|
||||
|
||||
if (authed === null) {
|
||||
return (
|
||||
@@ -51,14 +57,16 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
if (!authed) {
|
||||
return <SignInScreen onAuthed={() => setAuthed(true)} />;
|
||||
// v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed —
|
||||
// isAdmin has to come from the user who just signed in.
|
||||
return <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
||||
}
|
||||
|
||||
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||
return <AppShell onLogout={() => setAuthed(false)} />;
|
||||
return <AppShell onLogout={() => setAuthed(false)} isAdmin={isAdmin} />;
|
||||
}
|
||||
|
||||
function AppShell({ onLogout }: { onLogout: () => void }) {
|
||||
function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) {
|
||||
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
||||
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||
@@ -95,7 +103,7 @@ function AppShell({ onLogout }: { onLogout: () => void }) {
|
||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||
action response into the shared state, exactly like RightPane does */}
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||
</div>
|
||||
|
||||
+24
-4
@@ -14,7 +14,7 @@ import { approxLocalTime } from '../lib/localTime';
|
||||
import Modal from './Modal';
|
||||
import AdminPanel from './AdminPanel';
|
||||
import Leaderboard from './Leaderboard';
|
||||
import { sessionHeaders } from '../lib/authClient';
|
||||
import { clearStoredSession, sessionHeaders } from '../lib/authClient';
|
||||
|
||||
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
||||
// Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator,
|
||||
@@ -74,11 +74,13 @@ interface MapProps {
|
||||
// v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back
|
||||
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
||||
onLogout?: () => void;
|
||||
// v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button.
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// Named FlightMap (not Map): a component called `Map` would shadow the global
|
||||
// Map constructor inside its own body, breaking `new Map(...)` for the airport index.
|
||||
export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
|
||||
export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
// /api/airports is fetched at most once per page life: every styledata event
|
||||
@@ -324,12 +326,15 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
if (!f) return;
|
||||
const p = f.properties as PlaneFeatureProps;
|
||||
const routeLine = p.routeText ? `<br/><span data-testid="plane-route">${escapeHtml(p.routeText)}</span>` : '';
|
||||
// v1.12.0: phase label next to the status — "flying · taking off" etc. plane-status keeps
|
||||
// the raw status so existing assertions on it stay exact.
|
||||
const phaseLine = p.phaseLabel ? `<span data-testid="plane-phase"> · ${p.phaseLabel}</span>` : '';
|
||||
new maplibregl.Popup({ offset: 12 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setHTML(
|
||||
`<div data-testid="plane-popup">` +
|
||||
`<strong>${escapeHtml(p.modelLabel)}</strong><br/>` +
|
||||
`<span data-testid="plane-status">${p.status}</span><br/>` +
|
||||
`<span data-testid="plane-status">${p.status}</span>${phaseLine}<br/>` +
|
||||
`fuel <span data-testid="plane-fuel">${p.fuelText}</span>` +
|
||||
routeLine +
|
||||
`</div>`,
|
||||
@@ -569,6 +574,14 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
||||
};
|
||||
|
||||
// v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort
|
||||
// server-side session kill, drop the stored id, tell the shell to show sign-in again.
|
||||
const onTopBarLogout = async () => {
|
||||
await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined);
|
||||
clearStoredSession();
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="map-wrap">
|
||||
<header data-testid="app-header" className="app-header">
|
||||
@@ -596,10 +609,17 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||
Leaderboard
|
||||
</button>
|
||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
|
||||
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
|
||||
{isAdmin && (
|
||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
|
||||
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
|
||||
Log out
|
||||
</button>
|
||||
</header>
|
||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||
|
||||
@@ -46,6 +46,15 @@ export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): Pla
|
||||
});
|
||||
}
|
||||
|
||||
// v1.11.0 (ISS-159): the open packages the market list shows — hidden while no GROUNDED plane sits at
|
||||
// their origin ("jobs for planes that are in air" disappear until landing). Payload-blocked rows stay:
|
||||
// a grounded plane at the origin keeps the row visible with its disabled Load button.
|
||||
export function visibleOpenPackages(state: StateSnapshot): PackageState[] {
|
||||
return state.packages.filter(
|
||||
(p) => p.status === 'open' && state.planes.some((pl) => pl.status === 'available' && pl.locationIcao === p.originIcao),
|
||||
);
|
||||
}
|
||||
|
||||
// Load controls for one open package: a single Load button when exactly one plane
|
||||
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||
@@ -141,7 +150,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
if (!state) return null;
|
||||
|
||||
const clockMs = state.simState.clockMs;
|
||||
const open = state.packages.filter((p) => p.status === 'open');
|
||||
const open = visibleOpenPackages(state);
|
||||
|
||||
const catalog = state.catalog;
|
||||
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cargoWeightFor, loadEligiblePlanes } from './RightPane';
|
||||
import { cargoWeightFor, loadEligiblePlanes, visibleOpenPackages } from './RightPane';
|
||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
|
||||
// v0.12.0 (weight model v0.27.0) — the package-model picker rules: a plane can LOAD a
|
||||
@@ -134,3 +134,39 @@ describe('loadEligiblePlanes (Load button eligibility)', () => {
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleOpenPackages (market list row filter, ISS-159)', () => {
|
||||
it('shows an open package when a grounded plane sits at its origin', () => {
|
||||
const s = snap(
|
||||
[plane({ id: 'at-origin' })],
|
||||
[pkg({ id: 'x' }), pkg({ id: 'onboard', status: 'loaded', planeId: 'at-origin' })], // non-open statuses never listed
|
||||
);
|
||||
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('hides an open package when only a flying plane is at its origin (in air, not grounded)', () => {
|
||||
const s = snap([plane({ id: 'en-route', status: 'flying', destIcao: 'LEMD' })], [pkg({ id: 'x' })]);
|
||||
expect(visibleOpenPackages(s)).toEqual([]);
|
||||
});
|
||||
|
||||
it('hides an open package when the grounded plane is elsewhere and another flies to its origin', () => {
|
||||
const s = snap(
|
||||
[
|
||||
plane({ id: 'grounded-elsewhere', locationIcao: 'LEMD' }),
|
||||
plane({ id: 'en-route', status: 'flying', destIcao: 'EDDF' }), // flying TO the package's origin — not grounded there yet
|
||||
],
|
||||
[pkg({ id: 'x' })],
|
||||
);
|
||||
expect(visibleOpenPackages(s)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a payload-blocked row visible when a grounded plane at the origin has no room (disabled Load path preserved)', () => {
|
||||
const packages = [
|
||||
...Array.from({ length: 4 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full', weightKg: 1_200 })), // 4 × 1200 = 4800 — full
|
||||
pkg({ id: 'x' }),
|
||||
];
|
||||
const s = snap([plane({ id: 'full' })], packages);
|
||||
expect(visibleOpenPackages(s).map((p) => p.id)).toEqual(['x']); // row stays…
|
||||
expect(loadEligiblePlanes(s, s.packages.find((p) => p.id === 'x')!)).toEqual([]); // …with Load disabled
|
||||
});
|
||||
});
|
||||
|
||||
+6
-5
@@ -20,6 +20,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
|
||||
@@ -334,7 +335,7 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
|
||||
});
|
||||
|
||||
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
|
||||
test.setTimeout(480_000); // two ~1 s real-time legs at 10_000× (HUB↔LEMD) plus expiry wait
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||
@@ -350,12 +351,12 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
||||
}
|
||||
|
||||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||||
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||
// window so scale 10_000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||||
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST);
|
||||
let s = await getState(page);
|
||||
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||
s = await getState(page);
|
||||
@@ -387,9 +388,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||
|
||||
// Restore: top up + fly home at SCALE.
|
||||
// Restore: top up + fly home at FAST (pure arrival wait).
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST);
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
|
||||
+5
-2
@@ -9,8 +9,11 @@ import { expect } from '@playwright/test';
|
||||
|
||||
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||
|
||||
export async function mintPlayerSession(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session');
|
||||
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
|
||||
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
|
||||
// usual local player, exactly as before.
|
||||
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
|
||||
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||
}
|
||||
|
||||
+23
-17
@@ -17,6 +17,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
||||
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||
@@ -516,7 +517,7 @@ test('hovering the Relocate section previews the planned route and target airpor
|
||||
});
|
||||
|
||||
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
@@ -525,23 +526,26 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
|
||||
// post-action snapshot already shows the plane flying — no poll race.
|
||||
const pkg = await createPackage(page, HUB, DEST);
|
||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(load.status()).toBe(200);
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(fly.status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
const flyBody = (await fly.json()) as StateBody;
|
||||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||
|
||||
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
|
||||
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
|
||||
// test below).
|
||||
await setScale(page, 1);
|
||||
|
||||
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||
const block = page.getByTestId('plane-plane-starter');
|
||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||
|
||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||||
// e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
|
||||
// the route line stays drawn while frozen.
|
||||
await setScale(page, 1);
|
||||
|
||||
await block.hover();
|
||||
await expect.poll(async () => {
|
||||
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||||
@@ -554,9 +558,10 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
message: 'waiting for the highlight to clear after mouseleave',
|
||||
}).toBe(0);
|
||||
|
||||
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
||||
await setScale(page, SCALE);
|
||||
// Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
|
||||
// stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
|
||||
// arrivals → FAST (~1 s each instead of ~10 s).
|
||||
await setScale(page, FAST);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
@@ -564,8 +569,8 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||
.toBe(true);
|
||||
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
// Re-set FAST for the return leg.
|
||||
await setScale(page, FAST);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
await expect
|
||||
@@ -577,7 +582,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
||||
});
|
||||
|
||||
test('hovering an on-board package while its carrier is mid-flight traces from the landing airport', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
@@ -651,7 +656,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
||||
// not DEST — it never delivered), then fly home.
|
||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(cancelRes.status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||
await setScale(page, FAST);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
@@ -662,8 +668,8 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
||||
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
// Re-set FAST for the return leg (~1 s instead of ~10 s).
|
||||
await setScale(page, FAST);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
await expect
|
||||
|
||||
@@ -20,6 +20,9 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
// the 6300 L tank, so a full-tank departure always makes it home.
|
||||
const DEST = 'LEMD';
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
// Pure arrival waits and relocation-chain hops (no mid-leg observation): a lagged tick can only finish
|
||||
// the leg sooner, never skip an observed state. Max hop ≤3400 km lands in <1 s at this rate.
|
||||
const FAST = 50_000;
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
|
||||
interface PlaneState {
|
||||
@@ -106,15 +109,15 @@ 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);
|
||||
}
|
||||
|
||||
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
||||
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||
// Load a package onto plane-starter and fly it from → to via the API, then fast-forward at FAST
|
||||
// (pure arrival wait — no mid-leg observation) until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||
const pkg = await createPackage(page, fromIcao, toIcao);
|
||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(load.status()).toBe(200);
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
||||
expect(fly.status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
@@ -204,7 +207,7 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
||||
});
|
||||
|
||||
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup
|
||||
test.setTimeout(600_000); // self-heal from a far airport (~3 s worst case at 50_000×) + main flight + cleanup
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
@@ -215,7 +218,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||
expect(foundStarter).toBeDefined();
|
||||
if (foundStarter!.status === 'flying') {
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
@@ -348,8 +351,8 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||
|
||||
// Restore SCALE before step 5 — the landing wait needs a fast clock.
|
||||
await setScale(page, SCALE);
|
||||
// Fast-forward to arrival (pure wait — no mid-leg observation) at FAST: ~0.2 s instead of ~4 s.
|
||||
await setScale(page, FAST);
|
||||
|
||||
// 5) Let the flight finish, then fly home so the live instance stays in its
|
||||
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||
@@ -478,7 +481,7 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
|
||||
test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin
|
||||
test.setTimeout(900_000); // relocation chain (~15 s per direction at 50_000×) + crossing leg + return chain + margin
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
@@ -489,7 +492,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
||||
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST); // pure arrival wait — no mid-leg observation needed here
|
||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||||
}
|
||||
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
|
||||
@@ -500,7 +503,7 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
||||
for (const next of await chainTo(page, loc, ORIGIN)) {
|
||||
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||
}
|
||||
}
|
||||
@@ -521,9 +524,12 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
||||
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
|
||||
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||||
console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||
// The crossing leg must run at SCALE (1000×): the freshness gate (~700 km ≈ 5 s of flight) and the
|
||||
// >3 fresh samples both assume ~14 s of real-time flight. Set it BEFORE dispatch — a leftover FAST
|
||||
// rate would let the dispatch tick itself consume most of the leg before this setScale lands.
|
||||
await setScale(page, SCALE);
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
|
||||
if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`);
|
||||
await setScale(page, SCALE);
|
||||
console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`);
|
||||
|
||||
// querySourceFeatures only sees LOADED tiles — the v0.70.0 default view ([0,0] z2) doesn't cover this
|
||||
@@ -591,16 +597,16 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)'
|
||||
expect(lons.some((lon) => Math.abs(lon) > 180), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto a world copy, either side of ±180
|
||||
expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(lons)}`).toBeGreaterThan(4); // traversed the crossing region, not a static window
|
||||
} finally {
|
||||
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical
|
||||
// state. The package was delivered on arrival (generous deadline).
|
||||
await setScale(page, SCALE);
|
||||
// Cleanup: let any in-flight leg land (pure arrival wait → FAST), then chain back home so the other
|
||||
// specs start from the canonical state. The package was delivered on arrival (generous deadline).
|
||||
await setScale(page, FAST);
|
||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||||
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||
if (endLoc !== HUB) {
|
||||
for (const next of await chainTo(page, endLoc, HUB)) {
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await setScale(page, FAST); // pure arrival wait per hop — a lagged tick can only land it sooner
|
||||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,17 @@ test('header shows the app version sourced from package.json', async ({ page })
|
||||
);
|
||||
});
|
||||
|
||||
// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button
|
||||
// (mirrors e2e/admin.spec.ts' logout assertion).
|
||||
test('top bar Log out drops back to the sign-in screen', async ({ page }) => {
|
||||
await page.goto(APP_URL);
|
||||
const header = page.getByTestId('app-header');
|
||||
await expect(header).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId('logout-btn').click();
|
||||
await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(header).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||
await page.goto(APP_URL);
|
||||
await waitForAirportCount(page);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||||
|
||||
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||||
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||||
// player; player B is a second account minted through the same hook with a username override (the
|
||||
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||||
//
|
||||
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||||
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||||
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||||
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||||
|
||||
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||||
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
status: string;
|
||||
locationIcao: string;
|
||||
maxFuel: number;
|
||||
queue: string[];
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
interface PlayerSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
cash: number;
|
||||
reputation: number;
|
||||
planeCount: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number };
|
||||
planes: PlaneState[];
|
||||
players: PlayerSummary[];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||
if (q.length === 0) return;
|
||||
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||||
async function nearestToHub(page: Page): Promise<string> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
return fc.features
|
||||
.filter((f) => f.properties.icao !== HUB)
|
||||
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||
.sort((x, y) => x.d - y.d)[0].icao;
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||||
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||||
await applySession(page, sessionId);
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||||
async function healStarterHome(page: Page): Promise<void> {
|
||||
await clearQueueViaApi(page, STARTER);
|
||||
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||||
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||||
if (p.status === 'flying') {
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight starter to land during cleanup',
|
||||
})
|
||||
.toBe('available');
|
||||
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
}
|
||||
if (p.locationIcao !== HUB) {
|
||||
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyHome.status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for the starter to land back at the hub during cleanup',
|
||||
})
|
||||
.toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||||
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||||
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for the fleet to be fully grounded',
|
||||
})
|
||||
.toBe('idle');
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||||
|
||||
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||||
await healStarterHome(page);
|
||||
await setScale(page, WORLD);
|
||||
} finally {
|
||||
if (bCtx) await bCtx.close();
|
||||
bCtx = null;
|
||||
}
|
||||
});
|
||||
|
||||
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||||
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||||
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||||
// Verify through the same surface the game uses — mint a local session just for this read.
|
||||
const sessionId = await mintPlayerSession(page);
|
||||
await applySession(page, sessionId);
|
||||
const s = (await getState(page)) as StateBody;
|
||||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||||
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||||
const b = await mintPlayerB(browser);
|
||||
bCtx = b.ctx;
|
||||
|
||||
await waitForGroundedFleet(page);
|
||||
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||||
|
||||
const dest = await nearestToHub(page);
|
||||
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||||
await setFuelFull(page, STARTER);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||||
|
||||
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||||
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||||
let prev = (await getState(page)).simState.clockMs;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(500);
|
||||
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||||
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||||
const now = s.simState.clockMs;
|
||||
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||||
prev = now;
|
||||
}
|
||||
|
||||
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: "waiting for A's plane to land",
|
||||
})
|
||||
.toBe('available');
|
||||
|
||||
const frozen1 = await getState(page);
|
||||
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||||
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||||
const frozen2 = await getState(page);
|
||||
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||||
});
|
||||
|
||||
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||||
const b = await mintPlayerB(browser);
|
||||
bCtx = b.ctx;
|
||||
|
||||
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||||
await waitForGroundedFleet(page);
|
||||
|
||||
const sA = await getState(page); // A's (local session's) view of the shared world
|
||||
const sB = await getState(b.page); // B's view — same world…
|
||||
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||||
|
||||
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||||
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||||
expect(starter.ownerId).toBe('local');
|
||||
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||||
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||||
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||||
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||||
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||||
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||||
|
||||
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||||
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||||
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyForeign.status()).toBe(403);
|
||||
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||||
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||||
expect(sellForeign.status()).toBe(403);
|
||||
|
||||
// …and B cannot act on A's.
|
||||
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyA.status()).toBe(403);
|
||||
|
||||
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||||
// so nothing else can touch either economy between reads.
|
||||
const cA = sA.account.cash;
|
||||
const cB = sB.account.cash;
|
||||
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||||
expect(grant.status()).toBe(200);
|
||||
const granted = ((await grant.json()) as StateBody).account.cash;
|
||||
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||||
const sBAfter = await getState(b.page);
|
||||
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||||
|
||||
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||||
for (const s of [sA, sBAfter]) {
|
||||
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
import { haversineKm } from '../lib/geo';
|
||||
import { legPhases } from '../lib/sim/movement';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog';
|
||||
|
||||
// v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and
|
||||
// assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate)
|
||||
// and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The
|
||||
// phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork:
|
||||
// each observation happens inside a window computed from departureSimTime + clockMs.
|
||||
|
||||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses
|
||||
const STARTER = 'plane-starter';
|
||||
// Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long
|
||||
// enough for the fit + click + popup pipeline, short enough to keep the test fast.
|
||||
const OBS = 30;
|
||||
// Fast-forward through the cruise between the two observations (pure arrival wait).
|
||||
const MID = 1_000;
|
||||
// Self-heal and final arrival waits — no mid-leg observation at this rate.
|
||||
const FAST = 50_000;
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
|
||||
interface StateBody {
|
||||
simState: { clockMs: number };
|
||||
planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
// ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||
test.afterEach(async ({ page }) => {
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
async function waitForAirportCount(page: Page): Promise<void> {
|
||||
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;
|
||||
}).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left
|
||||
// it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second.
|
||||
async function ensureGroundedAt(page: Page, icao: string): Promise<void> {
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
if (p.status === 'available' && p.locationIcao === icao) return 'ok';
|
||||
if (p.status === 'available') {
|
||||
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } });
|
||||
return `flying home from ${p.locationIcao}`;
|
||||
}
|
||||
return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly)
|
||||
}, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok');
|
||||
}
|
||||
|
||||
// Frame both endpoints so the marker is rendered wherever it holds on the leg.
|
||||
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const points = icaos.map((icao) => {
|
||||
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||
});
|
||||
await page.evaluate((pts: [number, number][]) => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m) throw new Error('map not ready');
|
||||
const lons = pts.map((p) => p[0]);
|
||||
const lats = pts.map((p) => p[1]);
|
||||
// duration 0 → instant camera jump, no animation to wait out.
|
||||
m.fitBounds(
|
||||
[
|
||||
[Math.min(...lons), Math.min(...lats)],
|
||||
[Math.max(...lons), Math.max(...lats)],
|
||||
],
|
||||
{ padding: 40, duration: 0 },
|
||||
);
|
||||
}, points);
|
||||
}
|
||||
|
||||
// Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state
|
||||
// fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames —
|
||||
// used right after dispatch, when the last rendered frame may predate it.
|
||||
async function clickPlane(page: Page, flyingOnly: boolean): Promise<void> {
|
||||
const pt = await page.evaluate(async ({ onlyFlying, starterId }: { onlyFlying: boolean; starterId: string }) => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m) return null;
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
let feats: Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }> = [];
|
||||
try {
|
||||
feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] });
|
||||
} catch {
|
||||
// map not ready yet — keep polling
|
||||
}
|
||||
const f = feats.find((x) => x.properties?.id === starterId && (!onlyFlying || x.properties?.flying === true));
|
||||
if (f && f.geometry) {
|
||||
const projected = m.project(f.geometry.coordinates);
|
||||
const rect = m.getContainer().getBoundingClientRect();
|
||||
return { x: rect.left + projected.x, y: rect.top + projected.y };
|
||||
}
|
||||
if (Date.now() >= deadline) return null;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}, { onlyFlying: flyingOnly, starterId: STARTER });
|
||||
expect(pt, 'plane marker not rendered').not.toBeNull();
|
||||
|
||||
// Popups are created per click and never auto-close — remove any stale ones so the testids below
|
||||
// stay unique.
|
||||
await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove()));
|
||||
await page.mouse.click(pt!.x, pt!.y);
|
||||
const popup = page.getByTestId('plane-popup');
|
||||
await expect(popup).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
// Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left.
|
||||
await setScale(page, FAST);
|
||||
await ensureGroundedAt(page, HUB);
|
||||
|
||||
// The exact phase boundaries for this leg — same composition the engine uses (v1.12.0).
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((x) => x.properties.icao === HUB)!;
|
||||
const dest = fc.features.find((x) => x.properties.icao === DEST)!;
|
||||
const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon);
|
||||
const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST);
|
||||
|
||||
// --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. ---
|
||||
await setScale(page, OBS);
|
||||
const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } });
|
||||
expect(fly.status()).toBe(200);
|
||||
const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!;
|
||||
|
||||
await fitToAirports(page, [HUB, DEST]);
|
||||
await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch
|
||||
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||
await expect(page.getByTestId('plane-phase')).toContainText('taking off');
|
||||
|
||||
// --- Landing: fast-forward through the cruise to just before the landing window, then drop back
|
||||
// to OBS so the hold at the destination gate is observable. ---
|
||||
const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs;
|
||||
await setScale(page, MID); // fast-forward through takeoff hold + cruise (pure arrival wait)
|
||||
await expect.poll(async () => {
|
||||
const body = await getState(page);
|
||||
const p = body.planes.find((x) => x.id === STARTER)!;
|
||||
if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
|
||||
return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins
|
||||
}, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true);
|
||||
|
||||
await setScale(page, OBS);
|
||||
// Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and
|
||||
// before arrival (landingMs ≥ BASE_PHASE_MS > the offset below).
|
||||
const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000);
|
||||
await expect.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
|
||||
return body.simState.clockMs >= landingTarget;
|
||||
}, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true);
|
||||
|
||||
await clickPlane(page, true);
|
||||
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||
await expect(page.getByTestId('plane-phase')).toContainText('landing');
|
||||
|
||||
// --- Arrival: fast-forward until the engine flips it to available at DEST. ---
|
||||
await setScale(page, FAST);
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
return p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true);
|
||||
|
||||
// Leave the onboarding state for the other specs: starter grounded back at HUB.
|
||||
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
return p.status === 'available' && p.locationIcao === HUB;
|
||||
}, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true);
|
||||
});
|
||||
+26
-15
@@ -20,6 +20,12 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
// Pure arrival waits (no mid-leg observation): LEMD-length legs land in ~1 s. Safe at any scale —
|
||||
// a lagged tick can only finish the leg sooner, never skip an observed state.
|
||||
const FAST = 10_000;
|
||||
// Chained-leg observation: max per-tick advance (2 s × S) must stay below the LEMD-length leg's
|
||||
// sim-duration (~1e7 sim-ms) so one lagged tick can't complete it before a poll sees the chain.
|
||||
const CHAIN = 4_000;
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||
@@ -285,14 +291,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||||
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||
await setScale(page, SCALE);
|
||||
// Both waits are pure arrivals — FAST is safe and ~10× quicker than SCALE here.
|
||||
await setScale(page, FAST);
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
// Re-set FAST for the return leg.
|
||||
await setScale(page, FAST);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||
@@ -366,14 +373,15 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
||||
// to arrival, then fly home.
|
||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(cancelRes.status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
// Pure arrival waits — FAST is safe and ~10× quicker than SCALE here.
|
||||
await setScale(page, FAST);
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
// Re-set SCALE for the return leg.
|
||||
await setScale(page, SCALE);
|
||||
// Re-set FAST for the return leg.
|
||||
await setScale(page, FAST);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||||
@@ -457,9 +465,9 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||||
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
||||
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||
await setScale(page, SCALE);
|
||||
// return leg — no manual dispatch. The chained state persists for the whole ~2.5 s return leg at
|
||||
// CHAIN, so a poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||||
await setScale(page, CHAIN);
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||||
@@ -566,8 +574,10 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
|
||||
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);
|
||||
// Landing at DEST auto-chains QUEUE with the top-up… 2_000× (not SCALE): leg 2's chained state must
|
||||
// stay observable — max per-tick advance (2 s × S) stays below its ~1e7 sim-ms duration, and the
|
||||
// snapshot window below is sized for this rate.
|
||||
await setScale(page, 2_000);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||
@@ -577,9 +587,9 @@ test('a drained tank is topped up before a chained leg at exactly the rep-discou
|
||||
// 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…
|
||||
// 1.3× not 1.4×: under dind load the snapshot can land ~0.1×required2 into leg 2's departure burn;
|
||||
// a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
||||
expect(fuel).toBeGreaterThan(1.3 * required2);
|
||||
// > required2 (not 1.3×): at 2_000× the snapshot can land ~0.4×required2 into leg 2's burn under dind
|
||||
// load; a wrong top-up level (≤ required2, or none at all) still lands far below this bound.
|
||||
expect(fuel).toBeGreaterThan(required2);
|
||||
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.
|
||||
@@ -603,7 +613,8 @@ test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)
|
||||
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(cancel.status()).toBe(200);
|
||||
|
||||
await setScale(page, SCALE);
|
||||
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||||
await setScale(page, FAST);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||
|
||||
@@ -14,7 +14,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
// 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; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
||||
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — cleanup legs land in ~1 s
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. The v0.70.0 default view ([0,0] z2)
|
||||
@@ -93,8 +93,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
let s = await getState(page);
|
||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
// Fast-forward any leftover leg / repositioning flight (~2 s real time worst case at FAST).
|
||||
await setScale(page, FAST);
|
||||
}
|
||||
if (plane.status === 'flying') {
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing
|
||||
@@ -104,8 +104,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
// Make sure the world rate is back at FAST so the return leg fast-forwards.
|
||||
await setScale(page, FAST);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
@@ -122,7 +122,7 @@ test.afterEach(async ({ page }) => {
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
||||
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
@@ -154,7 +154,7 @@ test('left pane Relocate section relocates a grounded plane', async ({ page }) =
|
||||
});
|
||||
|
||||
test('airport popup offers Relocate options for grounded planes', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS
|
||||
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one leg to LFRS
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
@@ -221,7 +221,7 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
||||
});
|
||||
|
||||
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
|
||||
test.setTimeout(300_000); // self-heal (worst case ~2 s at FAST) + one short leg, fast-forwarded in cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
|
||||
+41
-25
@@ -154,39 +154,35 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
||||
return (await created.json()) as PackageState;
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. The end
|
||||
// state is verified by ONE poll on FRESH snapshots (v1.12.0): legs now include takeoff/landing holds,
|
||||
// so a leftover leg can land at its destination after an earlier snapshot was taken — deciding from a
|
||||
// stale pre-flight snapshot let tests load cargo onto a plane that wasn't actually at the hub (409).
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
// ISS-127-style transient: test hooks intermittently 500 under dind load — retry briefly instead of failing.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const healthRes = await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } });
|
||||
if (healthRes.status() === 200) break;
|
||||
if (attempt >= 3) throw new Error(`/api/test/set-health returned ${healthRes.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
|
||||
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||
// Fast-forward any leftover leg / repositioning flight (a few seconds real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||
if (p.status === 'available' && p.locationIcao === HUB) return 'ok';
|
||||
if (p.status === 'available') {
|
||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable leg
|
||||
// when cash allows — no top-up retry needed. Fail loud with the server's reason otherwise.
|
||||
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
if (flyHome.status() !== 200) throw new Error(`reposition home failed (${flyHome.status()}): ${await flyHome.text()}`);
|
||||
return `flying home from ${p.locationIcao}`;
|
||||
}
|
||||
return p.status; // flying or repairing — keep waiting (SCALE makes both finish quickly)
|
||||
}, { timeout: 240_000, message: 'waiting for plane-starter to be grounded at the hub' }).toBe('ok');
|
||||
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||
const s1 = await getState(page);
|
||||
for (const pkg of s1.packages) {
|
||||
@@ -270,6 +266,26 @@ test('open jobs only appear where a plane can take them — foreign-origin jobs
|
||||
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('jobs at an airport with no grounded plane stay hidden while a plane is still en route (ISS-159)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // starter grounded at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
// Dispatch the only plane toward `nearest` — while airborne its DESTINATION joins the pool (v0.65.0), so a job there
|
||||
// reaches /api/state, but no GROUNDED plane sits at that airport yet: ISS-159 hides it until landing.
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
const pkg = await createPackage(page, nearest.icao, HUB);
|
||||
// The server keeps the job (its origin is in the pool)…
|
||||
await expect.poll(async () => (await getState(page)).packages.some((p) => p.id === pkg.id), { timeout: 15_000 }).toBe(true);
|
||||
|
||||
await page.goto('/');
|
||||
// …but no row renders while the plane is still en route — no grounded plane at its origin. No landing wait needed;
|
||||
// the next test's flyHomeViaApi self-heal finishes this leg.
|
||||
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||
test.setTimeout(240_000); // one short flight at scale 1000 (seconds of real time) plus UI steps
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
+14
-4
@@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — range-edge legs land in ~2.5 s
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||
@@ -349,7 +350,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
|
||||
});
|
||||
|
||||
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
||||
test.setTimeout(300_000); // self-heal + range-edge legs fast-forwarded at 10_000×
|
||||
const secondId = await buySecondPlaneViaUi(page);
|
||||
|
||||
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||
@@ -379,8 +380,9 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
|
||||
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||||
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||||
|
||||
// Fast-forward to the FIRST landing (the short hop)…
|
||||
await setScale(page, SCALE);
|
||||
// Fast-forward to the FIRST landing (the short hop)… FAST is safe here — pure arrival waits, and a
|
||||
// lagged tick can only land a plane sooner. The range-edge leg takes ~2.5 s at this rate.
|
||||
await setScale(page, FAST);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||
timeout: 240_000,
|
||||
@@ -403,9 +405,17 @@ test('two planes fly in parallel and both deliver their packages', async ({ page
|
||||
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||
await setFuelFull(page, secondId);
|
||||
|
||||
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
|
||||
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs. The legs
|
||||
// dispatch at FAST (inherited above) — let them finish HERE, otherwise afterEach's heal pays the
|
||||
// range-edge return leg at SCALE (~24 s).
|
||||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for both planes to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { travelDurationMs } from '@/lib/sim/movement';
|
||||
import { legPhases } from '@/lib/sim/movement';
|
||||
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { SPECIALS } from './specials';
|
||||
import {
|
||||
@@ -478,7 +478,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
|
||||
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
|
||||
const o = airports.find((a) => a.icao === c.originIcao)!;
|
||||
const d = airports.find((a) => a.icao === c.destIcao)!;
|
||||
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
// v1.12.0: the floor includes the weather-scaled takeoff/landing phases (worst case for this pair).
|
||||
const floorMs = legPhases(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH, o.icao, d.icao).totalMs + EXPIRY_BUFFER_MS;
|
||||
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
|
||||
}
|
||||
});
|
||||
@@ -503,7 +504,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
|
||||
}
|
||||
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
|
||||
|
||||
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
// v1.12.0: phase-inclusive floor — same composition seed.ts uses for this pair.
|
||||
const floorMs = legPhases(bestKm, FASTEST_CATALOG_SPEED_KMH, airports[bestI].icao, airports[bestJ].icao).totalMs + EXPIRY_BUFFER_MS;
|
||||
for (const special of ['priority', null] as const) {
|
||||
let clamped = 0;
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
|
||||
+6
-4
@@ -1,5 +1,5 @@
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { travelDurationMs } from '@/lib/sim/movement';
|
||||
import { legPhases } from '@/lib/sim/movement';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { pickSpecial, SPECIALS } from './specials';
|
||||
@@ -284,9 +284,11 @@ export function generatePackage(input: {
|
||||
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs
|
||||
// (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls
|
||||
// below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of
|
||||
// airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate
|
||||
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling.
|
||||
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
|
||||
// airports on Earth (max leg ~20000 km → floor ≈ 23.5 h incl. worst-case phases < the 36 h priority
|
||||
// ceiling), so no separate ceiling raise is needed. Forced offsets skip the floor, same as they skip
|
||||
// the special scaling. v1.12.0: the travel time includes the weather-scaled takeoff/landing phases —
|
||||
// a package must be finishable even when both airports are in their worst (Fog) conditions.
|
||||
const expiryFloorMs = legPhases(km, FASTEST_CATALOG_SPEED_KMH, origin.icao, dest.icao).totalMs + EXPIRY_BUFFER_MS;
|
||||
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
|
||||
|
||||
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||||
|
||||
+3
-1
@@ -192,7 +192,9 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
const to = coordsByIcao.get(p.destIcao);
|
||||
if (!type || !from || !to) return null;
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||
// v1.12.0: piecewise cruise progress — holds at the origin during takeoff and at the
|
||||
// destination during landing (weather-scaled phase durations from legPhases).
|
||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao, simRow.clockMs);
|
||||
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
|
||||
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||
|
||||
+25
-2
@@ -1,6 +1,7 @@
|
||||
import type { PlaneState, StateSnapshot } from './game/state';
|
||||
import { formatFuel } from './format';
|
||||
import { positionAt } from './sim/movement';
|
||||
import { haversineKm } from './geo';
|
||||
import { legPhaseAt, positionAt, type LegPhase } from './sim/movement';
|
||||
|
||||
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
|
||||
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
|
||||
@@ -14,9 +15,14 @@ export interface PlaneFeatureProps {
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuelText: string; // "1234 / 6300 L"
|
||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||
// v1.12.0: 'taking off' | 'cruising' | 'landing' while flying (derived from the sim clock), '' when grounded.
|
||||
phaseLabel: string;
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
// v1.12.0: display text for each timed leg segment (see legPhaseAt in lib/sim/movement.ts).
|
||||
const PHASE_LABELS: Record<LegPhase, string> = { takeoff: 'taking off', cruise: 'cruising', landing: 'landing' };
|
||||
|
||||
export interface RouteFeatureProps {
|
||||
planeId: string;
|
||||
}
|
||||
@@ -54,13 +60,29 @@ export function planesGeoJson(
|
||||
if (!state) return EMPTY_POINTS;
|
||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
||||
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||
const speedByModel = new Map(state.catalog.map((e) => [e.model, e.speedKmh]));
|
||||
const clockMs = state.simState.clockMs;
|
||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||
const flying = p.status === 'flying';
|
||||
let lon = p.lon;
|
||||
if (flying && p.destIcao) {
|
||||
// v1.12.0: the marker's phase label — same legPhases composition the engine uses, derived from
|
||||
// departureSimTime + clockMs (both in the snapshot) so no extra state is needed.
|
||||
let phaseLabel = '';
|
||||
if (flying && p.destIcao && p.departureSimTime != null) {
|
||||
const from = airportsByIcao.get(p.locationIcao);
|
||||
const to = airportsByIcao.get(p.destIcao);
|
||||
const speedKmh = speedByModel.get(p.model);
|
||||
if (from && to) lon = unwrapLon(p.lon, from.lon);
|
||||
if (from && to && speedKmh !== undefined) {
|
||||
phaseLabel = PHASE_LABELS[legPhaseAt(
|
||||
p.departureSimTime,
|
||||
haversineKm(from.lat, from.lon, to.lat, to.lon),
|
||||
speedKmh,
|
||||
p.locationIcao,
|
||||
p.destIcao,
|
||||
clockMs,
|
||||
)];
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'Feature',
|
||||
@@ -71,6 +93,7 @@ export function planesGeoJson(
|
||||
status: p.status,
|
||||
fuelText: formatFuel(p.fuel, p.maxFuel),
|
||||
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
|
||||
phaseLabel,
|
||||
flying,
|
||||
},
|
||||
};
|
||||
|
||||
+63
-22
@@ -231,7 +231,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
const { inst, km, type } = await setupFlight('deliver-early.db');
|
||||
const playerBefore = (await getLocalPlayer(inst));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO) - 1; // one ms before arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(0);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -252,7 +252,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -298,7 +298,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
|
||||
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO);
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
@@ -317,7 +317,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const player = (await getLocalPlayer(inst));
|
||||
@@ -358,7 +358,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -383,7 +383,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
|
||||
@@ -429,7 +429,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -445,7 +445,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
it('grounds as before when the queue is empty', async () => {
|
||||
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||
@@ -473,7 +473,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.set({ queue: JSON.stringify(['FGSL']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -490,7 +490,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).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…
|
||||
@@ -514,7 +514,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
{ id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const bAfter = (await getPlayer(inst, 'player-b'));
|
||||
@@ -540,7 +540,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
|
||||
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
|
||||
const bBefore = (await getPlayer(inst, 'player-b'));
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
// Expected cost from the SAME helpers the engine uses — computed with B's rep discount.
|
||||
@@ -594,7 +594,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||
const { inst, km } = await setupFlight('wear-leg.db');
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
|
||||
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
|
||||
@@ -607,18 +607,19 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||
.set({ health: WEAR_PER_KM * km / 2 })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // clamped — never negative…
|
||||
|
||||
// …and a second leg keeps it pinned at 0.
|
||||
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||
const arr1 = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // leg 1's exact arrival (v1.12.0 phases)
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: arr1, fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||
expect(await deliverPackages(inst, arrivalTimeFor(arr1, nextKm, type.speedKmh, TO, 'EHAM'))).toBe(1);
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||
});
|
||||
@@ -632,7 +633,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||
.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, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.health).toBe(0); // broken by this landing's wear…
|
||||
@@ -742,6 +743,32 @@ describe('grounded fleet freezes the world clock (#135)', () => {
|
||||
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
||||
});
|
||||
|
||||
it('N-player fleet: one owner airborne keeps the clock running; all owners grounded freezes it (ISS-113)', async () => {
|
||||
const inst = await freshDb('nplayer-clock.db');
|
||||
// A second player with their own GROUNDED plane — the world now has two owners.
|
||||
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-b', ownerId: 'player-b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||
});
|
||||
|
||||
// A (local) takes off while B stays grounded → the world advances — one owner flying is enough…
|
||||
await makeWorldActive(inst);
|
||||
vi.setSystemTime(T0 + 1_000);
|
||||
const sim = await tickOnce(inst);
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // …at the fixed world rate
|
||||
|
||||
// …and grounding A's plane (B was already grounded) freezes the whole world.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'available', destIcao: null, departureSimTime: null })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
vi.setSystemTime(T0 + 2_000); // a full second of real idleness with every owner grounded
|
||||
const frozen = await tickOnce(inst);
|
||||
|
||||
expect(frozen.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // frozen — no advance…
|
||||
expect(frozen.lastRealTickMs).toBe(T0 + 2_000); // …but the real-time anchor keeps tracking
|
||||
});
|
||||
|
||||
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
|
||||
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
|
||||
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
|
||||
@@ -944,6 +971,24 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('MAX picks the local row when local leads — symmetric to the player-b case (v1.5.0)', async () => {
|
||||
const inst = await freshDb('pool-shared-rep-local.db');
|
||||
// Local is far ahead of b's starting rep — a "prefer non-local" implementation would pick b and fail here.
|
||||
await updateLocalPlayerEconomy(inst, { reputation: 60 });
|
||||
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||
for (const p of openRows) {
|
||||
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
|
||||
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to b's starting rep
|
||||
}
|
||||
});
|
||||
|
||||
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
|
||||
const inst = await freshDb('pool-single-rep.db');
|
||||
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
|
||||
@@ -962,8 +1007,4 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
||||
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
||||
function travelMs(km: number, speedKmh: number): number {
|
||||
return (km / speedKmh) * 3_600_000;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -228,7 +228,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
if (!type || !from || !to) continue; // no route info — leave alone
|
||||
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||
// v1.12.0: the gate includes the takeoff/landing phases (weather-scaled) — see legPhases.
|
||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao)) continue;
|
||||
|
||||
// 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.
|
||||
|
||||
+94
-24
@@ -1,13 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { weatherFor, type AirportWeather, type WeatherCondition } from '@/lib/weather';
|
||||
import {
|
||||
arrivalTimeFor,
|
||||
BASE_PHASE_MS,
|
||||
burnRateConstant,
|
||||
FUEL_DENSITY_KG_PER_L,
|
||||
fuelAtWeighted,
|
||||
guaranteedRangeKm,
|
||||
legPhases,
|
||||
legPhaseAt,
|
||||
MAX_PHASE_FACTOR,
|
||||
MAX_WIND_BURN_FACTOR,
|
||||
maxRangeKm,
|
||||
phaseFactor,
|
||||
positionAt,
|
||||
progressAt,
|
||||
requiredFuelFor,
|
||||
@@ -38,36 +44,98 @@ describe('travelDurationMs / arrivalTimeFor', () => {
|
||||
expect(() => travelDurationMs(100, 0)).toThrow();
|
||||
});
|
||||
|
||||
it('arrival is departure + duration', () => {
|
||||
const arrival = arrivalTimeFor(DEP, KM, SPEED);
|
||||
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
|
||||
expect(arrival).toBeGreaterThan(DEP);
|
||||
it('arrival is departure plus all three segments (v1.12.0)', () => {
|
||||
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBe(DEP + ph.totalMs);
|
||||
// The cruise part is the old pure duration — phases only add on top.
|
||||
expect(ph.cruiseMs).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
|
||||
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBeGreaterThan(DEP + ph.cruiseMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressAt', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
describe('phaseFactor / legPhases (v1.12.0)', () => {
|
||||
const mk = (condition: WeatherCondition): AirportWeather => ({ condition, windDirDeg: 90, windKt: 15, visibilityM: 8_000 });
|
||||
|
||||
it('is 0 before departure and at departure', () => {
|
||||
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
|
||||
it('MAX_PHASE_FACTOR is phaseFactor\'s actual maximum over the weather domain', () => {
|
||||
// Fog is the worst condition — the constant must track the factor function, not a copy.
|
||||
expect(MAX_PHASE_FACTOR).toBe(phaseFactor(mk('Fog')));
|
||||
});
|
||||
|
||||
it('is linear in the middle of the flight', () => {
|
||||
it('stays within [1, MAX_PHASE_FACTOR] and is monotone in condition severity', () => {
|
||||
for (const c of ['Clear', 'Cloudy', 'Rain', 'Fog'] as const) {
|
||||
const f = phaseFactor(mk(c));
|
||||
expect(f).toBeGreaterThanOrEqual(1);
|
||||
expect(f).toBeLessThanOrEqual(MAX_PHASE_FACTOR);
|
||||
}
|
||||
expect(phaseFactor(mk('Clear'))).toBe(1); // clear skies stay at 1×
|
||||
expect(phaseFactor(mk('Cloudy'))).toBeGreaterThan(phaseFactor(mk('Clear')));
|
||||
expect(phaseFactor(mk('Rain'))).toBeGreaterThan(phaseFactor(mk('Cloudy')));
|
||||
expect(phaseFactor(mk('Fog'))).toBe(MAX_PHASE_FACTOR); // worst condition is exactly 2×
|
||||
});
|
||||
|
||||
it('is a deterministic function of the ICAO via weatherFor', () => {
|
||||
for (const icao of ['EDDF', 'LEMD', 'EHAM', 'KSMF']) {
|
||||
expect(phaseFactor(weatherFor(icao))).toBe(phaseFactor(weatherFor(icao)));
|
||||
}
|
||||
// KSMF is Clear in the deterministic generator — a real ICAO pinned at exactly 1×.
|
||||
expect(phaseFactor(weatherFor('KSMF'))).toBe(1);
|
||||
});
|
||||
|
||||
it('takeoff scales on ORIGIN weather, landing on DESTINATION weather', () => {
|
||||
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||
expect(ph.takeoffMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('EDDF')));
|
||||
expect(ph.landingMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('LEMD')));
|
||||
// Non-vacuous: the two airports really do differ (Rain vs Cloudy), so a swapped
|
||||
// origin/dest would fail these pins.
|
||||
expect(phaseFactor(weatherFor('EDDF'))).not.toBe(phaseFactor(weatherFor('LEMD')));
|
||||
});
|
||||
|
||||
it('total is the sum of the three segments and exceeds the old pure duration', () => {
|
||||
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||
expect(ph.totalMs).toBe(ph.takeoffMs + ph.cruiseMs + ph.landingMs);
|
||||
expect(ph.totalMs).toBeGreaterThan(travelDurationMs(KM, SPEED));
|
||||
});
|
||||
|
||||
it('legPhaseAt walks takeoff → cruise → landing over the leg', () => {
|
||||
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP)).toBe('takeoff');
|
||||
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs - 1)).toBe('takeoff');
|
||||
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs)).toBe('cruise');
|
||||
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs + ph.cruiseMs - 1)).toBe('cruise');
|
||||
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.totalMs - 1)).toBe('landing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressAt (piecewise, v1.12.0)', () => {
|
||||
const ORIGIN = 'EDDF';
|
||||
const DEST = 'LEMD';
|
||||
const ph = legPhases(KM, SPEED, ORIGIN, DEST);
|
||||
|
||||
it('is 0 before departure and throughout the takeoff hold at the origin', () => {
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP - 1)).toBe(0);
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs / 2)).toBe(0);
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs - 1));
|
||||
expect(p).toEqual({ lat: FROM.lat, lon: FROM.lon }); // still at the gate — no crawl along the route
|
||||
});
|
||||
|
||||
it('is linear over the cruise window', () => {
|
||||
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
|
||||
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs / 2)).toBeCloseTo(0.5, 9);
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs * 0.25)).toBeCloseTo(0.25, 9);
|
||||
});
|
||||
|
||||
it('is exactly 1 at arrival and stays 1 after', () => {
|
||||
const arrival = DEP + duration;
|
||||
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
|
||||
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
|
||||
it('is exactly 1 at the start of the landing hold and stays 1 through arrival', () => {
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs));
|
||||
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // parked at the destination gate during landing
|
||||
const arrival = DEP + ph.totalMs;
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival)).toBe(1);
|
||||
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival + 10_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('a zero-distance leg is complete immediately', () => {
|
||||
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
|
||||
it('a zero-distance leg has no cruise to progress through', () => {
|
||||
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP - 5)).toBe(0); // before departure
|
||||
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP + 1)).toBe(1); // complete once departed
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,12 +228,14 @@ describe('weighted burn model (v0.16.0)', () => {
|
||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
|
||||
});
|
||||
|
||||
it('composes with progressAt for a just-before-arrival snapshot', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
const clock = DEP + duration - 1; // one ms before arrival
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
|
||||
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
|
||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
|
||||
it('composes with progressAt for a just-before-arrival snapshot (v1.12.0)', () => {
|
||||
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
|
||||
const clock = DEP + ph.totalMs - 1; // one ms before arrival — inside the landing hold
|
||||
const progress = progressAt(DEP, KM, SPEED, 'EDDF', 'LEMD', clock);
|
||||
expect(progress).toBe(1); // cruise is done: the plane holds at the destination gate…
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progress);
|
||||
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // …so it IS at the gate (v1.12.0 piecewise)
|
||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progress)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+83
-8
@@ -3,6 +3,8 @@
|
||||
// distance in game time without losing accuracy. All inputs are explicit; there
|
||||
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
|
||||
|
||||
import { weatherFor, type AirportWeather } from '@/lib/weather';
|
||||
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const toDeg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
@@ -12,22 +14,95 @@ export function travelDurationMs(distanceKm: number, speedKmh: number): number {
|
||||
return (distanceKm / speedKmh) * 3_600_000;
|
||||
}
|
||||
|
||||
// Sim-clock time at which the leg completes.
|
||||
export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number {
|
||||
return departureSimTime + travelDurationMs(distanceKm, speedKmh);
|
||||
// --- Takeoff & landing phases (v1.12.0) ---
|
||||
//
|
||||
// Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the
|
||||
// destination, on top of the pure cruise time. Weather at the relevant airport stretches each
|
||||
// phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases
|
||||
// burn no fuel — they are pure time, so the weighted-burn model is untouched.
|
||||
|
||||
// One phase's duration in clear weather: 10 minutes of sim time.
|
||||
export const BASE_PHASE_MS = 600_000;
|
||||
|
||||
// Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the
|
||||
// condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a
|
||||
// function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent
|
||||
// signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR.
|
||||
export function phaseFactor(w: AirportWeather): number {
|
||||
const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3;
|
||||
return 1 + step / 3;
|
||||
}
|
||||
|
||||
// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before
|
||||
// departure and 1 after arrival. A zero-distance leg is complete immediately.
|
||||
// Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition).
|
||||
export const MAX_PHASE_FACTOR = 2;
|
||||
|
||||
export interface LegPhases {
|
||||
takeoffMs: number; // origin weather
|
||||
cruiseMs: number; // pure cruise — the old travelDurationMs value
|
||||
landingMs: number; // destination weather
|
||||
totalMs: number; // takeoff + cruise + landing
|
||||
}
|
||||
|
||||
// The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION
|
||||
// weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the
|
||||
// two ICAs recomputes exactly what the engine used — no stored state needed.
|
||||
export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases {
|
||||
const cruiseMs = travelDurationMs(distanceKm, speedKmh);
|
||||
const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao));
|
||||
const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao));
|
||||
return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs };
|
||||
}
|
||||
|
||||
// Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0).
|
||||
export function arrivalTimeFor(
|
||||
departureSimTime: number,
|
||||
distanceKm: number,
|
||||
speedKmh: number,
|
||||
originIcao: string,
|
||||
destIcao: string,
|
||||
): number {
|
||||
return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs;
|
||||
}
|
||||
|
||||
// CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before
|
||||
// departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear
|
||||
// over the cruise window, 1 while it holds at the destination during landing. That keeps
|
||||
// positionAt/fuel pinned to each airport during its phase instead of crawling along the whole
|
||||
// route. A zero-distance leg has no cruise: complete once departed.
|
||||
export function progressAt(
|
||||
departureSimTime: number,
|
||||
distanceKm: number,
|
||||
speedKmh: number,
|
||||
originIcao: string,
|
||||
destIcao: string,
|
||||
clockMs: number,
|
||||
): number {
|
||||
const duration = travelDurationMs(distanceKm, speedKmh);
|
||||
if (duration <= 0) return 1;
|
||||
return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration));
|
||||
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||||
const t = clockMs - departureSimTime;
|
||||
if (t <= 0) return 0;
|
||||
if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise
|
||||
if (t >= ph.totalMs) return 1;
|
||||
if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff
|
||||
if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing
|
||||
return (t - ph.takeoffMs) / ph.cruiseMs;
|
||||
}
|
||||
|
||||
export type LegPhase = 'takeoff' | 'cruise' | 'landing';
|
||||
|
||||
// Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0).
|
||||
export function legPhaseAt(
|
||||
departureSimTime: number,
|
||||
distanceKm: number,
|
||||
speedKmh: number,
|
||||
originIcao: string,
|
||||
destIcao: string,
|
||||
clockMs: number,
|
||||
): LegPhase {
|
||||
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||||
const t = clockMs - departureSimTime;
|
||||
if (t < ph.takeoffMs) return 'takeoff';
|
||||
if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise';
|
||||
return 'landing';
|
||||
}
|
||||
|
||||
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.6.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user