Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1983d17099 | ||
|
|
9b2938a5b0 | ||
|
|
0a40143497 | ||
|
|
5268a39fe9 | ||
|
|
ae91bf6a5d | ||
|
|
a8c4e93dc1 | ||
|
|
e5693b3301 | ||
|
|
dae354410f |
@@ -1,5 +1,21 @@
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -50,9 +50,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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.
|
||||
@@ -93,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
|
||||
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
|
||||
// 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> {
|
||||
await setHealth(page, 'plane-starter', { health: 50 });
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
|
||||
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
|
||||
// baseline below is captured AFTER deactivateWorld, not before it.
|
||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
||||
// repair for the expiry window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k
|
||||
// repair cost is why the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
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');
|
||||
expect(res.status()).toBe(200);
|
||||
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.
|
||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
+20
-18
@@ -48,9 +48,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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 —
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const s0 = await getState(page);
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||
// active repair gate from an interrupted run before 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) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||||
s = await getState(page);
|
||||
starter = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||
await setScale(page, SCALE);
|
||||
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)
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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 —
|
||||
|
||||
@@ -60,9 +60,14 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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 —
|
||||
|
||||
+25
-7
@@ -16,9 +16,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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.
|
||||
@@ -34,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair 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> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
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.
|
||||
await flyHomeViaApi(page);
|
||||
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);
|
||||
try {
|
||||
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-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
@@ -47,9 +47,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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.
|
||||
|
||||
@@ -21,9 +21,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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
|
||||
|
||||
+26
-12
@@ -70,9 +70,14 @@ interface StateBody {
|
||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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 —
|
||||
@@ -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}`);
|
||||
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
|
||||
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
|
||||
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
|
||||
const frozenClock = (await getState(page)).simState.clockMs;
|
||||
// other planes), so the world runs at the idle real-time rate (ISS-128) — verify it actually did,
|
||||
// then bound the UI's staleness instead of racing a frozen clock.
|
||||
const c0 = (await getState(page)).simState.clockMs;
|
||||
const t0 = Date.now();
|
||||
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 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);
|
||||
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.
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
|
||||
+34
-15
@@ -1,13 +1,15 @@
|
||||
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
|
||||
// fleet, background-tick advance at a raised rate while something is active, package expiry via
|
||||
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
|
||||
// v0.52.0 (ISS-117) fixed world clock against the live origin: a fully grounded fleet slowing to
|
||||
// real time instead of freezing (ISS-128), background-tick advance at a raised rate while
|
||||
// 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).
|
||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
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
|
||||
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
||||
const MARKET_TARGET = 10;
|
||||
@@ -23,9 +25,14 @@ interface StateBody {
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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.
|
||||
@@ -41,9 +48,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
|
||||
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
|
||||
// cash assertions; deactivateWorld restores full health and no gate afterwards.
|
||||
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
|
||||
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair
|
||||
// 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> {
|
||||
await setHealth(page, STARTER, { health: 50 });
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
|
||||
@@ -65,14 +73,25 @@ test.afterEach(async ({ page }) => {
|
||||
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);
|
||||
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++) {
|
||||
await sleep(500);
|
||||
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const c0 = s.simState.clockMs;
|
||||
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 }) => {
|
||||
|
||||
+17
-6
@@ -8,6 +8,9 @@ import { test, expect } from '@playwright/test';
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
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 {
|
||||
account: { cash: number; reputation: number };
|
||||
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.setTimeout(180_000);
|
||||
|
||||
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
|
||||
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
||||
// Self-heal to a fully grounded fleet first — while idle the clock advances at real time
|
||||
// (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
|
||||
.poll(async () => {
|
||||
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');
|
||||
expect(resBefore.status()).toBe(200);
|
||||
const before = (await resBefore.json()) as StateBody;
|
||||
const tBefore = Date.now();
|
||||
|
||||
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();
|
||||
const a = after!;
|
||||
const tAfter = Date.now();
|
||||
|
||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
|
||||
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
|
||||
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
||||
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
||||
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
|
||||
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, ISS-128
|
||||
// advances the clock at real time while idle — bounded tolerance instead of exact equality: no
|
||||
// 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.reputation).toBe(before.account.reputation);
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
|
||||
+8
-3
@@ -18,9 +18,14 @@ interface StateBody {
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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 }) => {
|
||||
|
||||
@@ -67,9 +67,14 @@ interface StateBody {
|
||||
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as StateBody;
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||||
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.
|
||||
|
||||
+6
-4
@@ -211,12 +211,14 @@ export async function persist(instance: GameDb): Promise<void> {
|
||||
await fs.rename(tmp, instance.file);
|
||||
}
|
||||
|
||||
// Singleton for the app process; openGameDb() is the uncached variant (tests).
|
||||
let ready: Promise<GameDb> | null = null;
|
||||
// Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0):
|
||||
// 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> {
|
||||
if (!ready) ready = open(defaultDbPath());
|
||||
return ready;
|
||||
return (g.__flightGameDb ??= open(defaultDbPath()));
|
||||
}
|
||||
|
||||
export async function openGameDb(file: string): Promise<GameDb> {
|
||||
|
||||
+2
-1
@@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
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([
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
|
||||
@@ -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': {
|
||||
get: {
|
||||
summary: 'App version',
|
||||
|
||||
+28
-11
@@ -16,6 +16,7 @@ import {
|
||||
deliverPackages,
|
||||
expirePackages,
|
||||
finishRepairs,
|
||||
IDLE_WORLD_TIME_SCALE,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
reputationGainFor,
|
||||
shouldAdvanceTime,
|
||||
@@ -30,8 +31,9 @@ import {
|
||||
// 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).
|
||||
|
||||
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
|
||||
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
|
||||
// ISS-117 / ISS-128: fresh DBs seed a GROUNDED starter, so any test that needs FULL-scale advance
|
||||
// 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>>) {
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
||||
@@ -60,7 +62,7 @@ async function freshDb(name: string) {
|
||||
describe('tickOnce — clock advance', () => {
|
||||
it('advances the clock by elapsed real ms × timeScale', async () => {
|
||||
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
|
||||
await inst.db.update(schema.simState).set({ timeScale: 5 });
|
||||
|
||||
@@ -70,12 +72,12 @@ describe('tickOnce — clock advance', () => {
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||
});
|
||||
|
||||
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
|
||||
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
|
||||
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
|
||||
|
||||
vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
|
||||
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);
|
||||
|
||||
// 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);
|
||||
vi.setSystemTime(T0 + 3_600_500);
|
||||
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 () => {
|
||||
@@ -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', () => {
|
||||
expect(shouldAdvanceTime([])).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 });
|
||||
});
|
||||
|
||||
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
|
||||
vi.setSystemTime(T0 + 3_600_000);
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
|
||||
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); // …and the real-time anchor moved on
|
||||
});
|
||||
|
||||
it('a repairing plane keeps the world advancing', async () => {
|
||||
|
||||
+43
-17
@@ -7,7 +7,9 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
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 { 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 {
|
||||
generatePackage,
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
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.
|
||||
export { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
// ISS-117 / ISS-128: the fixed world rates — re-exported here so server code and tests have one
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the
|
||||
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
||||
// no advance is ever applied twice.
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
// Serialize ticks across concurrent requests (Next.js route handlers run in the same process): each
|
||||
// tick re-reads sim_state after the previous one wrote it, so no advance is ever applied twice.
|
||||
// ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get
|
||||
// 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> {
|
||||
const run = chain.then(fn);
|
||||
chain = run.then(
|
||||
const run = (g.__flightTickChain ??= Promise.resolve()).then(fn);
|
||||
g.__flightTickChain = run.then(
|
||||
() => 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) ---
|
||||
|
||||
// ISS-117: idle-pause 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
|
||||
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
|
||||
// ISS-117 / ISS-128: idle decision — pure, so unit tests feed it synthetic fleets. The world clock
|
||||
// runs at full scale only while something is happening (at least one plane flying OR in repair); a
|
||||
// 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 } {
|
||||
let flying = 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');
|
||||
|
||||
const nowMs = Date.now();
|
||||
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
|
||||
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
|
||||
// ISS-128: a fully grounded fleet slows the world to real time (IDLE_WORLD_TIME_SCALE) instead of
|
||||
// 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 { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
|
||||
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);
|
||||
|
||||
if (advanceMs > 0) {
|
||||
@@ -409,8 +419,24 @@ export async function spawnCoveragePackages(
|
||||
|
||||
// --- 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> {
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
@@ -35,8 +35,14 @@ export async function fetchState(): Promise<StateSnapshot> {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page,
|
||||
// so every pane renders from the same snapshot instead of running its own poll loops.
|
||||
// The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane
|
||||
// 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 {
|
||||
const [state, setState] = useState<StateSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -70,10 +76,53 @@ export function useGameState(intervalMs = 1000): GameStateResult {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh(); // fetch immediately on mount, don't wait for the first interval tick
|
||||
const id = setInterval(refresh, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs, refresh]);
|
||||
let pollId: ReturnType<typeof setInterval> | null = null;
|
||||
let silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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
|
||||
// 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 => ({
|
||||
clockMs: 1_750_000_000_000,
|
||||
@@ -24,10 +25,10 @@ describe('advanceSimClock', () => {
|
||||
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 });
|
||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
|
||||
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); // zero elapsed — still the anchor value
|
||||
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', () => {
|
||||
|
||||
+10
-9
@@ -2,15 +2,16 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
|
||||
// server's idle-pause instead of a player-controlled pause.
|
||||
// fixed WORLD_TIME_SCALE; ISS-128 (v0.56.0): while nothing is happening it slows to
|
||||
// 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 {
|
||||
// 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
|
||||
// real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
|
||||
// the anchor value instead of going backwards. Unit-testable in node.
|
||||
// real-time × WORLD_TIME_SCALE; while idle it slows to real-time × IDLE_WORLD_TIME_SCALE (ISS-128).
|
||||
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||
if (!anchor.advancing) return anchor.clockMs;
|
||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||||
const scale = anchor.advancing ? WORLD_TIME_SCALE : IDLE_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.
|
||||
@@ -41,8 +42,8 @@ export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
||||
// (useGameState), so this runs once per poll cycle as well.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
|
||||
// flying or in repair. One line on purpose — keep it in sync with that function.
|
||||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
|
||||
// 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');
|
||||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
// 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.
|
||||
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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.54.0",
|
||||
"version": "0.57.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user