Compare commits

...
Author SHA1 Message Date
kamilkrzywda 1983d17099 push updated state to clients over SSE (ISS-129)
CI / build + unit + e2e (pull_request) Successful in 11m7s
2026-09-22 20:44:13 +00:00
kamilkrzywda 9b2938a5b0 Merge pull request 'v0.56.0 — idle fleet slows world clock to real-time instead of freezing (#128)' (#130) from feat/idle-slowdown into master
CI / build + unit + e2e (push) Failing after 10m12s
2026-09-22 20:48:17 +02:00
kamilkrzywda 0a40143497 idle fleet slows world clock to real time instead of freezing (ISS-128)
CI / build + unit + e2e (pull_request) Successful in 10m32s
2026-09-22 18:37:06 +00:00
kamilkrzywda 5268a39fe9 Merge pull request 'v0.55.0 — add favicon (#122)' (#126) from feat/favicon into master
CI / build + unit + e2e (push) Successful in 9m52s
2026-09-22 19:39:50 +02:00
kamilkrzywda ae91bf6a5d harden e2e getState against transient /api/state 500s (ISS-127)
CI / build + unit + e2e (pull_request) Successful in 9m55s
2026-09-22 17:29:45 +00:00
kamilkrzywda a8c4e93dc1 fix e2e: actions.spec.ts self-heal tops tank full after reposition (retry cascade after transient 500)
CI / build + unit + e2e (pull_request) Failing after 9m33s
2026-09-22 16:42:38 +00:00
kamilkrzywda e5693b3301 fix e2e: favicon href hash query + hover self-heal
CI / build + unit + e2e (pull_request) Failing after 10m50s
2026-09-22 16:03:21 +00:00
kamilkrzywda dae354410f Add favicon: white airplane on pastel blue as app/icon.svg (ISS-122)
CI / build + unit + e2e (pull_request) Failing after 12m56s
2026-09-22 15:39:04 +00:00
kamilkrzywda d53ad41c09 Merge pull request 'v0.54.0 — long priority jobs are now always finishable (#119)' (#125) from feat/expiry-floor into master
CI / build + unit + e2e (push) Failing after 8m47s
2026-09-22 17:31:51 +02:00
kamilkrzywda 82061ed869 Clamp generated package expiry to travel time at max catalog speed + buffer so long jobs are finishable (ISS-119)
CI / build + unit + e2e (pull_request) Successful in 9m43s
2026-09-22 15:21:50 +00:00
kamilkrzywda 2a4b341a9c Merge pull request 'v0.53.0 — antimeridian routes no longer chord across the map (#120)' (#123) from feat/antimeridian-route into master
CI / build + unit + e2e (push) Successful in 12m43s
2026-09-22 17:00:15 +02:00
kamilkrzywda cd2c766895 Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120)
CI / build + unit + e2e (pull_request) Successful in 10m58s
2026-09-22 14:48:53 +00:00
36 changed files with 973 additions and 149 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## v0.57.0 — 2026-09-22 19:19
- **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: <json>\n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional).
- **API.** OpenAPI spec gains `GET /api/events` (200 → `text/event-stream`) in `lib/openapi.ts`, so the machine-readable spec at `/api/openapi` stays accurate.
- **Tests.** Unit: new `lib/sse.test.ts` — subscribe/broadcast/unsubscribe semantics, a throwing subscriber doesn't break others, idempotent unsubscribe; new `app/api/events/route.test.ts` (throwaway-DB pattern from the state route test) — SSE headers + first chunk parses as a snapshot with `simState.clockMs`. E2E: new `e2e/events.spec.ts` (+1) — node-side fetch of `/api/events` against the app under test asserts content-type `text/event-stream`, the first event parses as a valid snapshot (`simState.clockMs`), and within ~5 s at least one MORE event arrives with an advanced clock (background tick pushes while idle since v0.56.0 — no scale hook); stream closed in `finally`. The exact-endpoint-list assertions in `lib/openapi.test.ts` and `e2e/api.spec.ts` move twelve → thirteen endpoints (`/api/events` added).
## v0.56.0 — 2026-09-22 18:28
- **Idle fleet slows the world to real time instead of freezing it (ISS-128).** A fully grounded fleet no longer freezes the sim clock — `tickOnce` in `lib/sim/engine.ts` now advances by `min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE` when nothing is flying or in repair, where new constant `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24` (= exactly 1, i.e. real time) lives next to `WORLD_TIME_SCALE` in `lib/worldClock.ts`. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and `lastRealTickMs` still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump.
- **Tests.** Unit: `lib/sim/engine.test.ts` — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1`. `lib/useSimClock.test.ts` — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: `e2e/sim.spec.ts`'s freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); `e2e/state.spec.ts`'s restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ `MAX_TICK_ELAPSED_MS` of clamped downtime + the live wall span; `e2e/right-pane.spec.ts`'s penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The `activateWorld`/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated.
## v0.55.0 — 2026-09-22 15:38
- **Favicon (ISS-122).** The app shipped with no favicon at all (`app/layout.tsx` set only title/description; no `app/icon.svg`, no `public/favicon.ico`). New hand-written single-file `app/icon.svg` — a bold white top-down airplane silhouette (fuselage + swept wings + tail, one straight-line path) on a pastel blue (#93c5fd) rounded square, 64×64 viewBox. Next App Router's file convention auto-serves it at `/icon.svg` and injects `<link rel="icon">` into the head; no layout/metadata changes needed.
- **Tests.** E2E +1 in `e2e/panes.spec.ts`: page load asserts exactly one `head link[rel~="icon"]` pointing at `/icon.svg`, and a direct `GET /icon.svg` returns 200 with content-type `image/svg+xml`. No unit test — static asset.
- **E2E hardening (CI run #167).** Two e2e-only fixes: the favicon assertion now tolerates the content-hash query string Next App Router production builds append to the auto-injected icon `<link href>` (`/icon.svg?icon.<hash>.svg` — matched with `/^\/icon\.svg/`, canonical `GET /icon.svg` check unchanged), and `e2e/hover.spec.ts`'s local `flyHomeViaApi` self-heal is replaced by the established `e2e/panes.spec.ts` pattern (set-health repair-gate reset + re-read state AFTER an in-flight leg lands) — the old version checked `locationIcao` against the stale pre-landing snapshot, so a retry after a mid-flight interruption left plane-starter grounded at the destination and its package load 409'd ("plane not at the origin").
- **E2E hardening (CI run #168).** Retry-cascade fix in `e2e/actions.spec.ts`: its local `flyHomeViaApi` self-heal now also tops plane-starter's tank back to full via the free `POST /api/test/set-fuel` hook after repositioning home. A transient `/api/state` 500 (issue #127) killed attempt 1 mid-flight; the retry's self-heal then flew the starter home with a low tank, so the test's own dispatch auto-refueled AFTER its cash baseline was captured and the exact-cash "no reward for expired cargo" assertion failed by €1190.56 — a full tank makes every subsequent dispatch top-up cost exactly €0.
- **E2E hardening (ISS-127).** e2e `getState` hardened against transient `/api/state` 500s — internal retry ×3/500 ms keeps poll callbacks total.
## v0.54.0 — 2026-09-22 15:20
- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up 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 `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.
## v0.53.0 — 2026-09-22 14:47
- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
## v0.52.0 — 2026-09-22 10:45 ## v0.52.0 — 2026-09-22 10:45
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server. - **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`. - **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
+41
View File
@@ -0,0 +1,41 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GET } from './route';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so
// this test never touches ./db/flight.db. getGameDb() reads the env var on first call, and
// vitest gives each test file its own module registry (fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
describe('GET /api/events', () => {
it('streams SSE with the current snapshot as the first event', async () => {
const res = await GET();
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache');
expect(res.headers.get('x-accel-buffering')).toBe('no');
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
try {
const { value } = await reader.read(); // first chunk: the initial snapshot event
const text = new TextDecoder().decode(value);
expect(text.startsWith('data: ')).toBe(true);
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
} finally {
await reader.cancel(); // → stream cancel → unsubscribe from the registry
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import { getStateSnapshot } from '@/lib/game/state';
import { subscribe } from '@/lib/sse';
// ISS-129 (v0.57.0): Server-Sent Events feed of full state snapshots — the current snapshot on
// connect, then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player
// actions stay REST POSTs. Heartbeats keep idle proxies from dropping the connection.
const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000;
export async function GET() {
let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
const cleanup = () => {
if (heartbeat !== null) clearInterval(heartbeat);
if (unsubscribe !== null) unsubscribe();
};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
// for the next push instead of seeing an out-of-order frame.
getStateSnapshot()
.then((snapshot) => {
unsubscribe = subscribe((text) => {
try {
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
controller.enqueue(encoder.encode(`data: ${text}\n\n`));
} catch {
// stream already closed — cleanup runs on cancel
}
});
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'));
} catch {
// same as above
}
}, HEARTBEAT_MS);
controller.enqueue(encoder.encode(`data: ${JSON.stringify(snapshot)}\n\n`));
})
.catch(() => {
cleanup();
try {
controller.close();
} catch {
// already closed
}
});
},
cancel() {
cleanup(); // client went away — drop the subscription + heartbeat
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // prod sits behind Traefik — don't buffer the stream
},
});
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#93c5fd"/>
<path d="M32 5 L36 20 L59 33 L36 38 L35 47 L48 56 L35 52 L32 59 L29 52 L16 56 L29 47 L28 38 L5 33 L28 20 Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

+19 -9
View File
@@ -50,9 +50,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.ok()).toBeTruthy(); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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);
}
} }
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live. // The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
@@ -93,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
} }
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock // A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its // repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards. // is why the expiry test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld
// restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> { async function activateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 50 }); await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
@@ -176,6 +182,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
} }
} }
// Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own
// dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so
// exact-cash assertions never see that cost.
await setFuelFull(page, 'plane-starter');
} }
@@ -334,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
} }
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs — // A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep // repair for the expiry window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k
// baseline below is captured AFTER deactivateWorld, not before it. // repair cost is why the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page); await activateWorld(page);
try { try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
+2 -1
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi'); const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json'); expect(res.headers()['content-type']).toContain('application/json');
@@ -23,6 +23,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly. // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
expect(Object.keys(spec.paths).sort()).toEqual([ expect(Object.keys(spec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
// ISS-129 (v0.57.0): /api/events pushes full state snapshots over SSE — the current snapshot on
// connect, then one after every sim tick (~1 s; the background tick runs even while idle since
// v0.56.0, so no scale hook is needed). Reached via the same base URL as playwright.config.ts
// (E2E_BASE_URL, defaulting to the live instance) — node-side fetch, no browser involved.
const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl';
interface StateBody {
simState: { clockMs: number };
}
test('GET /api/events streams state snapshots over SSE', async () => {
const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(res.body).not.toBeNull();
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const clocks: number[] = []; // simState.clockMs of each parsed snapshot event
try {
// Collect two snapshot events: the initial one, then at least one pushed by a background tick.
const deadline = Date.now() + 15_000;
while (clocks.length < 2 && Date.now() < deadline) {
let chunk: ReadableStreamReadResult<Uint8Array>;
try {
chunk = await Promise.race([
reader.read(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('SSE stream stalled')), Math.max(1, deadline - Date.now())),
),
]);
} catch {
break; // stalled or past the deadline — let the length assertion report it
}
if (chunk.done) break; // stream ended early — same
buffer += decoder.decode(chunk.value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of frame.split('\n')) {
if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping")
const snap = JSON.parse(line.slice('data: '.length)) as StateBody;
clocks.push(snap.simState.clockMs);
}
}
}
} finally {
await reader.cancel(); // close the stream → server unsubscribes
}
// The initial snapshot arrives on connect; the second comes from a background tick within ~1 s.
expect(clocks.length).toBeGreaterThanOrEqual(2);
expect(clocks[1]).toBeGreaterThan(clocks[0]); // clock advanced between pushes — real updates, not replays
});
+72
View File
@@ -0,0 +1,72 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's
// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas.
import { haversineKm } from '../lib/geo';
import { travelDurationMs } from '../lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog';
import { EXPIRY_BUFFER_MS } from '../lib/game/seed';
// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to
// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even
// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1)
// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on
// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or
// map queries are involved (the assertion is self-contained in the hook response), so none of those
// conventions apply here.
const FROM = 'EDDB'; // Berlin Brandenburg
const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM
const COUNT = 10; // priority packages created and checked
interface Pkg {
id: string;
originIcao: string;
destIcao: string;
createdSimTime: number;
deadlineSimTime: number;
}
async function createPriorityPackage(page: Page, from: string, to: string): Promise<Pkg> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } });
expect(res.status()).toBe(201);
return (await res.json()) as Pkg;
}
test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => {
// The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses.
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const from = fc.features.find((f) => f.properties.icao === FROM)?.properties;
const to = fc.features.find((f) => f.properties.icao === TO)?.properties;
expect(from, `${FROM} missing from the airport dataset`).toBeTruthy();
expect(to, `${TO} missing from the airport dataset`).toBeTruthy();
if (!from || !to) return; // guarded above — keeps the math below total
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous
const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
const created: Pkg[] = [];
try {
for (let i = 0; i < COUNT; i++) {
const pkg = await createPriorityPackage(page, FROM, TO);
expect(pkg.originIcao).toBe(FROM);
expect(pkg.destIcao).toBe(TO);
// The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation.
// deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly.
const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime;
expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs));
// And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane.
expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000);
created.push(pkg);
}
} finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
}
}
});
+82 -20
View File
@@ -48,9 +48,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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 — // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
@@ -84,33 +89,30 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API. // Self-heal (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an
// interrupted run, let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> { async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page); // v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
const starter = s0.planes.find((p) => p.id === 'plane-starter')!; // active repair gate from an interrupted run before acting on the starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let starter = s.planes.find((p) => p.id === 'plane-starter')!;
if (starter.status === 'flying' || starter.locationIcao !== HUB) { if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE); await setScale(page, SCALE);
} }
if (starter.status === 'flying') { if (starter.status === 'flying') {
await expect // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
timeout: 240_000, s = await getState(page);
message: 'waiting for an in-flight plane-starter to land', starter = s.planes.find((p) => p.id === 'plane-starter')!;
})
.toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards. // Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
.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);
} }
} }
@@ -171,6 +173,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
if (!f) throw new Error(`airport ${icao} not found in /api/airports`); if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number]; return [f.properties.lon, f.properties.lat] as [number, number];
}); });
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
for (let i = 1; i < points.length; i++) {
let lon = points[i]![0];
const prev = points[i - 1]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
points[i]![0] = lon;
}
await page.evaluate((pts: [number, number][]) => { await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap; const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready'); if (!m) throw new Error('map not ready');
@@ -217,8 +229,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> { async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]); const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
const near = (a: [number, number], b: [number, number]) => // ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; // (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
const near = (a: [number, number], b: [number, number]) => {
const dLon = Math.abs(a[0] - b[0]);
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
};
await expect.poll(async () => { await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter // Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
@@ -344,6 +361,51 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0); }).toBe(0);
}); });
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
await fitToAirports(page, ['NZAA', 'NFTF']);
await row.hover();
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
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; // none yet — keep polling
for (const s of segs) {
const c = s.geometry.coordinates;
for (let i = 1; i < c.length; i++) {
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
}
}
return true;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).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);
});
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => { test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
+8 -3
View File
@@ -42,9 +42,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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 — // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
+8 -3
View File
@@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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 — // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
+25 -7
View File
@@ -16,9 +16,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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. // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
@@ -34,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
} }
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock // A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative // repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
// cash assertions; deactivateWorld restores full health and no gate afterwards. // is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> { async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 }); await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -146,7 +151,7 @@ test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, I
// 409s on non-available planes — it must be grounded before activateWorld. // 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page); await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances. // A grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in repair for full scale.
await activateWorld(page); await activateWorld(page);
try { try {
await page.goto('/'); await page.goto('/');
@@ -192,3 +197,16 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
await expect(page.getByTestId('tab-planes-market')).toBeVisible(); await expect(page.getByTestId('tab-planes-market')).toBeVisible();
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true'); await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
}); });
test('favicon ships as /icon.svg (ISS-122)', async ({ page }) => {
await page.goto('/');
// App Router file convention: app/icon.svg is auto-served and injected into the head.
const link = page.locator('head link[rel~="icon"]');
await expect(link).toHaveCount(1);
// Production builds append a content-hash query string to the auto-injected icon href.
expect(await link.getAttribute('href')).toMatch(/^\/icon\.svg/);
const res = await page.request.get('/icon.svg');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('image/svg+xml');
});
+8 -3
View File
@@ -47,9 +47,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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. // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
+8 -3
View File
@@ -21,9 +21,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.ok()).toBeTruthy(); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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): top the starter's tank back to full — replaces the removed /refuel // Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
+26 -12
View File
@@ -70,9 +70,14 @@ interface StateBody {
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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 — // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
@@ -267,19 +272,28 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 }); await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server). // The abandon button previews the penalty at its snapshot's clock (same formula as the server).
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no // The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every // other planes), so the world runs at the idle real-time rate (ISS-128) — verify it actually did,
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop. // then bound the UI's staleness instead of racing a frozen clock.
const frozenClock = (await getState(page)).simState.clockMs; const c0 = (await getState(page)).simState.clockMs;
const t0 = Date.now();
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
expect(
(await getState(page)).simState.clockMs,
'world should be frozen while the fleet is idle (ISS-117)',
).toBe(frozenClock);
const state = await getState(page); const state = await getState(page);
const idleDelta = state.simState.clockMs - c0;
expect(idleDelta, 'world should advance at the idle real-time rate while grounded (ISS-128)').toBeGreaterThan(0);
expect(idleDelta).toBeLessThan((Date.now() - t0) * WORLD); // clearly below the full world rate
// The UI renders the penalty from its last snapshot — up to one poll interval (~1 s, useGameState)
// older than `state`, i.e. ≤ ~2 game-seconds of clock drift at the idle real-time rate (ISS-128).
// Allow the formula's slope over that window instead of an exact string match.
const DRIFT_MS = 3_000; // a couple of poll intervals at the idle rate (= 1), margin for slow CI boxes
const span = pkg.deadlineSimTime - pkg.createdSimTime;
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs); const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty)); const slopePerGameMs = (pkg.reward * 0.45) / span; // abandonPenaltyPct's SPAN across the package lifetime
expect(Math.abs(Number(penaltyText) - expectedPenalty)).toBeLessThanOrEqual(slopePerGameMs * DRIFT_MS + 0.01);
// Fly to the destination through the UI — the button is the destination's name. // Fly to the destination through the UI — the button is the destination's name.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
+34 -15
View File
@@ -1,13 +1,15 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded // v0.52.0 (ISS-117) fixed world clock against the live origin: a fully grounded fleet slowing to
// fleet, background-tick advance at a raised rate while something is active, package expiry via // real time instead of freezing (ISS-128), background-tick advance at a raised rate while
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up. // something is active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml),
// and market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts). // Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000; const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const IDLE = 1; // the idle real-time rate (ISS-128) — must match IDLE_WORLD_TIME_SCALE in lib/worldClock.ts (= WORLD / 24)
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000 const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts. // Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10; const MARKET_TARGET = 10;
@@ -23,9 +25,14 @@ interface StateBody {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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. // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
@@ -41,9 +48,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
} }
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock // A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative // repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair
// cash assertions; deactivateWorld restores full health and no gate afterwards. // cost is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health
// and no gate afterwards.
async function activateWorld(page: Page): Promise<void> { async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 }); await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -65,14 +73,25 @@ test.afterEach(async ({ page }) => {
await setScale(page, WORLD); await setScale(page, WORLD);
}); });
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => { test('a fully grounded fleet slows the clock to real time instead of freezing it (ISS-128)', async ({ page }) => {
const s = await getState(page); const s = await getState(page);
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true); expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const frozen = s.simState.clockMs;
for (let i = 0; i < 3; i++) { const t0 = Date.now();
await sleep(500); const c0 = s.simState.clockMs;
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen); await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
} const after = await getState(page);
const elapsedReal = Date.now() - t0;
const delta = after.simState.clockMs - c0;
expect(delta, 'the idle clock must advance (no longer frozen)').toBeGreaterThan(0);
// ≈ real time: each tick counts min(gap, MAX_TICK_ELAPSED_MS) × IDLE rate, so the total can only fall
// short of the wall span when a tick gap was clamped — worst case is one clamped idle-rate tick.
expect(delta).toBeGreaterThanOrEqual(Math.min(elapsedReal * IDLE * 0.5, MAX_TICK_ELAPSED_MS * IDLE));
// …and never more than the wall span itself (plus one clamp of pre-read downtime for slow boxes)…
expect(delta).toBeLessThanOrEqual(elapsedReal + MAX_TICK_ELAPSED_MS);
// …which is clearly below the full world rate: at ×24 this window would be ≥ ~60 game seconds.
expect(delta).toBeLessThan(elapsedReal * WORLD);
}); });
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => { test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
+17 -6
View File
@@ -8,6 +8,9 @@ import { test, expect } from '@playwright/test';
const repoRoot = path.join(__dirname, '..'); const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as e2e/sim.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
@@ -42,8 +45,9 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => { test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the // Self-heal to a fully grounded fleet first — while idle the clock advances at real time
// post-restart snapshot must match byte-for-byte instead of within one tick bound. // (ISS-128), so the post-restart snapshot is bounded by one clamped tick of downtime plus live
// ticks, not byte-for-byte identical.
await expect await expect
.poll(async () => { .poll(async () => {
const res = await page.request.get('/api/state'); const res = await page.request.get('/api/state');
@@ -56,6 +60,7 @@ test('state survives a container restart (sim clock + account persisted)', async
const resBefore = await page.request.get('/api/state'); const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200); expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody; const before = (await resBefore.json()) as StateBody;
const tBefore = Date.now();
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' }); execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
@@ -75,11 +80,17 @@ test('state survives a container restart (sim clock + account persisted)', async
expect(after).toBeDefined(); expect(after).toBeDefined();
const a = after!; const a = after!;
const tAfter = Date.now();
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
// persisted across sessions"): account and entity ids are byte-identical. With the fleet // sessions"): account and entity ids are byte-identical. With the fleet grounded above, ISS-128
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality. // advances the clock at real time while idle — bounded tolerance instead of exact equality: no
expect(a.simState.clockMs).toBe(before.simState.clockMs); // ticks run while the container is down, so the first post-restart tick counts the downtime
// clamped to MAX_TICK_ELAPSED_MS × IDLE rate (= 1), and every other counted gap lies inside the
// live wall span [tBefore, tAfter] at that same rate. A re-seed would reset clockMs backwards.
const delta = a.simState.clockMs - before.simState.clockMs;
expect(delta).toBeGreaterThanOrEqual(0); // never goes backwards — not re-seeded
expect(delta).toBeLessThanOrEqual(MAX_TICK_ELAPSED_MS + (tAfter - tBefore));
expect(a.account.cash).toBe(before.account.cash); expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation); expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
+8 -3
View File
@@ -18,9 +18,14 @@ interface StateBody {
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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('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 }) => { 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 }) => {
+8 -3
View File
@@ -67,9 +67,14 @@ interface StateBody {
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
expect(res.status()).toBe(200); // instantly fail any expect.poll callback, so retry briefly instead.
return (await res.json()) as StateBody; 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. // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
+6 -4
View File
@@ -211,12 +211,14 @@ export async function persist(instance: GameDb): Promise<void> {
await fs.rename(tmp, instance.file); await fs.rename(tmp, instance.file);
} }
// Singleton for the app process; openGameDb() is the uncached variant (tests). // Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0):
let ready: Promise<GameDb> | null = null; // lives on globalThis because Next.js can load this module more than once per process —
// instrumentation.ts and route handlers get separate module instances, so a plain `let` would give
// each its own in-memory sql.js DB (both persisting to the same file). One world per process.
const g = globalThis as unknown as { __flightGameDb?: Promise<GameDb> };
export function getGameDb(): Promise<GameDb> { export function getGameDb(): Promise<GameDb> {
if (!ready) ready = open(defaultDbPath()); return (g.__flightGameDb ??= open(defaultDbPath()));
return ready;
} }
export async function openGameDb(file: string): Promise<GameDb> { export async function openGameDb(file: string): Promise<GameDb> {
+5
View File
@@ -97,6 +97,11 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
}, },
}; };
// v0.54.0 (ISS-119): fastest cruise speed in the catalog — a generated job must be finishable by
// the best available aircraft, so seed's expiry floor references this, not the starter's 510 km/h
// (that would wreck long-job economics). Data-driven: adding a faster plane tightens the floor.
export const FASTEST_CATALOG_SPEED_KMH = Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh));
// The plane the player starts with. // The plane the player starts with.
export const STARTER_MODEL = 'atr72'; export const STARTER_MODEL = 'atr72';
+84 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { travelDurationMs } from '@/lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials'; import { SPECIALS } from './specials';
import { import {
BASE_REWARD, BASE_REWARD,
buildSeed, buildSeed,
EXPIRY_BUFFER_MS,
JOB_BONUS_PER_REP, JOB_BONUS_PER_REP,
MAX_JOB_BONUS, MAX_JOB_BONUS,
PACKAGE_COUNT_MAX, PACKAGE_COUNT_MAX,
@@ -437,3 +439,84 @@ describe('distance cap (v0.44.0, ISS-079)', () => {
} }
}); });
}); });
describe('expiry floor (v0.54.0, ISS-119)', () => {
// Pre-v0.54.0 deadline for a forced-endpoint + forced-special package: replays the fixed draw order
// (cargoType, cargoCount, weight jitter, window — forced inputs consume no draws) so short-leg
// packages can be pinned byte-identical to the old behavior.
const oldDeadlineFor = (seed: number, scale: number): number => {
const rng = mulberry32(seed);
rng(); // cargoType
rng(); // cargoCount
rng(); // weight jitter
const w = rng(); // deadline window
return Math.round(NOW + (6 + w * (72 - 6)) * 3_600_000 * scale);
};
it('pins the constants: fastest catalog speed (not the starter) and a flat 1 h buffer', () => {
expect(FASTEST_CATALOG_SPEED_KMH).toBe(Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh)));
expect(FASTEST_CATALOG_SPEED_KMH).toBeGreaterThan(PLANE_TYPES[STARTER_MODEL].speedKmh);
expect(EXPIRY_BUFFER_MS).toBe(3_600_000);
});
it('every generated package on the real dataset satisfies deadline ≥ now + travel time at max speed + buffer', async () => {
const { loadAirportsGeoJson } = await import('@/lib/airports');
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
for (let seed = 1; seed <= 200; seed++) {
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;
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
}
});
it('long legs near MAX_JOB_LEG_KM are clamped up to the floor for every special (real dataset)', async () => {
const { loadAirportsGeoJson } = await import('@/lib/airports');
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
// Farthest pair within the cap — a leg as long as any generated job can be.
let bestI = -1;
let bestJ = -1;
let bestKm = 0;
for (let i = 0; i < airports.length; i++) {
for (let j = i + 1; j < airports.length; j++) {
const km = haversineKm(airports[i].lat, airports[i].lon, airports[j].lat, airports[j].lon);
if (km <= MAX_JOB_LEG_KM && km > bestKm) {
bestI = i;
bestJ = j;
bestKm = km;
}
}
}
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
for (const special of ['priority', null] as const) {
let clamped = 0;
for (let seed = 1; seed <= 50; seed++) {
const c = generatePackage({ id: `c-long-${special ?? 'plain'}-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW, originIcao: airports[bestI].icao, destIcao: airports[bestJ].icao, special });
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); // the invariant
if (c.deadlineSimTime === Math.round(NOW + floorMs)) clamped++; // landed exactly on the floor → the clamp fired
}
// Deterministic for these fixed seeds — proves the clamp actually engages, not just vacuously holds.
expect(clamped).toBeGreaterThan(0);
}
});
it('short legs are byte-identical to the pre-v0.54.0 draw (no economy regression)', () => {
// EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h — below even the priority minimum window (3 h), so the clamp never fires here.
for (const seed of [1, 2, 3, 4, 5]) {
const plain = generatePackage({ id: 'c-short', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null });
expect(plain.deadlineSimTime).toBe(oldDeadlineFor(seed, 1));
const prio = generatePackage({ id: 'c-short-p', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: 'priority' });
expect(prio.deadlineSimTime).toBe(oldDeadlineFor(seed, SPECIALS.priority.deadlineScale));
}
});
it('forced deadline offsets still skip the floor (e2e expiry hook)', () => {
const c = generatePackage({ id: 'c-floor-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // the hook's near-term expiry is untouched by the floor
});
});
+18 -3
View File
@@ -1,6 +1,7 @@
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { travelDurationMs } from '@/lib/sim/movement';
import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials'; import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs) // First-run seed. Everything here is a pure function of (airports, rng, nowMs)
@@ -123,6 +124,10 @@ const LEG_CAP_MAX_ATTEMPTS = 25;
const DEADLINE_MIN_HOURS = 6; const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72; const DEADLINE_MAX_HOURS = 72;
// v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at
// least this much after the fastest plane lands, so dispatch + landing are never instant.
export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const; const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo // ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
@@ -157,6 +162,9 @@ export function rewardFor(km: number, weightKg: number): number {
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot // v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the // as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
// starting value so seed-time behavior is byte-identical. // starting value so seed-time behavior is byte-identical.
// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at
// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is
// finishable by the fastest catalog plane; short legs are byte-identical to before.
export function generatePackage(input: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -230,8 +238,15 @@ export function generatePackage(input: {
// forced value (including explicit null) skips it, same pattern as the other overrides. // forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng); const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined; const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws. // v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane:
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1); // deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a
// 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;
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. // v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS); const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
+76
View File
@@ -8,8 +8,26 @@ const AIRPORTS = new Map<string, AirportCoord>([
['EDDV', { lat: 52.46, lon: 9.68 }], ['EDDV', { lat: 52.46, lon: 9.68 }],
['EHAM', { lat: 52.31, lon: 4.76 }], ['EHAM', { lat: 52.31, lon: 4.76 }],
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test ['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
]); ]);
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
const modDiff = (a: number, b: number): number => {
const d = Math.abs(a - b) % 360;
return Math.min(d, 360 - d);
};
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
// keeps MapLibre from drawing a chord across the map.
function expectContinuousLons(coords: [number, number][]): void {
for (let i = 1; i < coords.length; i++) {
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
}
}
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot { function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
return { return {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
@@ -320,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
}); });
}); });
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
const arc = greatCircleArc(from, to);
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
let sum = 0;
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
});
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
const arc = greatCircleArc(to, from);
expectContinuousLons(arc);
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
});
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
expectContinuousLons(arc);
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
});
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
const coords = fc.features[0]!.geometry.coordinates;
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
const last = coords[coords.length - 1]!;
const nzch = AIRPORTS.get('NZCH')!;
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
});
});
+24 -3
View File
@@ -74,16 +74,34 @@ export interface AirportCoord {
// curved path the plane actually flies (same slerp as lib/sim/movement.ts). // curved path the plane actually flies (same slerp as lib/sim/movement.ts).
export const ROUTE_ARC_SEGMENTS = 24; export const ROUTE_ARC_SEGMENTS = 24;
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
function unwrapLons(coords: [number, number][]): [number, number][] {
let prev = coords[0]![0];
for (let i = 1; i < coords.length; i++) {
let lon = coords[i]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
coords[i]![0] = lon;
prev = lon;
}
return coords;
}
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at // Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport // t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
// coords (positionAt returns them verbatim at t=0 and t=1). // ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] { export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
const coords: [number, number][] = []; const coords: [number, number][] = [];
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) { for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
coords.push([pos.lon, pos.lat]); coords.push([pos.lon, pos.lat]);
} }
return coords; return unwrapLons(coords);
} }
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two // ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
@@ -195,6 +213,9 @@ export function queuedRoutesGeoJson(
const arc = greatCircleArc(pts[i]!, pts[i + 1]!); const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
} }
// ISS-120: each leg's arc is unwrapped relative to its OWN start airport, so at a joint the
// sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) — re-unify.
unwrapLons(coords);
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { type: 'LineString', coordinates: coords }, geometry: { type: 'LineString', coordinates: coords },
+2 -1
View File
@@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => { it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/market/buy-plane', '/api/market/buy-plane',
'/api/market/sell-plane', '/api/market/sell-plane',
'/api/packages/{id}/abandon', '/api/packages/{id}/abandon',
+16
View File
@@ -51,6 +51,22 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/events': {
get: {
summary: 'Live state stream (SSE)',
description:
'Server-Sent Events feed of full game-state snapshots (v0.57.0, ISS-129) — the current snapshot is sent immediately on connect and a fresh one after every sim tick (~1 s), so clients can subscribe instead of polling /api/state. Each event\'s data payload is JSON with exactly the StateSnapshot shape; comment lines starting with ":" are heartbeats.',
operationId: 'subscribeToEvents',
responses: {
200: {
description: 'SSE stream of state snapshots (text/event-stream)',
content: {
'text/event-stream': { schema: { type: 'string' } },
},
},
},
},
},
'/api/version': { '/api/version': {
get: { get: {
summary: 'App version', summary: 'App version',
+28 -11
View File
@@ -16,6 +16,7 @@ import {
deliverPackages, deliverPackages,
expirePackages, expirePackages,
finishRepairs, finishRepairs,
IDLE_WORLD_TIME_SCALE,
MAX_TICK_ELAPSED_MS, MAX_TICK_ELAPSED_MS,
reputationGainFor, reputationGainFor,
shouldAdvanceTime, shouldAdvanceTime,
@@ -30,8 +31,9 @@ import {
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite // public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
// (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation). // (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put // ISS-117 / ISS-128: fresh DBs seed a GROUNDED starter, so any test that needs FULL-scale advance
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away. // must put a plane in flight first (a grounded fleet only advances at the idle real-time rate) —
// departureSimTime at the game-clock origin keeps its arrival hours away.
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) { async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS }) .set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
@@ -60,7 +62,7 @@ async function freshDb(name: string) {
describe('tickOnce — clock advance', () => { describe('tickOnce — clock advance', () => {
it('advances the clock by elapsed real ms × timeScale', async () => { it('advances the clock by elapsed real ms × timeScale', async () => {
const inst = await freshDb('scale.db'); const inst = await freshDb('scale.db');
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117) await makeWorldActive(inst); // a grounded fleet would slow the world to the idle real-time rate (ISS-128)
vi.setSystemTime(T0 + 1_000); // one real second later vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 }); await inst.db.update(schema.simState).set({ timeScale: 5 });
@@ -70,12 +72,12 @@ describe('tickOnce — clock advance', () => {
expect(sim.lastRealTickMs).toBe(T0 + 1_000); expect(sim.lastRealTickMs).toBe(T0 + 1_000);
}); });
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => { it('a fully grounded fleet advances at the idle real-time rate, not frozen (ISS-128)', async () => {
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117) const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
let sim = await tickOnce(inst); let sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE); // clamped to one idle-rate tick — no fast-forward, no freeze
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
// A plane takes off: resumes from where it stopped, with NO catch-up for the idle time. // A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
@@ -83,7 +85,17 @@ describe('tickOnce — clock advance', () => {
await makeWorldActive(inst); await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_500); vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst); sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60 expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE + 500 * 60); // only the 500 ms since activity × 60
});
it('a grounded fleet ignores any raised timeScale — the idle rate wins (ISS-128)', async () => {
const inst = await freshDb('idle-scale.db');
await inst.db.update(schema.simState).set({ timeScale: 1_000 }); // FLIGHT_TEST_HOOKS-style override
vi.setSystemTime(T0 + 1_000);
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * IDLE_WORLD_TIME_SCALE); // real time, NOT ×1000
}); });
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => { it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
@@ -574,7 +586,12 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
}); });
}); });
describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => { describe('idle slowdown — a grounded fleet slows the world to real time (ISS-128)', () => {
it('pins IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24, i.e. exactly real time', () => {
expect(IDLE_WORLD_TIME_SCALE).toBe(WORLD_TIME_SCALE / 24);
expect(IDLE_WORLD_TIME_SCALE).toBe(1); // one game-second per real second
});
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => { it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 }); expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 }); expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
@@ -583,14 +600,14 @@ describe('idle-pause — the world freezes when nothing is happening (ISS-117)',
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 }); expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
}); });
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => { it('a fully grounded fleet advances at the idle rate but keeps tracking real time', async () => {
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
vi.setSystemTime(T0 + 3_600_000); vi.setSystemTime(T0 + 3_600_000);
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * IDLE_WORLD_TIME_SCALE); // real-time rate, clamped — not frozen
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …and the real-time anchor moved on
}); });
it('a repairing plane keeps the world advancing', async () => { it('a repairing plane keeps the world advancing', async () => {
+43 -17
View File
@@ -7,7 +7,9 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state'; import { getStateSnapshot, parseIcaoQueue } from '@/lib/game/state';
import { broadcast } from '@/lib/sse';
import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock';
import { import {
generatePackage, generatePackage,
MARKET_TARGET_OPEN_PACKAGES, MARKET_TARGET_OPEN_PACKAGES,
@@ -22,7 +24,8 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the // tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
// same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped // same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
// per tick so long absences never fast-forward the game; while no plane is flying or in repair the // per tick so long absences never fast-forward the game; while no plane is flying or in repair the
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time. // world slows to real time instead of freezing (IDLE_WORLD_TIME_SCALE, ISS-128 v0.56.0), and
// lastRealTickMs always keeps tracking real time.
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above // Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
// both the client poll interval (~1 s) and the background-tick interval, so active play advances // both the client poll interval (~1 s) and the background-tick interval, so active play advances
@@ -30,8 +33,9 @@ import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from
// 2 s × scale. // 2 s × scale.
export const MAX_TICK_ELAPSED_MS = 2_000; export const MAX_TICK_ELAPSED_MS = 2_000;
// ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home. // ISS-117 / ISS-128: the fixed world rates — re-exported here so server code and tests have one
export { WORLD_TIME_SCALE } from '@/lib/worldClock'; // familiar home (full rate while something is active, real-time rate while fully grounded).
export { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from '@/lib/worldClock';
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded // Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a // to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
@@ -43,13 +47,15 @@ export function reputationGainFor(km: number): number {
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
} }
// Serialize ticks across concurrent requests (Next.js route handlers run in the // Serialize ticks across concurrent requests (Next.js route handlers run in the same process): each
// same process): each tick re-reads sim_state after the previous one wrote it, so // tick re-reads sim_state after the previous one wrote it, so no advance is ever applied twice.
// no advance is ever applied twice. // ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get
let chain: Promise<unknown> = Promise.resolve(); // separate module instances from Next.js, so a plain `let` would give each layer its own lock and let
// their ticks interleave against the shared DB below.
const g = globalThis as unknown as { __flightTickChain?: Promise<unknown> };
function withLock<T>(fn: () => Promise<T>): Promise<T> { function withLock<T>(fn: () => Promise<T>): Promise<T> {
const run = chain.then(fn); const run = (g.__flightTickChain ??= Promise.resolve()).then(fn);
chain = run.then( g.__flightTickChain = run.then(
() => undefined, () => undefined,
() => undefined, () => undefined,
); );
@@ -58,9 +64,10 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) --- // --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock // ISS-117 / ISS-128: idle decision — pure, so unit tests feed it synthetic fleets. The world clock
// advances only while something is happening: at least one plane flying OR in repair. A fully // runs at full scale only while something is happening (at least one plane flying OR in repair); a
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts. // fully grounded fleet slows it to IDLE_WORLD_TIME_SCALE (real time) instead of freezing it, so
// expiries/spawns keep running until a dispatch or repair starts.
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } { export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0; let flying = 0;
let repairing = 0; let repairing = 0;
@@ -77,12 +84,15 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now(); const nowMs = Date.now();
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still // ISS-128: a fully grounded fleet slows the world to real time (IDLE_WORLD_TIME_SCALE) instead of
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration. // freezing it — packages keep spawning/expiring while idle. An active fleet keeps its full scale,
// including any FLIGHT_TEST_HOOKS override in sim.timeScale. lastRealTickMs always tracks real
// time, so resuming activity never fast-forwards the idle duration.
const planeRows = await inst.db.select().from(schema.planes); const planeRows = await inst.db.select().from(schema.planes);
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status }))); const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0; const scale = advance ? sim.timeScale : IDLE_WORLD_TIME_SCALE;
const advanceMs = Math.min(elapsed, MAX_TICK_ELAPSED_MS) * scale;
const clockMs = Math.round(sim.clockMs + advanceMs); const clockMs = Math.round(sim.clockMs + advanceMs);
if (advanceMs > 0) { if (advanceMs > 0) {
@@ -409,8 +419,24 @@ export async function spawnCoveragePackages(
// --- Public API (singleton DB + lock) — call from every read/action endpoint --- // --- Public API (singleton DB + lock) — call from every read/action endpoint ---
// ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE
// subscribers (/api/events) — the exact same body GET /api/state returns (getStateSnapshot), so
// pushed and polled states can never diverge in shape. Failures are logged, not fatal: polling
// still works either way. Background ticks (~1 s) cover action mutations for other clients too.
async function broadcastState(): Promise<void> {
try {
broadcast(JSON.stringify(await getStateSnapshot()));
} catch (err) {
console.error('flight: state broadcast failed', err);
}
}
export function tickNow(): Promise<schema.SimStateRow> { export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => tickOnce(await getGameDb())); return withLock(async () => {
const row = await tickOnce(await getGameDb());
await broadcastState();
return row;
});
} }
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked // ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { broadcast, subscribe } from './sse';
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
// each test only asserts on its own arrays.
describe('lib/sse', () => {
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
const a: string[] = [];
const b: string[] = [];
const unsubA = subscribe((t) => a.push(t));
const unsubB = subscribe((t) => b.push(t));
broadcast('x');
expect(a).toEqual(['x']);
expect(b).toEqual(['x']);
unsubA();
broadcast('y');
expect(a).toEqual(['x']); // unsubscribed — no more events
expect(b).toEqual(['x', 'y']);
unsubB();
});
it('a throwing subscriber does not break the others', () => {
const got: string[] = [];
subscribe(() => {
throw new Error('boom');
});
const unsub = subscribe((t) => got.push(t));
broadcast('z');
expect(got).toEqual(['z']);
unsub();
});
it('unsubscribe is idempotent', () => {
const got: string[] = [];
const unsub = subscribe((t) => got.push(t));
unsub();
unsub(); // second call must not throw
broadcast('w');
expect(got).toEqual([]);
});
});
+29
View File
@@ -0,0 +1,29 @@
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
// give each its own empty set and no push would ever be delivered.
type Push = (text: string) => void;
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
export function subscribe(fn: Push): () => void {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
}
// Call each subscriber safely — one dead stream must not take the others down.
export function broadcast(text: string): void {
for (const fn of [...subscribers]) {
try {
fn(text);
} catch {
// subscriber's stream is gone; its own cleanup unsubscribes it
}
}
}
+55 -6
View File
@@ -35,8 +35,14 @@ export async function fetchState(): Promise<StateSnapshot> {
return snapshot; return snapshot;
} }
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page, // The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane
// so every pane renders from the same snapshot instead of running its own poll loops. // renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary
// transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an
// automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh
// events flow. EventSource reconnects natively, so there is no custom reconnect logic here.
const SSE_URL = '/api/events';
const SSE_SILENCE_MS = 5_000;
export function useGameState(intervalMs = 1000): GameStateResult { export function useGameState(intervalMs = 1000): GameStateResult {
const [state, setState] = useState<StateSnapshot | null>(null); const [state, setState] = useState<StateSnapshot | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -70,10 +76,53 @@ export function useGameState(intervalMs = 1000): GameStateResult {
}, []); }, []);
useEffect(() => { useEffect(() => {
refresh(); // fetch immediately on mount, don't wait for the first interval tick let pollId: ReturnType<typeof setInterval> | null = null;
const id = setInterval(refresh, intervalMs); let silenceTimer: ReturnType<typeof setTimeout> | null = null;
return () => clearInterval(id); let closed = false;
}, [intervalMs, refresh]);
const stopPolling = () => {
if (pollId !== null) clearInterval(pollId);
pollId = null;
};
// Fallback loop: immediate catch-up fetch, then the old interval.
const startPolling = () => {
if (closed || pollId !== null) return;
refresh();
pollId = setInterval(refresh, intervalMs);
};
let source: EventSource | null = null;
try {
source = new EventSource(SSE_URL);
} catch {
source = null; // no EventSource support — pure polling below
}
if (source !== null) {
source.onmessage = (event) => {
try {
applySnapshot(JSON.parse(event.data));
} catch {
// malformed event — the next one overwrites it anyway
}
stopPolling(); // fresh push flow → polling no longer needed
if (silenceTimer !== null) clearTimeout(silenceTimer); // connect check served its purpose
};
source.onerror = () => startPolling(); // stream trouble → poll until pushes resume
silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll
} else {
startPolling();
}
refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot)
return () => {
closed = true;
source?.close();
stopPolling();
if (silenceTimer !== null) clearTimeout(silenceTimer);
};
}, [intervalMs, refresh, applySnapshot]);
return { state, loading, error, refresh, applySnapshot }; return { state, loading, error, refresh, applySnapshot };
} }
+6 -5
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock'; import { advanceSimClock, type SimClockAnchor } from './useSimClock';
import { WORLD_TIME_SCALE } from './worldClock'; import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a // ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls"); // thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle. // this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, and slows to
// real-time × IDLE_WORLD_TIME_SCALE while idle (ISS-128).
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({ const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
clockMs: 1_750_000_000_000, clockMs: 1_750_000_000_000,
@@ -24,10 +25,10 @@ describe('advanceSimClock', () => {
expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000); expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
}); });
it('is frozen while idle regardless of elapsed time', () => { it('advances at the idle real-time rate (IDLE_WORLD_TIME_SCALE) while grounded, not frozen (ISS-128)', () => {
const a = anchor({ advancing: false }); const a = anchor({ advancing: false });
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — still the anchor value
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen expect(advanceSimClock(a, 1_001_000)).toBe(1_750_000_000_000 + 1_000 * IDLE_WORLD_TIME_SCALE); // a full second later — real time, not frozen
}); });
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => { it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
+10 -9
View File
@@ -2,15 +2,16 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state'; import type { StateSnapshot } from './game/state';
import { WORLD_TIME_SCALE } from './worldClock'; import { IDLE_WORLD_TIME_SCALE, WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of // ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale // jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so // (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to // client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the // server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the // fixed WORLD_TIME_SCALE; ISS-128 (v0.56.0): while nothing is happening it slows to
// server's idle-pause instead of a player-controlled pause. // IDLE_WORLD_TIME_SCALE (real time) instead of freezing — mirroring the server's idle slowdown,
// so polls re-anchor without a visible jump.
export interface SimClockAnchor { export interface SimClockAnchor {
// Server truth at anchor time — the snapshot's simState.clockMs. // Server truth at anchor time — the snapshot's simState.clockMs.
@@ -22,11 +23,11 @@ export interface SimClockAnchor {
} }
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by // Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to // real-time × WORLD_TIME_SCALE; while idle it slows to real-time × IDLE_WORLD_TIME_SCALE (ISS-128).
// the anchor value instead of going backwards. Unit-testable in node. // nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number { export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
if (!anchor.advancing) return anchor.clockMs; const scale = anchor.advancing ? WORLD_TIME_SCALE : IDLE_WORLD_TIME_SCALE;
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE; return anchor.clockMs + Math.max(0, nowMs - anchor.at) * scale;
} }
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders. // ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
@@ -41,8 +42,8 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
// (useGameState), so this runs once per poll cycle as well. // (useGameState), so this runs once per poll cycle as well.
useEffect(() => { useEffect(() => {
if (!snapshot) return; if (!snapshot) return;
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is // Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
// flying or in repair. One line on purpose — keep it in sync with that function. // is flying or in repair, idle real-time otherwise. One line on purpose — keep it in sync.
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing'); const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing }; anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
setDisplayMs(advanceSimClock(anchorRef.current, Date.now())); setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
+5
View File
@@ -2,3 +2,8 @@
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side // Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here. // lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
export const WORLD_TIME_SCALE = 24; export const WORLD_TIME_SCALE = 24;
// ISS-128 (v0.56.0): a fully grounded fleet no longer freezes the world — it slows to real time
// (one game-second per real second) so packages keep spawning/expiring and the market stays fresh.
// Derived from the existing constant: WORLD_TIME_SCALE / 24 = 1, i.e. exactly "real-time".
export const IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.52.0", "version": "0.57.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",