Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e109e003c0 | ||
|
|
cbe0cda15d | ||
|
|
4620707abc | ||
|
|
3024606ac8 | ||
|
|
f44e7aa0f5 | ||
|
|
58d05f5287 | ||
|
|
e18d39edd0 | ||
|
|
a7150a1062 | ||
|
|
8eeef401ae | ||
|
|
f273d385c2 | ||
|
|
3b02c7feac | ||
|
|
1ba70ebbf3 |
+2
-1
@@ -1 +1,2 @@
|
||||
# No environment variables required yet.
|
||||
# No environment variables required yet.
|
||||
GITEA_TOKEN=gitea-api-token
|
||||
@@ -28,6 +28,3 @@ public/styles/
|
||||
public/fonts/
|
||||
public/sprites/
|
||||
public/map-assets-manifest.json
|
||||
|
||||
# Local-only successor-agent manual (references host setup/credentials pointers) — never commit
|
||||
ORCHESTRATOR.md
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## v0.24.0 — 2026-09-20 09:48
|
||||
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
|
||||
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
|
||||
- Tests: unit — `lib/sim/engine.test.ts`: the auto-slow describe block rewritten for pause semantics (last plane lands → `paused === true` AND stored `timeScale` UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). `lib/game/actions.test.ts`: new test — dispatching on a paused sim clears `paused` and leaves `timeScale` untouched. E2E — `e2e/auto-slow.spec.ts` renamed to `e2e/pause-on-landing.spec.ts` and reworked: after the starter lands, asserts `simState.paused === true` AND `timeScale` still 1000 (NOT reset), then a subsequent `/fly` dispatch clears `paused` on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec `setScale` helper (`actions`, `hover`, `map-interactions`, `relocation`, `right-pane`) now POSTs `{ timeScale, paused: false }` — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (`sim.spec.ts` / `panes.spec.ts` freeze tests, marker-click freezes) are untouched
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (291); `npx playwright test --list` = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.23.2 — 2026-09-20 09:15
|
||||
- **On-board package hover previews the route from the carrier's current position (ISS-040).** Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder `hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao)` in `lib/mapData.ts` (sibling of `hoverRouteGeoJson`, same shape/conventions; empty collection when the destination is missing); `components/Map.tsx`'s `applyHoverRef` looks up the carrier via the hover's `planeId` and uses it, falling back to the ICAO trace when no carrier is in state. The `[state]` effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles
|
||||
- Tests: unit — `lib/mapData.test.ts` gains a `hoverTraceGeoJson` block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — `e2e/hover.spec.ts`: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home
|
||||
|
||||
## v0.23.1 — 2026-09-20 08:42
|
||||
- **Toasts repositioned to the map's right edge (ISS-039).** The `.toasts` stack was `position: fixed` at the viewport bottom-right, overlapping the 280px right pane; its `right` changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
|
||||
- Tests: e2e wording only — `toasts.spec.ts` header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.23.0 — 2026-09-20 08:25
|
||||
- **Refuel-to-full row moves to the left pane (ISS-035).** Refueling a grounded plane is "anything with our plane", so it belongs in the left pane next to Relocate, not as a market row on the right. The `.market-row` JSX (`market-refuel-none` "All tanks full." span / `market-refuel-plane` select of grounded planes labeled `{planeName} — {fuel}/{maxFuel} L`, `market-refuel-cost` preview, `market-refuel` button → `POST /api/planes/{id}/refuel`) moved verbatim from the Market section in `components/RightPane.tsx` into a new "Refuel" `<section data-testid="refuel-section">` in `components/LeftPane.tsx`, inserted between Relocate and Time. Its state (`refuelPlaneId`) + derived values (`refuelable`, effective-selection `refuelPlane`, `refuelCost`) moved with it, reusing LeftPane's existing `grounded` list; busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`). Testids kept byte-identical so all e2e locators are unchanged. The per-package "Refuel" button in the On board section (`refuel-for-{pkg.id}`, mode 'forFlight') is a different feature and stays put; exported helpers `slotsUsedFor` / `loadEligiblePlanes` untouched
|
||||
- Tests: e2e — `right-pane.spec.ts`: the market test keeps its buy/sell assertions, renamed to reflect that the refuel row now renders inside `[data-testid="left-pane"]` (asserted before the click); functional cash/tank assertions unchanged; the for-leg test's title/comment updated from "market button" to left pane. `panes.spec.ts`: first test gains structural assertions — Refuel heading + `market-refuel` visible in the left pane, both absent from the right pane (same present-in-left / absent-from-right pattern as On board). No unit-test changes (suite stays at 288)
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.22.2 — 2026-09-20 07:32
|
||||
- Docs: ORCHESTRATOR.md successor-agent manual committed to the repo; its token reference now points to the gitignored local .env (GITEA_TOKEN, rotated after the Sept 2026 incident); §9 rewritten as durable 'how to find current state' guidance instead of a stale snapshot; no code changes
|
||||
|
||||
## v0.22.1 — 2026-09-20 06:57
|
||||
- Hygiene: gitignore the local-only ORCHESTRATOR.md successor-agent manual (it references host-specific setup and must never be version-controlled); no code changes
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# ORCHESTRATOR.md — Operating Manual for Successor Agents
|
||||
|
||||
You are taking over the autonomous development loop on **Flight-Ops** (browser game: run an airplane transport company in Europe; server-authoritative sim, MapLibre GL JS map). This document captures operational knowledge that is NOT written down in AGENTS.md. Read AGENTS.md first — it defines the mandatory change process and this file complements it.
|
||||
|
||||
Credentials are deliberately NOT stored in this file — see §1 for where they live. Never paste token values into any tracked file.
|
||||
|
||||
## 1. Project identity
|
||||
- Repo: Gitea at https://git.kkhost.pl → org **OpenCode**, repo **Flight-Ops** (full name `OpenCode/Flight-Ops`). Every Gitea API/MCP call uses owner=`OpenCode`, repo=`Flight-Ops`.
|
||||
- Local workdir + prod deploy dir: `/home/krzyw/projects/sandbox/flight` (git checkout; `docker compose up -d --build` deploys from here).
|
||||
- Prod URL: https://flight.dev.kkhost.pl (Traefik → docker-compose stack on this host). `GET /api/version` reports the running version.
|
||||
- Gitea API access for git.kkhost.pl: prefer the gitea MCP tools — they are already configured with a token. For raw REST/curl, read the current token from the local .env file at repo root (variable `GITEA_TOKEN`; .env is gitignored and must never be committed): `source .env && curl -s -H "Authorization: token $GITEA_TOKEN" https://git.kkhost.pl/api/v1/user`. (Incident note, Sept 2026: an early draft of this doc committed a live token to the repo; that commit was purged from all refs and the token has since been rotated — never repeat this.)
|
||||
- Current user: kamilkrzywda (id 1).
|
||||
|
||||
## 2. The loop (per issue)
|
||||
1. Small incremental change on a feature branch off `master`; connected issues may share one branch/PR with a single version bump.
|
||||
2. Bump `package.json` version + add CHANGELOG.md entry at top: `## v<version> — YYYY-MM-DD HH:MM` then bullet points of what changed (docs-only changes get patch bumps too — precedent v0.19.1/v0.19.2).
|
||||
3. Local verification ONLY (there are NO local e2e runs): `npx tsc --noEmit`, `npx vitest run` (the whole unit suite must pass — the test count grows over time, so compare against the previous green run, never a hardcoded number), `npx playwright test --list` (all specs compile and list).
|
||||
4. Push → CI (~7 min) → **auto-merge on green** (PRs have auto-merge configured; no manual merge needed — verify via `GET /pulls/{n}` that state=closed and merged_at is set). Note: if a PR was created via API rather than create-on-push, double-check auto-merge actually fires.
|
||||
5. Tag the version on master: `git tag v<version> origin/master && git push origin v<version>` (after `git fetch origin master`).
|
||||
6. Deploy prod in a PTY (long task): `cd /home/krzyw/projects/sandbox/flight && git checkout master && git reset --hard origin/master && docker compose up -d --build`; then verify `curl https://flight.dev.kkhost.pl/api/version` returns the new version and the root page is HTTP 200.
|
||||
7. Close each issue with a comment: what changed, version, PR number, merge commit SHA, deployed URL.
|
||||
8. Repeat for the next open issue: `GET /issues?state=open&type=issues` — note Gitea lists pull requests in this endpoint too; filter out entries with a `pull_request` key.
|
||||
|
||||
## 3. Gitea API + MCP quirks (learned the hard way)
|
||||
- The gitea MCP tools work for issues/PRs/files/tags, and Actions endpoints work IF you pass owner=`OpenCode`, repo=`Flight-Ops`. A wrong owner/repo makes EVERY call return `{"message":"not found","url":"https://git.kkhost.pl/api/swagger"}` — that swagger URL inside a 404 is the signature of "wrong path", not "Gitea is down".
|
||||
- MCP merge endpoint 405s → don't bother; auto-merge handles merging. Manual fallback if ever needed: `POST /repos/OpenCode/Flight-Ops/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge"}`.
|
||||
- Gitea REST shapes (NOT GitHub's): list runs → `{workflow_runs:[...]}`; run jobs → `/actions/runs/{id}/jobs` returns `{"jobs":[...]}`; single job log via MCP `get_job_log_preview` (param tail_lines) or `download_job_log` to a file, then grep.
|
||||
- Failure anatomy in CI logs: the Playwright summary at the end lists "N failed" + test titles; each failure block has a code frame with line numbers plus an "Error Context:" path. The e2e step is the only one that can fail flakily; unit failures are real regressions.
|
||||
- CI watcher pattern that works: `run_background_process` (notify=true) running a loop that polls `GET /actions/runs/{id}` every 60 s and exits when status=completed, printing status+conclusion each tick. Don't poll in the foreground; don't sleep-loop on pty_read.
|
||||
|
||||
## 4. CI facts (.gitea/workflows/ci.yml + docker-compose.ci.yml)
|
||||
- Single job "build + unit + e2e" on runner gitea-runner (docker-in-docker). Fresh DB per run (volume removed); compose project `flight-ci`; app container on a static IP → `E2E_BASE_URL=http://172.39.0.10:3000`; `PW_REMOTE_BROWSER=0` (CI runs local chromium — the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind); `E2E_COMPOSE_ARGS="-f docker-compose.ci.yml -p flight-ci"` is read by e2e/state.spec.ts for its mid-run `docker compose restart flight`.
|
||||
- Flaky uplink → retry loops in ci.yml; an occasional "flaky" test (failed once, passed on retry) does NOT fail the run.
|
||||
- Branch protection requires status check `CI / build + unit + e2e (pull request)` — PRs cannot merge until green; CI never deploys to production.
|
||||
|
||||
## 5. E2E conventions (e2e/*.spec.ts) — read before touching tests
|
||||
- Sim scale in tests: SCALE=1000; hub `HUB='EDDF'` (Frankfurt); typical destination LEMD.
|
||||
- `flyHomeViaApi(page)` self-heal at test start — interrupted runs can leave planes mid-flight; landing waits up to 240 s.
|
||||
- Pause-sim pattern for deterministic marker clicks: pause, act, assert, resume.
|
||||
- Dev hook: `window.__flightMap` exposes the live MapLibre map instance in dev builds (also `__flightLastSnapshot`).
|
||||
- `queryRenderedFeatures` is viewport-scoped → fitBounds/fitToAirports before asserting on far features; give the renderer ~300 ms after a camera jump.
|
||||
- **expect.poll callbacks must be total** — a throw inside fails the test instantly instead of retrying. Guard/filter everything you dereference.
|
||||
|
||||
## 6. ⚠️ The big one: Playwright evaluate serialization drops prototype getters
|
||||
MapLibre's `queryRenderedFeatures` returns live `GeoJSONFeature` class instances whose `geometry` is a **lazy PROTOTYPE getter** (own properties are only type/properties/id/source/state plus private fields). Playwright serializes evaluate results by copying **own enumerable properties only**, so every feature arriving in test context has NO geometry — silently.
|
||||
- Verified empirically: `page.evaluate(() => { class A{constructor(){this.own=1} get geo(){return 42}} return [new A()] })` → `[{"own":1}]`.
|
||||
- Symptom matrix (how it masqueraded as "the line isn't drawn" while the trace rendered fine on screen, across three consecutive failed CI runs in Sept 2026): strict length guard → timeout; direct `.geometry.coordinates` access → `TypeError: Cannot read properties of undefined (reading 'coordinates')`; defensive filter → clean 15 s timeout.
|
||||
- **Rule: never return maplibre feature instances across page.evaluate — always map to plain objects in-page** (`{type, id, properties, geometry: f.geometry}` forces the getter to evaluate). Canonical pattern: `renderedFeatures()` in e2e/hover.spec.ts. Other specs that touch `.geometry` do so inside evaluate (safe) or only read array length (safe).
|
||||
|
||||
## 7. MapLibre rendering facts (for map assertions)
|
||||
- GeoJSON geometry is stored per-tile in INTEGER grids; long lines are clipped → `queryRenderedFeatures` returns ONE FEATURE PER TILE the line crosses (the EDDF→LEMD leg ≈ 4–5 segments at z=4/5).
|
||||
- Coordinates are quantized to the grid: error up to ~half a grid step (~0.001° observed at z≥4, worst ~0.02° at z=0 with extent 8192). Endpoint assertions must use an EPS sized for this (the hover tests define one in `expectHoverRoute` in e2e/hover.spec.ts — ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports) and check endpoints ACROSS ALL SEGMENTS: some segment must START within EPS of origin AND some must END within EPS of destination. Never strict equality on a single feature.
|
||||
- The true line endpoints survive clipping verbatim in whichever tile contains them.
|
||||
|
||||
## 8. App architecture quick map
|
||||
- `components/Map.tsx` — the MapLibre layer: sources/layers created in ensureLayers (dedicated `hover-routes` GeoJSON source + top-most dashed `hover-route` LineLayer, plus an `airports-target-highlight` ring layer added last so it sits above the line at its endpoint — current colors/widths live in Map.tsx, don't trust this doc for exact paint values); applyHoverRef re-pushes data on every hover; `window.__flightMap` dev hook.
|
||||
- `lib/mapData.ts` — pure GeoJSON builders (`hoverRouteGeoJson`, `routesGeoJson`, `greatCircleArc` slerp) + unit tests in lib/mapData.test.ts.
|
||||
- Hover protocol: panes report a HoverTarget via mouseenter/leave (`components/hoverTypes.ts`): `{kind:'plane';id} | {kind:'package';originIcao;destIcao;planeId?} | {kind:'route';fromIcao;toIcao}`; the `''` sentinel means no hover.
|
||||
- Server: sim ticks server-side, client polls /api/state; OpenAPI spec generated from lib/openapi.ts and served at /api/openapi — update it whenever an endpoint changes.
|
||||
|
||||
## 9. Finding current state (this file keeps no snapshots)
|
||||
This manual is meant to stay valid long-term, so it deliberately contains NO version or issue snapshots — they rot. To orient yourself when you take over: `git log --oneline -10` + CHANGELOG.md for what has shipped; `GET /issues?state=open&type=issues` (filter out entries with a `pull_request` key) for what is left to do; PLAN.md's "Later" section for the roadmap backlog.
|
||||
|
||||
## 10. Standing user instruction & etiquette
|
||||
- The user's standing order for this project: work through the issue list autonomously — implement → PR → CI green → merge → tag → deploy prod → close with comment → next — until told to stop.
|
||||
- Small incremental changes; each part fully tested (unit + e2e) before moving on.
|
||||
- Long terminal tasks → pty_* tools (deploys, builds). Background watchers → run_background_process with notify=true.
|
||||
- Never push directly to master; never deploy from a feature branch; CI never touches prod — production deploys are manual only (`docker compose up -d --build` after tagging).
|
||||
- **Never commit credentials.** Tokens/secrets live in the MCP server config or with the user, never in tracked files (see incident note in §1).
|
||||
+2
-1
@@ -420,7 +420,8 @@ body {
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
/* 280px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
|
||||
right: 292px;
|
||||
z-index: 50; /* above map popups/controls and the app header */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -102,6 +102,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
const [relocatePlaneId, setRelocatePlaneId] = useState<string | null>(null);
|
||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(null);
|
||||
|
||||
// ISS-035 (v0.23.0): Refuel-to-full selection — moved here from the right-pane market row; same
|
||||
// "effective selection" pattern as Relocate above: the user's pick while it stays refuelable, else
|
||||
// a sensible default (first grounded plane with fuel below max).
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
||||
|
||||
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
|
||||
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
|
||||
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
|
||||
@@ -205,6 +210,16 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
const relocateDest =
|
||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||
|
||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
||||
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
||||
// market row; reuses `grounded` above, and with state null falls back to null.
|
||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
||||
const refuelPlane =
|
||||
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
||||
refuelable[0] ??
|
||||
null;
|
||||
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
||||
|
||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
||||
// user releases the slider (browsers fire a native 'change' on release). The server's
|
||||
@@ -507,6 +522,50 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ISS-035 (v0.23.0): moved here from the right-pane market row — it belongs with everything else
|
||||
about our planes, next to Relocate. Plain section: no hover handlers (refuel is not a route). */}
|
||||
<section className="pane-section" data-testid="refuel-section">
|
||||
<h3 className="pane-heading">Refuel</h3>
|
||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
||||
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
|
||||
<div className="market-row">
|
||||
{refuelable.length === 0 ? (
|
||||
<span data-testid="market-refuel-none" className="pane-muted">
|
||||
All tanks full.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
data-testid="market-refuel-plane"
|
||||
aria-label="Plane to refuel"
|
||||
value={refuelPlane?.id ?? ''}
|
||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
||||
>
|
||||
{refuelable.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {Math.round(p.fuel)} / {p.maxFuel} L
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span data-testid="market-refuel-cost" className="market-preview">
|
||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-refuel"
|
||||
className="pane-btn"
|
||||
disabled={actionsBusy || refuelPlane === null}
|
||||
onClick={() => {
|
||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
||||
}}
|
||||
>
|
||||
Refuel to full
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Time</h3>
|
||||
<p data-testid="sim-clock" className="sim-clock">
|
||||
|
||||
+12
-2
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
|
||||
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
||||
import type { StateSnapshot } from '../lib/game/state';
|
||||
import type { HoverTarget } from './hoverTypes';
|
||||
import { hoverRouteGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { approxLocalTime } from '../lib/localTime';
|
||||
|
||||
@@ -100,6 +100,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
useEffect(() => {
|
||||
stateRef.current = state ?? null;
|
||||
applyDynamicRef.current();
|
||||
// ISS-040 (v0.23.2): re-apply the hover too, so an on-board package's trace follows a moving
|
||||
// carrier between polls while hovered — idempotent and cheap (one small setData + ring filters).
|
||||
applyHoverRef.current();
|
||||
}, [state]);
|
||||
|
||||
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
|
||||
@@ -138,7 +141,14 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
|
||||
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
if (hoverSource) {
|
||||
void hoverSource.setData(hoverRouteGeoJson(fromIcao, toIcao, airportsIndexRef.current ?? new Map()));
|
||||
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
||||
// not its origin airport — identical to before while grounded at the pickup, different once it
|
||||
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
||||
const idx = airportsIndexRef.current ?? new Map();
|
||||
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
||||
void hoverSource.setData(
|
||||
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
|
||||
);
|
||||
}
|
||||
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
||||
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
|
||||
|
||||
@@ -116,10 +116,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
|
||||
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
|
||||
|
||||
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
||||
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
||||
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
|
||||
// left pane's "Refuel" section next to Relocate.
|
||||
|
||||
if (loading && !state) {
|
||||
return (
|
||||
@@ -139,15 +140,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
|
||||
const grounded = state.planes.filter((p) => p.status === 'available');
|
||||
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
|
||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
||||
const refuelPlane =
|
||||
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
|
||||
refuelable[0] ??
|
||||
null;
|
||||
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
|
||||
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
|
||||
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
|
||||
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
||||
|
||||
return (
|
||||
<aside data-testid="right-pane" className="pane right-pane">
|
||||
@@ -262,44 +257,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="market-row">
|
||||
{refuelable.length === 0 ? (
|
||||
<span data-testid="market-refuel-none" className="pane-muted">
|
||||
All tanks full.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
data-testid="market-refuel-plane"
|
||||
aria-label="Plane to refuel"
|
||||
value={refuelPlane?.id ?? ''}
|
||||
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
|
||||
>
|
||||
{refuelable.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span data-testid="market-refuel-cost" className="market-preview">
|
||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-refuel"
|
||||
className="pane-btn"
|
||||
disabled={busy || refuelPlane === null}
|
||||
onClick={() => {
|
||||
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
|
||||
}}
|
||||
>
|
||||
Refuel to full
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||||
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||||
</section>
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
|
||||
+11
-9
@@ -66,8 +66,10 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
@@ -118,7 +120,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
@@ -225,8 +227,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
||||
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
|
||||
|
||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-011: the
|
||||
// arrival above auto-slown the sim to 1×, so re-set SCALE for the return leg.
|
||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
@@ -314,8 +316,8 @@ 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: refuel + fly home. ISS-011: the LEMD arrival above auto-slown the sim to 1×, so
|
||||
// re-set SCALE for the return leg (the expiry wait's scale no longer persists past a landing).
|
||||
// Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
||||
await page.request.post('/api/planes/plane-starter/refuel');
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
@@ -355,8 +357,8 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||
expect(res.status()).toBe(409);
|
||||
|
||||
// Fly home from the hop so the fuel section starts at the hub. ISS-011: the hop arrival above
|
||||
// auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
|
||||
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
@@ -380,7 +382,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(atFar.locationIcao).toBe(FAR);
|
||||
// The return leg fits: the weighted burn over it is less than what's left in the tank.
|
||||
// ISS-011: the FAR arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||
// ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(backRes.ok()).toBeTruthy();
|
||||
|
||||
+99
-4
@@ -24,6 +24,8 @@ interface PlaneState {
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface PackageState {
|
||||
@@ -53,9 +55,10 @@ async function postSim(page: Page, body: unknown) {
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout.
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
@@ -93,7 +96,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
@@ -485,7 +488,99 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||
.toBe(true);
|
||||
|
||||
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
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 === HUB, {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
|
||||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||
expect(refuel.status()).toBe(200);
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
||||
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);
|
||||
|
||||
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
|
||||
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
|
||||
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
||||
const hubLonLat = await airportLonLat(page, HUB);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
if (!p || p.status !== 'flying') return false;
|
||||
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
|
||||
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
|
||||
.toBe(true);
|
||||
|
||||
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
|
||||
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
|
||||
await postSim(page, { paused: true });
|
||||
|
||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
|
||||
await fitToAirports(page, [HUB, DEST]);
|
||||
|
||||
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
|
||||
const destLonLat = await airportLonLat(page, DEST);
|
||||
|
||||
await row.hover();
|
||||
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
|
||||
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
|
||||
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
||||
await expect.poll(async () => {
|
||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||
const segs = feats.filter(
|
||||
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||
);
|
||||
if (!segs.length) return false;
|
||||
const near = (a: [number, number], b: [number, number]) =>
|
||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
|
||||
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
|
||||
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||||
return startsAtLive && endsAtDest && noneStartsAtHub;
|
||||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
|
||||
|
||||
await unhover(page);
|
||||
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||
timeout: 15_000,
|
||||
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||
}).toBe(0);
|
||||
|
||||
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
|
||||
// flying home afterwards leaves the starter clean for the other specs.
|
||||
await postSim(page, { timeScale: SCALE, paused: false });
|
||||
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);
|
||||
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(home.status()).toBe(200);
|
||||
|
||||
@@ -69,9 +69,10 @@ async function postSim(page: Page, body: unknown) {
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout.
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
|
||||
@@ -75,6 +75,14 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
||||
// …and no longer in the right pane.
|
||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||
|
||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
||||
// heading and button render here…
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible();
|
||||
// …and neither the heading nor the row remains in the right pane.
|
||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.19.0 — ISS-011: when the LAST plane lands (no plane remains in flight) the sim auto-slows
|
||||
// to 1×, server-side and persisted on the sim_state row so the UI slider reflects it on the next
|
||||
// snapshot. Arrival-triggered by design: a grounded fleet at high scale is left alone, there is no
|
||||
// auto-restore — the player speeds up manually again (asserted below).
|
||||
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses
|
||||
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight
|
||||
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses →
|
||||
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded
|
||||
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
@@ -33,8 +34,10 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as SimState;
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
@@ -75,7 +78,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
@@ -93,8 +96,8 @@ test.afterEach(async ({ page }) => {
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
test('auto-slow to 1× when the last plane lands; manual speed-up still works (ISS-011)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
||||
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
@@ -117,14 +120,19 @@ test('auto-slow to 1× when the last plane lands; manual speed-up still works (I
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
||||
.toBe(true);
|
||||
|
||||
// …and the server has auto-slown to 1× — persisted on sim_state, so this very snapshot shows it.
|
||||
expect(landedBody!.simState.timeScale).toBe(1);
|
||||
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it…
|
||||
expect(landedBody!.simState.paused).toBe(true);
|
||||
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch.
|
||||
expect(landedBody!.simState.timeScale).toBe(SCALE);
|
||||
|
||||
// Manual override still works: POST /api/sim {timeScale: 1000} → confirmed by the response…
|
||||
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE } });
|
||||
expect(post.status()).toBe(200);
|
||||
expect(((await post.json()) as SimState).timeScale).toBe(SCALE);
|
||||
// …and by a fresh snapshot (no auto-restore of the previous scale — and no re-slow either,
|
||||
// since nothing is in flight: the trigger is arrival-based, not idle-enforcement).
|
||||
expect((await getState(page)).simState.timeScale).toBe(SCALE);
|
||||
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full
|
||||
// post-action snapshot, so no poll race with the client's next tick.
|
||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(fly2.status()).toBe(200);
|
||||
const resumedBody = (await fly2.json()) as StateBody;
|
||||
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect
|
||||
|
||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
||||
});
|
||||
@@ -31,8 +31,10 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
@@ -72,7 +74,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
|
||||
+16
-11
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
|
||||
// abandon buttons (penalty preview from the same
|
||||
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
|
||||
// match the catalog + economy constants exposed in /api/state. Packages are created via
|
||||
// formula as the server); flying planes show their current leg; market buy/sell previews match
|
||||
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
|
||||
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
|
||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||
@@ -68,9 +69,10 @@ async function postSim(page: Page, body: unknown) {
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout.
|
||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
@@ -175,7 +177,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
@@ -288,7 +290,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
||||
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.status()).toBe(200);
|
||||
@@ -300,7 +302,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
|
||||
test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||
|
||||
@@ -325,6 +327,9 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
|
||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||
|
||||
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
|
||||
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Refuel through the pane: the flight genuinely burned fuel…
|
||||
if (starter.fuel < starter.maxFuel) {
|
||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||
@@ -348,7 +353,7 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
|
||||
}
|
||||
});
|
||||
|
||||
test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => {
|
||||
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
@@ -403,12 +408,12 @@ test('on-board row Refuel tops up just enough for the package leg; market button
|
||||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
||||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
||||
|
||||
// Market row: the fill-up button is labeled "Refuel to full" (testid unchanged).
|
||||
// Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
|
||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
||||
|
||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
||||
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-011: the far-end
|
||||
// arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
|
||||
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||
await setScale(page, SCALE);
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
||||
await expect
|
||||
|
||||
+6
-5
@@ -1,9 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.17.0 — ISS-008: action errors surface as bottom-right toasts (in addition to the inline
|
||||
// pane error) and game events appear as info toasts via snapshot diffing; the left-pane Time
|
||||
// section offers an explicit "Enable notifications" opt-in for browser notifications.
|
||||
// v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the
|
||||
// right pane, in addition to the inline pane error) and game events appear as info toasts via
|
||||
// snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in
|
||||
// for browser notifications.
|
||||
|
||||
interface CatalogEntry {
|
||||
model: string;
|
||||
@@ -22,7 +23,7 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
test('failed actions toast bottom-right (inline error stays) and the Time section offers a notifications opt-in', async ({ page }) => {
|
||||
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const buyModel = page.getByTestId('market-buy-model');
|
||||
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
|
||||
@@ -38,7 +39,7 @@ test('failed actions toast bottom-right (inline error stays) and the Time sectio
|
||||
|
||||
// The toast appears with the server's error message and the error variant class…
|
||||
const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i });
|
||||
await expect(toast, 'the error toast appears bottom-right').toBeVisible({ timeout: 10_000 });
|
||||
await expect(toast, 'the error toast appears at the map right edge').toBeVisible({ timeout: 10_000 });
|
||||
await expect(toast).toHaveClass(/toast-error/);
|
||||
|
||||
// …and the inline pane error still shows (both surfaces by design). It auto-clears on the
|
||||
|
||||
@@ -167,6 +167,18 @@ describe('flyPlane', () => {
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
});
|
||||
|
||||
it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => {
|
||||
const inst = await freshDb('fly-unpause.db');
|
||||
// The last landing pauses the sim at whatever scale was in use.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 });
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch auto-resumes…
|
||||
expect(sim.timeScale).toBe(250); // …with the stored speed preserved
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('fly-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
|
||||
@@ -125,6 +125,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
lon: origin.lon,
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside
|
||||
// runAction(), so tick + unpause + dispatch are atomic under the sim lock.
|
||||
await inst.db.update(schema.simState).set({ paused: false });
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||
import { haversineKm } from './geo';
|
||||
|
||||
const AIRPORTS = new Map<string, AirportCoord>([
|
||||
@@ -176,6 +176,23 @@ describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', ()
|
||||
});
|
||||
});
|
||||
|
||||
describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => {
|
||||
it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => {
|
||||
const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport
|
||||
const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
|
||||
const coords = fc.features[0].geometry.coordinates;
|
||||
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
|
||||
expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint
|
||||
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
|
||||
});
|
||||
|
||||
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
||||
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||
it('samples >2 points with the endpoints exactly at the airport coords', () => {
|
||||
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
|
||||
|
||||
@@ -112,6 +112,32 @@ export function hoverRouteGeoJson(
|
||||
};
|
||||
}
|
||||
|
||||
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
||||
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
||||
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
||||
// collection when the destination is missing from the dataset — never guessed.
|
||||
export function hoverTraceGeoJson(
|
||||
from: AirportCoord,
|
||||
toIcao: string,
|
||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||
): FeatureCollection<MapLineFeature> {
|
||||
const to = airportsByIcao.get(toIcao);
|
||||
if (!to) return EMPTY_LINES;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: greatCircleArc(from, to),
|
||||
},
|
||||
properties: { planeId: '' }, // no owning plane — a pure hover overlay
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
|
||||
// Grounded planes draw nothing; a flying plane missing either endpoint from the
|
||||
// dataset is skipped, never guessed. The line is sampled along the great circle
|
||||
|
||||
+17
-13
@@ -237,7 +237,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
|
||||
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
@@ -259,19 +259,20 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
|
||||
return { inst, from, km, type };
|
||||
}
|
||||
|
||||
it('last plane lands → stored timeScale becomes 1', async () => {
|
||||
const { inst } = await setupLanding('autoslow-last.db', 1000);
|
||||
it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => {
|
||||
const { inst } = await setupLanding('pause-last.db', 1000);
|
||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.timeScale).toBe(1); // persisted on the sim_state row…
|
||||
expect((await inst.db.select().from(schema.simState))[0].timeScale).toBe(1); // …and visible to the next snapshot
|
||||
expect(sim.paused).toBe(true); // persisted on the sim_state row…
|
||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(true); // …and visible to the next snapshot
|
||||
expect(sim.timeScale).toBe(1000); // speed preserved — no auto-slow anymore (ISS-041)
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
|
||||
});
|
||||
|
||||
it('a landing while another plane is still flying → no change', async () => {
|
||||
const { inst, from, type } = await setupLanding('autoslow-other.db', 1000);
|
||||
it('a landing while another plane is still flying → no pause', async () => {
|
||||
const { inst, from, type } = await setupLanding('pause-other.db', 1000);
|
||||
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
|
||||
@@ -281,32 +282,35 @@ describe('auto-slow to 1× when the last plane lands (ISS-011)', () => {
|
||||
vi.setSystemTime(T0 + 2_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.timeScale).toBe(1000); // p-b still in flight — no auto-slow
|
||||
expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it)
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
|
||||
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
|
||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle
|
||||
});
|
||||
|
||||
it('already at 1× → stays 1', async () => {
|
||||
const { inst } = await setupLanding('autoslow-one.db', 1);
|
||||
it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => {
|
||||
const { inst } = await setupLanding('pause-one.db', 1);
|
||||
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true);
|
||||
expect(sim.timeScale).toBe(1);
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
|
||||
it('paused sim with all planes landed → no change', async () => {
|
||||
const inst = await freshDb('autoslow-paused.db');
|
||||
const inst = await freshDb('pause-paused.db');
|
||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
||||
// scale even though "no plane in flight" holds: the trigger requires a landing this tick.
|
||||
// controls even though "no plane in flight" holds: the trigger requires a landing this tick.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1000, paused: true });
|
||||
|
||||
vi.setSystemTime(T0 + 5_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.timeScale).toBe(1000); // no landing processed while paused → trigger never fires
|
||||
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
+10
-9
@@ -74,8 +74,8 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
let landedThisTick = 0;
|
||||
if (advanceMs > 0) {
|
||||
await expirePackages(inst, clockMs);
|
||||
// ISS-011: remember the landings so the tick can auto-slow to 1× when this was
|
||||
// the last plane in flight.
|
||||
// ISS-041: remember the landings so the tick can pause when this was the last
|
||||
// plane in flight.
|
||||
landedThisTick = await deliverPackages(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
@@ -83,17 +83,18 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await spawnCoveragePackages(inst, clockMs);
|
||||
}
|
||||
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale' | 'paused'>> = {
|
||||
clockMs,
|
||||
lastRealTickMs: nowMs,
|
||||
};
|
||||
// Auto-slow (ISS-011): when ≥1 plane landed this tick and NO plane remains in flight,
|
||||
// drop the stored timeScale to 1× — persisted here so the UI slider reflects it on the
|
||||
// next snapshot. Arrival-triggered by design: a grounded fleet at high scale is left
|
||||
// alone (the player speeds up manually again; there is no auto-restore).
|
||||
if (landedThisTick > 0 && !sim.paused) {
|
||||
// Pause on landing (ISS-041): when ≥1 plane landed this tick and NO plane remains in flight,
|
||||
// pause the sim WITHOUT touching timeScale — speed is preserved for the next dispatch.
|
||||
// Arrival-triggered by design: a grounded fleet at high scale is left alone (there is no
|
||||
// auto-resume except starting a flight; see flyPlane). A paused tick never processes landings
|
||||
// (advanceMs === 0), so the trigger can only fire on an advancing tick.
|
||||
if (landedThisTick > 0) {
|
||||
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying'));
|
||||
if (flying.length === 0) sets.timeScale = MIN_TIME_SCALE;
|
||||
if (flying.length === 0) sets.paused = true;
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.22.1",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user