Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53ad41c09 | ||
|
|
82061ed869 | ||
|
|
2a4b341a9c | ||
|
|
cd2c766895 | ||
|
|
23896b2cc4 | ||
|
|
189a2c04eb | ||
|
|
a0560ec767 |
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **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`.
|
||||||
|
- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve).
|
||||||
|
|
||||||
## v0.51.0 — 2026-09-22 08:21
|
## v0.51.0 — 2026-09-22 08:21
|
||||||
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
|
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
|
||||||
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
|
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
};
|
};
|
||||||
expect(Object.keys(body).sort()).toEqual([
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
packages: Array<{ id: string; status: string; planeId: string | null }>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117
|
||||||
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
|
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
|
||||||
};
|
};
|
||||||
expect(Object.keys(body).sort()).toEqual([
|
expect(Object.keys(body).sort()).toEqual([
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
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 { getGameDb } from '@/lib/db/client';
|
|
||||||
import * as schema from '@/lib/db/schema';
|
|
||||||
import { POST } from './route';
|
|
||||||
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
|
|
||||||
// throwaway file (fresh module registry per test file → fresh singleton).
|
|
||||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-'));
|
|
||||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.FLIGHT_DB_PATH;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) }));
|
|
||||||
const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text }));
|
|
||||||
|
|
||||||
describe('POST /api/sim', () => {
|
|
||||||
it('returns the current simState for an empty body (tick + no-op)', async () => {
|
|
||||||
const res = await post({});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean };
|
|
||||||
expect(typeof body.clockMs).toBe('number');
|
|
||||||
expect(body.timeScale).toBe(1); // seeded default
|
|
||||||
expect(body.paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets and clamps timeScale into [1, 1000]', async () => {
|
|
||||||
const hi = await post({ timeScale: 2000 });
|
|
||||||
expect(hi.status).toBe(200);
|
|
||||||
expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000);
|
|
||||||
|
|
||||||
const lo = await post({ timeScale: 0.1 });
|
|
||||||
expect(lo.status).toBe(200);
|
|
||||||
expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1);
|
|
||||||
|
|
||||||
// The top of the ladder passes through unclamped (v0.14.0).
|
|
||||||
const max = await post({ timeScale: 1000 });
|
|
||||||
expect(max.status).toBe(200);
|
|
||||||
expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000);
|
|
||||||
|
|
||||||
// In-range values pass through unclamped — ladder and off-ladder alike.
|
|
||||||
const ladder = await post({ timeScale: 500 });
|
|
||||||
expect(ladder.status).toBe(200);
|
|
||||||
expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500);
|
|
||||||
|
|
||||||
const mid = await post({ timeScale: 37 });
|
|
||||||
expect(mid.status).toBe(200);
|
|
||||||
expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37);
|
|
||||||
|
|
||||||
// Restore the default for later tests.
|
|
||||||
await post({ timeScale: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles paused and freezes the clock across ticks', async () => {
|
|
||||||
const pause = await post({ paused: true });
|
|
||||||
expect(pause.status).toBe(200);
|
|
||||||
expect(((await pause.json()) as { paused: boolean }).paused).toBe(true);
|
|
||||||
|
|
||||||
// Two reads while paused → identical clock (tick-on-read advances nothing).
|
|
||||||
const a = await post({});
|
|
||||||
const b = await post({});
|
|
||||||
expect(((await a.json()) as { clockMs: number }).clockMs).toBe(
|
|
||||||
((await b.json()) as { clockMs: number }).clockMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resume = await post({ paused: false });
|
|
||||||
expect(resume.status).toBe(200);
|
|
||||||
expect(((await resume.json()) as { paused: boolean }).paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies both fields in one call', async () => {
|
|
||||||
const res = await post({ timeScale: 64, paused: true });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as { timeScale: number; paused: boolean };
|
|
||||||
expect(body.timeScale).toBe(64);
|
|
||||||
expect(body.paused).toBe(true);
|
|
||||||
await post({ timeScale: 1, paused: false }); // restore
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
|
|
||||||
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
|
|
||||||
const row = async () => (await inst.db.select().from(schema.simState))[0];
|
|
||||||
|
|
||||||
await post({ timeScale: 250 });
|
|
||||||
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
|
|
||||||
|
|
||||||
await post({ paused: true });
|
|
||||||
expect((await row()).paused).toBe(true);
|
|
||||||
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
|
|
||||||
|
|
||||||
await post({ timeScale: 1, paused: false }); // restore
|
|
||||||
expect((await row()).lastSelectedTimeScale).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid input with 400', async () => {
|
|
||||||
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
|
||||||
const res = await post(body);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(((await res.json()) as { error: string }).error).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const text of ['nope{', '[1,2]', '"str"', 'null']) {
|
|
||||||
const res = await postRaw(text);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { applyTimeControls } from '@/lib/sim/engine';
|
|
||||||
|
|
||||||
// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }.
|
|
||||||
// Ticks the clock first (so the response reflects current game time), then applies
|
|
||||||
// and persists the new controls. Returns the updated simState.
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const text = await req.text();
|
|
||||||
body = text ? JSON.parse(text) : {};
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
||||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = body as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(input)) {
|
|
||||||
if (key !== 'timeScale' && key !== 'paused') {
|
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) {
|
|
||||||
return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (input.paused !== undefined && typeof input.paused !== 'boolean') {
|
|
||||||
return Response.json({ error: 'paused must be a boolean' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sim = await applyTimeControls({
|
|
||||||
timeScale: input.timeScale as number | undefined,
|
|
||||||
paused: input.paused as boolean | undefined,
|
|
||||||
});
|
|
||||||
return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to apply sim controls', err);
|
|
||||||
return Response.json({ error: 'failed to apply sim controls' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,10 +43,8 @@ describe('GET /api/state', () => {
|
|||||||
expect(typeof account.cash).toBe('number');
|
expect(typeof account.cash).toBe('number');
|
||||||
expect(typeof account.reputation).toBe('number');
|
expect(typeof account.reputation).toBe('number');
|
||||||
|
|
||||||
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean };
|
const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
|
||||||
expect(typeof sim.clockMs).toBe('number');
|
expect(typeof sim.clockMs).toBe('number');
|
||||||
expect(typeof sim.timeScale).toBe('number');
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
|
|
||||||
const planes = body.planes as Array<Record<string, unknown>>;
|
const planes = body.planes as Array<Record<string, unknown>>;
|
||||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { setTestTimeScale } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now
|
||||||
|
// that there are no player-facing controls; without it every spec would run at the fixed world
|
||||||
|
// rate and a cross-Europe leg would take hours of real time. Disabled unless FLIGHT_TEST_HOOKS=1
|
||||||
|
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
|
||||||
|
// Sibling of /api/test/set-health — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-timescale
|
||||||
|
// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise).
|
||||||
|
// Ticks first so the write lands on current game state, under the sim lock.
|
||||||
|
// Returns { clockMs, timeScale } — the new effective scale for test assertions.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'timeScale') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timeScale = input.timeScale;
|
||||||
|
if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) {
|
||||||
|
return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await setTestTimeScale(timeScale);
|
||||||
|
return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -16,7 +16,7 @@ export default function Home() {
|
|||||||
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
|
||||||
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
|
||||||
// lets a pane push an action endpoint's full response snapshot in immediately.
|
// lets a pane push an action endpoint's full response snapshot in immediately.
|
||||||
const { state, loading, error, refresh, applySnapshot } = useGameState(1000);
|
const { state, loading, error, applySnapshot } = useGameState(1000);
|
||||||
|
|
||||||
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
|
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
|
||||||
// either pane highlights the matching plane/route/airports on the map.
|
// either pane highlights the matching plane/route/airports on the map.
|
||||||
@@ -45,7 +45,7 @@ export default function Home() {
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
|
||||||
fly/abandon actions push their response snapshot into the shared state */}
|
fly/abandon actions push their response snapshot into the shared state */}
|
||||||
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<LeftPane state={state} loading={loading} error={error} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
|
||||||
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
|
||||||
action response into the shared state, exactly like RightPane does */}
|
action response into the shared state, exactly like RightPane does */}
|
||||||
|
|||||||
+10
-218
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
import { cargoWeightFor } from './RightPane';
|
import { cargoWeightFor } from './RightPane';
|
||||||
@@ -16,8 +15,6 @@ interface LeftPaneProps {
|
|||||||
state: StateSnapshot | null;
|
state: StateSnapshot | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
|
||||||
onRefresh: () => void;
|
|
||||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||||
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
|
// state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
@@ -27,12 +24,6 @@ interface LeftPaneProps {
|
|||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
|
|
||||||
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
|
|
||||||
// roughly doubles the speed.
|
|
||||||
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
|
|
||||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
|
||||||
|
|
||||||
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
||||||
function planeLocation(p: PlaneState): string {
|
function planeLocation(p: PlaneState): string {
|
||||||
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
||||||
@@ -47,51 +38,7 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
|||||||
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||||
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
|
|
||||||
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
|
||||||
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
|
|
||||||
return pendingScale !== null && serverTimeScale === pendingScale;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
|
|
||||||
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
|
|
||||||
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
|
|
||||||
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
|
|
||||||
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
|
|
||||||
export const SIM_POST_MAX_ATTEMPTS = 3;
|
|
||||||
export const SIM_POST_RETRY_DELAY_MS = 500;
|
|
||||||
|
|
||||||
export type SimPostRetryResult =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; reason: 'exhausted'; error: unknown }
|
|
||||||
| { ok: false; reason: 'superseded' };
|
|
||||||
|
|
||||||
export async function retrySimPost(
|
|
||||||
post: () => Promise<void>,
|
|
||||||
superseded: () => boolean,
|
|
||||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
||||||
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
|
|
||||||
delayMs: number = SIM_POST_RETRY_DELAY_MS,
|
|
||||||
): Promise<SimPostRetryResult> {
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
|
|
||||||
try {
|
|
||||||
await post();
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
|
|
||||||
if (attempt < maxAttempts) await sleep(delayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: false, reason: 'exhausted', error: lastError };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||||
|
|
||||||
@@ -123,73 +70,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
|
|
||||||
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
|
|
||||||
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
|
|
||||||
// toast (the running poll retries on its own). A commit that lands while another POST is in
|
|
||||||
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
|
|
||||||
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
|
|
||||||
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
|
|
||||||
// which used to let a flush re-queue instead of send and silently drop a commit.
|
|
||||||
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
|
|
||||||
const busyRef = useRef(false);
|
|
||||||
|
|
||||||
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
|
|
||||||
|
|
||||||
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
|
|
||||||
// 'queued': an in-flight call holds it — its flush will send it.
|
|
||||||
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
|
|
||||||
const applySimControls = useCallback(
|
|
||||||
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
|
|
||||||
if (busyRef.current) {
|
|
||||||
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
|
|
||||||
return 'queued';
|
|
||||||
}
|
|
||||||
busyRef.current = true;
|
|
||||||
setBusy(true);
|
|
||||||
let sentOwn = false;
|
|
||||||
try {
|
|
||||||
let current: { timeScale?: number; paused?: boolean } | null = body;
|
|
||||||
while (current !== null) {
|
|
||||||
const target = current; // stable across this retry round
|
|
||||||
const result = await retrySimPost(
|
|
||||||
async () => {
|
|
||||||
const res = await fetch('/api/sim', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(target),
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
|
||||||
},
|
|
||||||
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
|
|
||||||
);
|
|
||||||
if (result.ok) {
|
|
||||||
onRefresh();
|
|
||||||
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
|
|
||||||
} else if (result.reason === 'exhausted') {
|
|
||||||
throw result.error; // only after the final attempt fails → error toast below
|
|
||||||
}
|
|
||||||
current = queuedRef.current; // pick up anything that arrived while we were in flight
|
|
||||||
queuedRef.current = null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to apply sim controls', err);
|
|
||||||
// ISS-008: surface the failure as an error toast too — previously console-only.
|
|
||||||
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
|
|
||||||
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
|
|
||||||
} finally {
|
|
||||||
busyRef.current = false;
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
return sentOwn ? 'sent' : 'failed';
|
|
||||||
},
|
|
||||||
[onRefresh, onError],
|
|
||||||
);
|
|
||||||
|
|
||||||
const paused = state?.simState.paused ?? false;
|
|
||||||
const timeScale = state?.simState.timeScale ?? 1;
|
|
||||||
|
|
||||||
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||||
const clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
|
|
||||||
@@ -208,69 +88,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
relocatePlane?.status === 'flying' && relocateDest !== null &&
|
||||||
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
|
||||||
|
|
||||||
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
|
|
||||||
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
|
|
||||||
// user releases the slider (browsers fire a native 'change' on release). The server's
|
|
||||||
// [1,1000] clamp stays as the backstop.
|
|
||||||
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
|
|
||||||
const pendingRef = useRef<number | null>(null);
|
|
||||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const setPending = useCallback((s: number | null) => {
|
|
||||||
pendingRef.current = s;
|
|
||||||
setPendingScaleState(s);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const commitNow = useCallback(() => {
|
|
||||||
if (commitTimerRef.current !== null) {
|
|
||||||
clearTimeout(commitTimerRef.current);
|
|
||||||
commitTimerRef.current = null;
|
|
||||||
}
|
|
||||||
const s = pendingRef.current;
|
|
||||||
if (s === null) return;
|
|
||||||
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
|
|
||||||
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
|
|
||||||
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
|
|
||||||
// call flushes it); only a genuine failure drops it so the display falls back to truth.
|
|
||||||
void applySimControls({ timeScale: s }).then((outcome) => {
|
|
||||||
if (outcome === 'failed') setPending(null);
|
|
||||||
});
|
|
||||||
}, [applySimControls, setPending]);
|
|
||||||
|
|
||||||
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
|
|
||||||
// flight, the optimistic value has been superseded by server truth — clear it (display is
|
|
||||||
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
|
|
||||||
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
|
|
||||||
// quick drag back to the previous scale must not be swallowed before its own commit fires).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
|
|
||||||
}, [state, pendingScale, busy, setPending]);
|
|
||||||
|
|
||||||
const onSliderInput = useCallback(
|
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPending(sliderToScale(Number(e.target.value)));
|
|
||||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
|
||||||
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
|
|
||||||
},
|
|
||||||
[commitNow, setPending],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sliderRef = useRef<HTMLInputElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = sliderRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
|
|
||||||
return () => el.removeEventListener('change', commitNow);
|
|
||||||
}, [commitNow]);
|
|
||||||
|
|
||||||
// Drop any pending debounce timer on unmount.
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
||||||
// in an effect after hydration, so server and client render identically) and follows the
|
// in an effect after hydration, so server and client render identically) and follows the
|
||||||
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
|
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
|
||||||
@@ -290,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
? 'Notifications unavailable'
|
? 'Notifications unavailable'
|
||||||
: 'Enable notifications';
|
: 'Enable notifications';
|
||||||
|
|
||||||
const displayedScale = pendingScale ?? timeScale;
|
// ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
|
||||||
|
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
|
||||||
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
|
// paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
|
||||||
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately,
|
// the server clock).
|
||||||
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and
|
const simClockMs = useSimClock(state);
|
||||||
// penalty previews stay snapshot-based (deterministic against the server clock).
|
|
||||||
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside data-testid="left-pane" className="pane left-pane">
|
<aside data-testid="left-pane" className="pane left-pane">
|
||||||
@@ -563,32 +378,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
|
|
||||||
<section className="pane-section">
|
<section className="pane-section">
|
||||||
<h3 className="pane-heading">Time</h3>
|
<h3 className="pane-heading">Time</h3>
|
||||||
<p data-testid="sim-clock" className="sim-clock">
|
{/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
|
||||||
{simClockMs !== null && formatSimClock(simClockMs)}
|
only the clock display remains here. */}
|
||||||
{paused ? ' (paused)' : ''}
|
<p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
|
||||||
</p>
|
|
||||||
<div className="time-controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="pause-toggle"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void applySimControls({ paused: !paused })}
|
|
||||||
>
|
|
||||||
{paused ? 'Resume' : 'Pause'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={sliderRef}
|
|
||||||
type="range"
|
|
||||||
data-testid="time-scale-slider"
|
|
||||||
aria-label="Time scale"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={SCALE_SLIDER_STEP}
|
|
||||||
value={scaleToSlider(displayedScale)}
|
|
||||||
onChange={onSliderInput}
|
|
||||||
/>
|
|
||||||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
|
||||||
</div>
|
|
||||||
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane';
|
|
||||||
|
|
||||||
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
|
|
||||||
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
|
|
||||||
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
|
||||||
|
|
||||||
describe('pendingScaleConfirmed', () => {
|
|
||||||
it('is false while there is no pending value', () => {
|
|
||||||
expect(pendingScaleConfirmed(null, 1)).toBe(false);
|
|
||||||
expect(pendingScaleConfirmed(null, 64)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
|
|
||||||
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
|
|
||||||
expect(pendingScaleConfirmed(64, 1)).toBe(false);
|
|
||||||
expect(pendingScaleConfirmed(32, 8)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('becomes true exactly when a snapshot carries the pending scale', () => {
|
|
||||||
expect(pendingScaleConfirmed(64, 64)).toBe(true);
|
|
||||||
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the
|
|
||||||
// error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer
|
|
||||||
// intent supersedes the in-flight body instead of waiting out its retries.
|
|
||||||
|
|
||||||
describe('retrySimPost', () => {
|
|
||||||
it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => {
|
|
||||||
expect(SIM_POST_MAX_ATTEMPTS).toBe(3);
|
|
||||||
expect(SIM_POST_RETRY_DELAY_MS).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('succeeds on the first attempt without sleeping or retrying', async () => {
|
|
||||||
const post = vi.fn(async () => {});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
|
|
||||||
expect(post).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sleep).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retries with the backoff delay and succeeds on a later attempt', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
if (++calls < 3) throw new Error('dropped');
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
|
|
||||||
expect(post).toHaveBeenCalledTimes(3);
|
|
||||||
expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts
|
|
||||||
expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives up after the final attempt with the last error (no trailing sleep)', async () => {
|
|
||||||
const err = new Error('down');
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
const result = await retrySimPost(post, () => false, sleep);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.reason).toBe('exhausted');
|
|
||||||
if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error
|
|
||||||
}
|
|
||||||
expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS);
|
|
||||||
expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const post = vi.fn(async () => {
|
|
||||||
calls++; // flip "superseded" right after the first failure, like a queued commitNow would
|
|
||||||
throw new Error('dropped');
|
|
||||||
});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
|
|
||||||
expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead
|
|
||||||
expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never even sends a body that is already superseded', async () => {
|
|
||||||
const post = vi.fn(async () => {});
|
|
||||||
const sleep = vi.fn(async () => {});
|
|
||||||
await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
|
|
||||||
expect(post).not.toHaveBeenCalled();
|
|
||||||
expect(sleep).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -54,7 +54,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
||||||
return {
|
return {
|
||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0 },
|
||||||
planes,
|
planes,
|
||||||
packages,
|
packages,
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
|
|||||||
+49
-24
@@ -14,7 +14,8 @@ import { windBurnFactor } from '../lib/sim/movement';
|
|||||||
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
||||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
||||||
@@ -39,7 +40,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||||
packages: Array<{
|
packages: Array<{
|
||||||
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
||||||
@@ -69,11 +70,6 @@ async function createPackage(
|
|||||||
return (await res.json()) as StateBody['packages'][number];
|
return (await res.json()) as StateBody['packages'][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||||||
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||||||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||||
@@ -82,15 +78,38 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
|
||||||
// resume it or the clock stays frozen and the next wait hangs.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
||||||
@@ -110,10 +129,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
|
// Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
|
||||||
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
@@ -142,8 +161,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
@@ -255,8 +273,7 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
||||||
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
||||||
|
|
||||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
|
// Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
|
||||||
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(homeRes.ok()).toBeTruthy();
|
expect(homeRes.ok()).toBeTruthy();
|
||||||
@@ -304,7 +321,6 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
|
|||||||
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||||
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
|
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
const before = await getState(page);
|
|
||||||
|
|
||||||
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||||
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
||||||
@@ -318,12 +334,23 @@ 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 —
|
||||||
|
// 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.
|
||||||
|
await activateWorld(page);
|
||||||
|
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).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
let s = await getState(page);
|
let s = await getState(page);
|
||||||
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||||
s = await getState(page);
|
s = await getState(page);
|
||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
||||||
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
|
||||||
|
let s = before;
|
||||||
|
|
||||||
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
||||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
||||||
@@ -345,8 +372,7 @@ test('expired packages deliver and abandon with no reward and no penalty', async
|
|||||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
|
||||||
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
|
||||||
|
|
||||||
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
|
// Restore: top up + fly home at SCALE.
|
||||||
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
|
|
||||||
await setFuelFull(page, 'plane-starter');
|
await setFuelFull(page, 'plane-starter');
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
@@ -386,8 +412,7 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
|||||||
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
expect(res.status()).toBe(409);
|
expect(res.status()).toBe(409);
|
||||||
|
|
||||||
// Fly home from the hop so the fuel section starts at the hub. ISS-041: the hop arrival above
|
// Fly home from the hop so the fuel section starts at the hub, at SCALE.
|
||||||
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|||||||
+1
-2
@@ -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 thirteen endpoints and the package.json version', async ({ page }) => {
|
test('GET /api/openapi serves the spec with all twelve 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');
|
||||||
@@ -32,7 +32,6 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
|
|||||||
'/api/planes/{planeId}/queue',
|
'/api/planes/{planeId}/queue',
|
||||||
'/api/planes/{planeId}/queue/cancel',
|
'/api/planes/{planeId}/queue/cancel',
|
||||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+83
-28
@@ -11,7 +11,8 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
|
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
|
||||||
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
@@ -41,7 +42,7 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
@@ -52,24 +53,18 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -106,8 +101,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
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)
|
||||||
@@ -177,6 +171,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');
|
||||||
@@ -223,8 +227,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
|
||||||
@@ -350,6 +359,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
|
||||||
@@ -472,8 +526,9 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||||||
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused.
|
// e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
|
||||||
await postSim(page, { paused: true });
|
// the route line stays drawn while frozen.
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
await block.hover();
|
await block.hover();
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
@@ -489,7 +544,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
|
|
||||||
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
// onboarding state (starter grounded at EDDF) for the other specs.
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -497,7 +552,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
|
|||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// Re-set SCALE for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
@@ -535,10 +590,10 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
|||||||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||||||
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused,
|
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. At
|
||||||
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING
|
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
|
||||||
// airport regardless of progress, so no mid-flight position window is needed at all.
|
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
|
||||||
await postSim(page, { paused: true });
|
await setScale(page, 1);
|
||||||
|
|
||||||
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||||
@@ -584,7 +639,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
|||||||
// not DEST — it never delivered), then fly home.
|
// not DEST — it never delivered), then fly home.
|
||||||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||||
expect(cancelRes.status()).toBe(200);
|
expect(cancelRes.status()).toBe(200);
|
||||||
await setScale(page, SCALE); // also clears the pause above
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
@@ -595,7 +650,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
|
|||||||
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
// Re-set SCALE for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
|
|||||||
+10
-10
@@ -10,7 +10,8 @@ import { REPAIR_COST } from '../lib/game/catalog';
|
|||||||
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
// engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
|
||||||
// passes.
|
// passes.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const STARTER = 'plane-starter';
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ interface CatalogEntry {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
}
|
}
|
||||||
@@ -46,13 +47,13 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
// a silent reset would otherwise surface as an unexplained wait timeout.
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||||
@@ -125,7 +126,7 @@ async function healStarterHome(page: Page): Promise<void> {
|
|||||||
starter = s.planes.find((p) => p.id === STARTER)!;
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
// Make sure the world rate is back at SCALE for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
||||||
// leg when cash allows — no top-up retry needed.
|
// leg when cash allows — no top-up retry needed.
|
||||||
@@ -152,11 +153,10 @@ async function restoreStarter(page: Page): Promise<void> {
|
|||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
// On exit (success or failure): restore the fixed world rate for the other specs (house convention)
|
||||||
// and leave a clean starter — full health, no active repair gate.
|
// and leave a clean starter — full health, no active repair gate.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
await restoreStarter(page);
|
await restoreStarter(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
|||||||
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
|
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
|
||||||
// the 6300 L tank, so a full-tank departure always makes it home.
|
// the 6300 L tank, so a full-tank departure always makes it home.
|
||||||
const DEST = 'LEMD';
|
const DEST = 'LEMD';
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top)
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,7 +41,7 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
@@ -64,24 +65,18 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -235,7 +230,7 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
||||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -258,23 +253,30 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
|
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
|
||||||
|
|
||||||
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
|
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
|
||||||
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered
|
// Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
|
||||||
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift
|
// our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
|
||||||
// on this leg. Pausing makes the position static; we resume right after the popup checks.
|
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
|
||||||
await postSim(page, { paused: true });
|
// we restore SCALE right after the popup checks.
|
||||||
|
await setScale(page, 1);
|
||||||
|
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
|
||||||
|
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
|
||||||
|
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
|
||||||
|
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
|
||||||
|
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
|
||||||
|
// would show (~km per poll pair).
|
||||||
let prevPos: { lat: number; lon: number } | null = null;
|
let prevPos: { lat: number; lon: number } | null = null;
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
if (!p || p.status !== 'flying') return false;
|
if (!p || p.status !== 'flying') return false;
|
||||||
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
|
if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
|
||||||
prevPos = { lat: p.lat, lon: p.lon };
|
prevPos = { lat: p.lat, lon: p.lon };
|
||||||
return false;
|
return false;
|
||||||
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' })
|
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused
|
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
|
||||||
// above the server's analytic position is static — but the map renders from its own ~1 s
|
// above the server's analytic position barely moves — but the map renders from its own ~1 s
|
||||||
// poll, so right after the freeze confirmation the last rendered frame can still be up to
|
// poll, so right after the freeze confirmation the last rendered frame can still be up to
|
||||||
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
|
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
|
||||||
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
|
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
|
||||||
@@ -314,8 +316,8 @@ test('plane markers move while flying and the plane popup shows status + route',
|
|||||||
// The popup's route line is the plane's own leg: origin name → destination name.
|
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||||
|
|
||||||
// Resume before step 5 — the landing wait needs a running clock.
|
// Restore SCALE before step 5 — the landing wait needs a fast clock.
|
||||||
await postSim(page, { timeScale: SCALE, paused: false });
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
// 5) Let the flight finish, then fly home so the live instance stays in its
|
// 5) Let the flight finish, then fly home so the live instance stays in its
|
||||||
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||||
|
|||||||
+89
-54
@@ -1,20 +1,16 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
||||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
|
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||||
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
|
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
|
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
|
||||||
interface SimState {
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
clockMs: number;
|
const STARTER = 'plane-starter';
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
@@ -25,11 +21,70 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
|
||||||
|
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
|
||||||
|
// and repairPlane 409s on non-available planes, which would break activateWorld.
|
||||||
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before acting on the starter.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 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 plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
|
||||||
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (plane.status === 'flying') {
|
||||||
|
// 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 === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
|
||||||
|
}
|
||||||
|
if (plane.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/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
|
||||||
// pattern as e2e/sim.spec.ts.
|
// pattern as e2e/sim.spec.ts.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
||||||
@@ -79,26 +134,34 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
// Refuel heading or row anymore.
|
// Refuel heading or row anymore.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
|
||||||
|
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
|
||||||
|
// Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
|
||||||
|
// 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.
|
||||||
|
await activateWorld(page);
|
||||||
|
try {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const clock = page.getByTestId('sim-clock');
|
const clock = page.getByTestId('sim-clock');
|
||||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Fastest scale so game seconds move visibly inside the sampling window.
|
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
|
||||||
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
|
|
||||||
expect(post.status()).toBe(200);
|
|
||||||
|
|
||||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
|
|
||||||
const sample = async () => {
|
const sample = async () => {
|
||||||
const text = ((await clock.textContent()) ?? '').trim();
|
const text = ((await clock.textContent()) ?? '').trim();
|
||||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
|
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
|
||||||
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
|
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
|
||||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||||
const t0 = await sample();
|
const t0 = await sample();
|
||||||
await new Promise((r) => setTimeout(r, 1_300));
|
await new Promise((r) => setTimeout(r, 1_300));
|
||||||
@@ -108,42 +171,14 @@ test('sim clock ticks smoothly between polls — seconds advance without waiting
|
|||||||
|
|
||||||
expect(t1).toBeGreaterThan(t0);
|
expect(t1).toBeGreaterThan(t0);
|
||||||
expect(t2).toBeGreaterThan(t1);
|
expect(t2).toBeGreaterThan(t1);
|
||||||
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
|
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
|
||||||
// still catching a frozen or wildly mis-scaled clock.
|
// still catching a frozen or wildly mis-scaled clock.
|
||||||
const total = t2 - t0;
|
const total = t2 - t0;
|
||||||
expect(total).toBeGreaterThanOrEqual(1_000_000);
|
expect(total).toBeGreaterThanOrEqual(50_000);
|
||||||
expect(total).toBeLessThanOrEqual(6_000_000);
|
expect(total).toBeLessThanOrEqual(200_000);
|
||||||
});
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
|
|
||||||
test.setTimeout(60_000);
|
|
||||||
await page.goto('/');
|
|
||||||
const slider = page.getByTestId('time-scale-slider');
|
|
||||||
await expect(slider).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Commit to max via keyboard (input + change → immediate commitNow POST)…
|
|
||||||
await slider.focus();
|
|
||||||
await page.keyboard.press('End');
|
|
||||||
|
|
||||||
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
|
|
||||||
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
|
|
||||||
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
|
|
||||||
const seen: string[] = [];
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
}
|
}
|
||||||
expect(
|
|
||||||
seen.every((v) => v === '1000×'),
|
|
||||||
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// …and the server confirms it.
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
|
||||||
|
|
||||||
// Restore to min for later tests (afterEach restores scale anyway).
|
|
||||||
await page.keyboard.press('Home');
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
||||||
|
|||||||
+14
-19
@@ -14,7 +14,8 @@ import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/move
|
|||||||
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
||||||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||||||
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||||||
@@ -40,7 +41,7 @@ interface PackageState {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
}
|
}
|
||||||
@@ -51,23 +52,17 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
return page.request.post('/api/sim', { data: body });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||||
@@ -119,7 +114,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause 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.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/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)
|
||||||
@@ -259,7 +254,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
await setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
|
||||||
|
|
||||||
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||||
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
@@ -286,7 +281,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
// Re-set SCALE for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
@@ -316,8 +311,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
const flyBody = (await flyRes.json()) as StateBody;
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
// Freeze mid-flight (scale 1) so the ~10 s leg can't land under us while we assert.
|
||||||
await postSim(page, { paused: true });
|
await setScale(page, 1);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
@@ -367,7 +362,7 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
|
|||||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||||
|
|
||||||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
// Re-set SCALE for the return leg.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(home.status()).toBe(200);
|
expect(home.status()).toBe(200);
|
||||||
|
|||||||
+9
-16
@@ -9,7 +9,8 @@ import { expect, test } from '@playwright/test';
|
|||||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
|
||||||
// away at the initial zoom, so a click on its projected center cannot hit another circle.
|
// away at the initial zoom, so a click on its projected center cannot hit another circle.
|
||||||
@@ -33,20 +34,13 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
// if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
|
||||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
|
||||||
// resume it or the clock stays frozen and the next wait hangs.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
|
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
|
||||||
@@ -83,8 +77,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||||||
// fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
@@ -93,10 +86,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
|
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
|
||||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
// fast-forward + reposition home here; then restore the fixed world rate for later specs.
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|||||||
+22
-24
@@ -18,7 +18,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
|
|||||||
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
||||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
|
||||||
interface PlaneState {
|
interface PlaneState {
|
||||||
@@ -57,7 +58,7 @@ interface CatalogEntry {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
@@ -74,24 +75,18 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
|
||||||
return page.request.post('/api/sim', { data: body });
|
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
|
||||||
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
|
|
||||||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
@@ -168,9 +163,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
if (starter.locationIcao !== HUB) {
|
if (starter.locationIcao !== HUB) {
|
||||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
|
||||||
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
|
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
@@ -274,9 +268,15 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
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 exact penalty at the snapshot's clock (same formula as the server).
|
||||||
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||||||
// making the preview deterministic instead of racing the 1 s poll loop.
|
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
|
||||||
await postSim(page, { paused: true });
|
// 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;
|
||||||
|
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 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));
|
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||||
@@ -297,8 +297,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||||
|
|
||||||
// Resume and fast-forward to arrival (poll the authoritative state).
|
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
|
||||||
await postSim(page, { paused: false });
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -313,8 +312,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
// Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
|
||||||
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(homeRes.status()).toBe(200);
|
expect(homeRes.status()).toBe(200);
|
||||||
|
|||||||
+82
-62
@@ -1,82 +1,103 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
|
||||||
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
|
// fleet, background-tick advance at a raised rate while something is active, package expiry via
|
||||||
// hook enabled in docker-compose.yml), and market top-up.
|
// 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; // fastest sim speed — top of the v0.14.0 ladder
|
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
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;
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
interface SimState {
|
|
||||||
clockMs: number;
|
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: SimState;
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<{ id: string; status: string }>;
|
||||||
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
|
||||||
return page.request.post('/api/sim', { data: body });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
const res = await page.request.get('/api/state');
|
const res = await page.request.get('/api/state');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
test.afterEach(async ({ page }) => {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||||
|
async function deactivateWorld(page: Page): Promise<void> {
|
||||||
|
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the starter to flip back to available',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await setScale(page, WORLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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 }) => {
|
||||||
const post = await postSim(page, { timeScale: SCALE });
|
await activateWorld(page);
|
||||||
expect(post.status()).toBe(200);
|
try {
|
||||||
const s1 = (await post.json()) as SimState;
|
await setScale(page, SCALE);
|
||||||
expect(s1.timeScale).toBe(SCALE);
|
|
||||||
expect(s1.paused).toBe(false);
|
|
||||||
|
|
||||||
let prev = s1.clockMs;
|
let prev = (await getState(page)).simState.clockMs;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
|
||||||
const body = await getState(page);
|
const now = (await getState(page)).simState.clockMs;
|
||||||
const now = body.simState.clockMs;
|
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
|
||||||
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
|
|
||||||
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
|
||||||
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
|
||||||
prev = now;
|
prev = now;
|
||||||
}
|
}
|
||||||
});
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
test('paused freezes the clock exactly across polls', async ({ page }) => {
|
|
||||||
const post = await postSim(page, { timeScale: SCALE, paused: true });
|
|
||||||
expect(post.status()).toBe(200);
|
|
||||||
const s1 = (await post.json()) as SimState;
|
|
||||||
expect(s1.paused).toBe(true);
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
await sleep(500);
|
|
||||||
const body = await getState(page);
|
|
||||||
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
await activateWorld(page);
|
||||||
|
try {
|
||||||
const before = await getState(page);
|
const before = await getState(page);
|
||||||
const victim = before.packages.find((p) => p.status === 'open');
|
const victim = before.packages.find((p) => p.status === 'open');
|
||||||
expect(victim).toBeDefined(); // steady state always has open packages
|
expect(victim).toBeDefined(); // steady state always has open packages
|
||||||
@@ -88,7 +109,7 @@ test('an open package past its short deadline expires and the market stays toppe
|
|||||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
||||||
expect(moved.id).toBe(victim!.id);
|
expect(moved.id).toBe(victim!.id);
|
||||||
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
let expiredBody: StateBody | undefined;
|
let expiredBody: StateBody | undefined;
|
||||||
await expect
|
await expect
|
||||||
@@ -106,17 +127,21 @@ test('an open package past its short deadline expires and the market stays toppe
|
|||||||
// …and spawn tops the market back up to the target — never below it.
|
// …and spawn tops the market back up to the target — never below it.
|
||||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
||||||
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
await activateWorld(page);
|
||||||
|
try {
|
||||||
const created = await page.request.post('/api/test/expire-package');
|
const created = await page.request.post('/api/test/expire-package');
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
const c = (await created.json()) as { id: string; status: string };
|
const c = (await created.json()) as { id: string; status: string };
|
||||||
expect(c.status).toBe('open');
|
expect(c.status).toBe('open');
|
||||||
|
|
||||||
await postSim(page, { timeScale: SCALE });
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
let expiredBody: StateBody | undefined;
|
let expiredBody: StateBody | undefined;
|
||||||
await expect
|
await expect
|
||||||
@@ -129,29 +154,24 @@ test('the hook can also create a fresh short-deadline package, which then expire
|
|||||||
|
|
||||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
||||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
||||||
|
} finally {
|
||||||
|
await deactivateWorld(page);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /api/sim validates and clamps its input', async ({ page }) => {
|
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
|
||||||
const clampHigh = await postSim(page, { timeScale: 2000 });
|
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
|
||||||
expect(clampHigh.status()).toBe(200);
|
expect(ok.status()).toBe(200);
|
||||||
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0)
|
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
|
||||||
|
|
||||||
const inRange = await postSim(page, { timeScale: 500 });
|
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
|
||||||
expect(inRange.status()).toBe(200);
|
const res = await page.request.post('/api/test/set-timescale', { data: body });
|
||||||
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
|
|
||||||
|
|
||||||
const clampLow = await postSim(page, { timeScale: 0.1 });
|
|
||||||
expect(clampLow.status()).toBe(200);
|
|
||||||
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
|
|
||||||
|
|
||||||
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
|
|
||||||
const res = await postSim(page, body);
|
|
||||||
expect(res.status()).toBe(400);
|
expect(res.status()).toBe(400);
|
||||||
}
|
}
|
||||||
const notJson = await page.request.post('/api/sim', { data: 'nope{' });
|
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
|
||||||
expect(notJson.status()).toBe(400);
|
expect(notJson.status()).toBe(400);
|
||||||
|
|
||||||
// Empty body is a valid no-op (just ticks + returns current state).
|
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
|
||||||
const empty = await postSim(page, {});
|
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
|
||||||
expect(empty.status()).toBe(200);
|
expect(empty.status()).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
|
|
||||||
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
|
|
||||||
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
|
|
||||||
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
|
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
|
||||||
|
|
||||||
interface SimState {
|
|
||||||
clockMs: number;
|
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateBody {
|
|
||||||
simState: SimState;
|
|
||||||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
|
||||||
const res = await page.request.post('/api/sim', { data: body });
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
|
||||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
|
||||||
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
|
|
||||||
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
const body = (await res.json()) as SimState;
|
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
|
|
||||||
async function nearestToHub(page: Page): Promise<{ icao: string }> {
|
|
||||||
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 hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
|
||||||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
|
||||||
for (const f of fc.features) {
|
|
||||||
if (f.properties.icao === HUB) continue;
|
|
||||||
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
|
||||||
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
|
||||||
if (d < dn) nearest = f.properties;
|
|
||||||
}
|
|
||||||
return { icao: nearest.icao };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
|
|
||||||
// reposition plane-starter home via the API so every test starts from the canonical state even
|
|
||||||
// after an interrupted previous run.
|
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
|
||||||
let s = await getState(page);
|
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
}
|
|
||||||
if (plane.status === 'flying') {
|
|
||||||
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
|
|
||||||
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
|
|
||||||
// flight is needed.
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
|
||||||
s = await getState(page);
|
|
||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
|
||||||
}
|
|
||||||
if (plane.locationIcao !== HUB) {
|
|
||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
|
||||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
|
||||||
// leg when cash allows — no top-up retry needed.
|
|
||||||
const flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
|
||||||
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
|
|
||||||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
|
||||||
test.afterEach(async ({ page }) => {
|
|
||||||
await flyHomeViaApi(page);
|
|
||||||
await postSim(page, { timeScale: 1, paused: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
|
||||||
|
|
||||||
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
|
|
||||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
|
||||||
|
|
||||||
const nearest = await nearestToHub(page);
|
|
||||||
|
|
||||||
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
|
|
||||||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
|
||||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
|
||||||
expect(flyRes.status()).toBe(200);
|
|
||||||
|
|
||||||
// Wait for the landing…
|
|
||||||
let landedBody: StateBody | undefined;
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const b = await getState(page);
|
|
||||||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
|
||||||
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
|
|
||||||
return !!landedBody;
|
|
||||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
|
|
||||||
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
|
|
||||||
expect(landedBody!.simState.paused).toBe(false);
|
|
||||||
expect(landedBody!.simState.timeScale).toBe(1);
|
|
||||||
|
|
||||||
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
|
|
||||||
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
|
|
||||||
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
|
|
||||||
// resumes it.
|
|
||||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
|
||||||
expect(fly2.status()).toBe(200);
|
|
||||||
const resumedBody = (await fly2.json()) as StateBody;
|
|
||||||
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
|
|
||||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
|
|
||||||
|
|
||||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
|
||||||
});
|
|
||||||
+16
-13
@@ -10,7 +10,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: Array<Record<string, unknown>>;
|
planes: Array<Record<string, unknown>>;
|
||||||
packages: Array<Record<string, unknown>>;
|
packages: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
|||||||
expect(typeof body.account.cash).toBe('number');
|
expect(typeof body.account.cash).toBe('number');
|
||||||
expect(typeof body.account.reputation).toBe('number');
|
expect(typeof body.account.reputation).toBe('number');
|
||||||
|
|
||||||
expect(typeof body.simState.clockMs).toBe('number');
|
expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
|
||||||
expect(typeof body.simState.timeScale).toBe('number');
|
|
||||||
expect(body.simState.paused).toBe(false);
|
|
||||||
|
|
||||||
expect(Array.isArray(body.planes)).toBe(true);
|
expect(Array.isArray(body.planes)).toBe(true);
|
||||||
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
expect(body.planes.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -44,6 +42,17 @@ 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
|
||||||
|
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
if (res.status() !== 200) return 'down';
|
||||||
|
const body = (await res.json()) as StateBody;
|
||||||
|
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
|
||||||
|
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
|
||||||
|
.toBe('idle');
|
||||||
|
|
||||||
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;
|
||||||
@@ -68,15 +77,9 @@ test('state survives a container restart (sim clock + account persisted)', async
|
|||||||
const a = after!;
|
const a = after!;
|
||||||
|
|
||||||
// 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 sessions"): account and entity ids are byte-identical.
|
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
|
||||||
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one
|
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
|
||||||
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the
|
expect(a.simState.clockMs).toBe(before.simState.clockMs);
|
||||||
// post-restart clock may be ahead — but never by more than that bound.
|
|
||||||
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
|
|
||||||
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
|
|
||||||
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
|
|
||||||
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
|
|
||||||
);
|
|
||||||
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());
|
||||||
|
|||||||
+21
-45
@@ -10,14 +10,15 @@ import { windBurnFactor } from '../lib/sim/movement';
|
|||||||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||||
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
// spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
|
||||||
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell.
|
// per-plane queues (scoped testids) and sell.
|
||||||
//
|
//
|
||||||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||||
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||||||
|
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||||||
@@ -54,7 +55,7 @@ interface CatalogEntry {
|
|||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
catalog: CatalogEntry[];
|
catalog: CatalogEntry[];
|
||||||
@@ -71,18 +72,12 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
return (await res.json()) as StateBody;
|
return (await res.json()) as StateBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSim(page: Page, body: unknown) {
|
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||||
return page.request.post('/api/sim', { data: body });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
|
|
||||||
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
|
|
||||||
// proceeds at full speed.
|
|
||||||
async function setScale(page: Page, scale: number): Promise<void> {
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
const body = (await res.json()) as { timeScale: number };
|
const body = (await res.json()) as { timeScale: number };
|
||||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
@@ -230,17 +225,14 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
|||||||
await clearQueueViaApi(page, planeId);
|
await clearQueueViaApi(page, planeId);
|
||||||
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||||||
if (!p) return; // already sold — nothing to heal
|
if (!p) return; // already sold — nothing to heal
|
||||||
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
|
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
|
||||||
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
if (p.status === 'flying') {
|
if (p.status === 'flying') {
|
||||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const s = await getState(page);
|
const s = await getState(page);
|
||||||
const plane = s.planes.find((x) => x.id === planeId);
|
return s.planes.find((x) => x.id === planeId)?.status ?? '';
|
||||||
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
|
|
||||||
return plane?.status ?? '';
|
|
||||||
}, {
|
}, {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
message: `waiting for an in-flight ${planeId} to land`,
|
message: `waiting for an in-flight ${planeId} to land`,
|
||||||
@@ -249,9 +241,8 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
|||||||
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
}
|
}
|
||||||
if (p.locationIcao !== HUB) {
|
if (p.locationIcao !== HUB) {
|
||||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
|
||||||
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
|
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||||||
// leg when cash allows — no top-up retry needed.
|
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(flyHome.status()).toBe(200);
|
expect(flyHome.status()).toBe(200);
|
||||||
@@ -295,8 +286,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
|
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||||||
const res = await postSim(page, { timeScale: 1, paused: false });
|
await setScale(page, WORLD);
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||||||
@@ -348,7 +338,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
|
|||||||
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
||||||
const secondId = await buySecondPlaneViaUi(page);
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
@@ -358,7 +348,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
|
|
||||||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||||
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||||
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
|
// the same tick (a shared-tick arrival would race the first-landing assert).
|
||||||
const [nearest] = await twoNearestToHub(page);
|
const [nearest] = await twoNearestToHub(page);
|
||||||
const farthest = await farthestFlyableToHub(page);
|
const farthest = await farthestFlyableToHub(page);
|
||||||
|
|
||||||
@@ -388,8 +378,6 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
|
|
||||||
await setScale(page, SCALE);
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -397,10 +385,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
|
|
||||||
const landed = await getState(page);
|
const landed = await getState(page);
|
||||||
expect(landed.simState.paused).toBe(false);
|
|
||||||
expect(landed.simState.timeScale).toBe(1);
|
|
||||||
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||||||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||||
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||||
@@ -408,17 +393,9 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
|
|||||||
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||||||
await setFuelFull(page, secondId);
|
await setFuelFull(page, secondId);
|
||||||
|
|
||||||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
|
||||||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
expect(flyB2.status()).toBe(200);
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
let body = (await flyB2.json()) as StateBody;
|
|
||||||
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
|
|
||||||
|
|
||||||
// …but dispatching the LAST grounded plane restores the last manually selected speed.
|
|
||||||
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
|
||||||
expect(flyA2.status()).toBe(200);
|
|
||||||
body = (await flyA2.json()) as StateBody;
|
|
||||||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||||||
@@ -503,9 +480,8 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
const flyBody = (await flyRes.json()) as StateBody;
|
const flyBody = (await flyRes.json()) as StateBody;
|
||||||
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||||
|
|
||||||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
|
||||||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
|
||||||
await setScale(page, SCALE);
|
|
||||||
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -519,7 +495,7 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
|||||||
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||||
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||||
|
|
||||||
// …then restore speed so the chained leg fast-forwards too.
|
// …fast-forward the chained leg to its landing.
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { startBackgroundTick } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// ISS-117 (v0.52.0): the world clock advances on a ~1 s background tick even with nobody polling —
|
||||||
|
// started once at server boot (Next ≥15 instrumentation hook; runs under `next dev` and `next start`).
|
||||||
|
export function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
startBackgroundTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import initSqlJs from 'sql.js';
|
import initSqlJs from 'sql.js';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
|
import { STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||||||
import { openGameDb, persist } from './client';
|
import { openGameDb, persist } from './client';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
@@ -111,8 +113,9 @@ describe('openGameDb', () => {
|
|||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account?.cash).toBeGreaterThan(0);
|
expect(account?.cash).toBeGreaterThan(0);
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
expect(sim?.paused).toBe(false);
|
// ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
|
||||||
expect(sim?.timeScale).toBe(1);
|
expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
|
||||||
|
expect(sim?.timeScale).toBe(WORLD_TIME_SCALE);
|
||||||
|
|
||||||
// The seed was durably written: the file exists and is non-empty.
|
// The seed was durably written: the file exists and is non-empty.
|
||||||
const st = await fs.stat(file);
|
const st = await fs.stat(file);
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
// First-run seed: only when the DB is empty (no account row yet).
|
// First-run seed: only when the DB is empty (no account row yet).
|
||||||
const res = raw.exec('SELECT COUNT(*) FROM account');
|
const res = raw.exec('SELECT COUNT(*) FROM account');
|
||||||
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
|
||||||
|
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
|
||||||
await seedEmpty(instance);
|
await seedEmpty(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
|
|
||||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0 },
|
||||||
planes: [basePlane('plane-starter')],
|
planes: [basePlane('plane-starter')],
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
@@ -59,7 +59,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
|||||||
describe('diffSnapshots', () => {
|
describe('diffSnapshots', () => {
|
||||||
it('returns [] for identical snapshots (only clock/cash drift)', () => {
|
it('returns [] for identical snapshots (only clock/cash drift)', () => {
|
||||||
const prev = snapshot();
|
const prev = snapshot();
|
||||||
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } });
|
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000 } });
|
||||||
expect(diffSnapshots(prev, next)).toEqual([]);
|
expect(diffSnapshots(prev, next)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
repRate,
|
repRate,
|
||||||
SELL_FRACTION,
|
SELL_FRACTION,
|
||||||
} from './catalog';
|
} from './catalog';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
@@ -173,7 +173,7 @@ describe('flyPlane', () => {
|
|||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
expect(plane.destIcao).toBe('LEMD');
|
expect(plane.destIcao).toBe('LEMD');
|
||||||
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), no tick in direct calls
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
expect(plane.lat).toBe(origin.lat);
|
expect(plane.lat).toBe(origin.lat);
|
||||||
@@ -182,34 +182,6 @@ describe('flyPlane', () => {
|
|||||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
|
|
||||||
const inst = await freshDb('fly-resume.db');
|
|
||||||
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
|
|
||||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
|
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
|
|
||||||
|
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
||||||
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
|
|
||||||
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
|
|
||||||
const inst = await freshDb('fly-stayslow.db');
|
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
|
|
||||||
// A second grounded plane — the starter taking off leaves it on the ground.
|
|
||||||
await inst.db.insert(schema.planes).values({
|
|
||||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
|
||||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
|
||||||
|
|
||||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a flying plane with 409', async () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('fly-flying.db');
|
const inst = await freshDb('fly-flying.db');
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
@@ -462,14 +434,14 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('abandonPackage', () => {
|
describe('abandonPackage', () => {
|
||||||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
// A loaded package created at the seeded game clock with a 10h window (ISS-117).
|
||||||
const REWARD = 1_000;
|
const REWARD = 1_000;
|
||||||
const WINDOW_MS = 10 * 3_600_000;
|
const WINDOW_MS = 10 * 3_600_000;
|
||||||
|
|
||||||
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
||||||
const inst = await freshDb(name);
|
const inst = await freshDb(name);
|
||||||
// Always insert OPEN and load it through the real action…
|
// Always insert OPEN and load it through the real action…
|
||||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
|
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
||||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||||
if (status === 'expired') {
|
if (status === 'expired') {
|
||||||
@@ -494,7 +466,7 @@ describe('abandonPackage', () => {
|
|||||||
|
|
||||||
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||||||
const inst = await setupLoaded('ab-mid.db');
|
const inst = await setupLoaded('ab-mid.db');
|
||||||
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
|
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||||
|
|
||||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||||
|
|
||||||
@@ -503,8 +475,8 @@ describe('abandonPackage', () => {
|
|||||||
|
|
||||||
it('caps at exactly 60% at and past the deadline', async () => {
|
it('caps at exactly 60% at and past the deadline', async () => {
|
||||||
for (const [name, clock] of [
|
for (const [name, clock] of [
|
||||||
['ab-deadline.db', T0 + WINDOW_MS],
|
['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
|
||||||
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||||||
] as const) {
|
] as const) {
|
||||||
const inst = await setupLoaded(name);
|
const inst = await setupLoaded(name);
|
||||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||||
@@ -735,7 +707,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
|||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('repairing');
|
expect(plane.status).toBe('repairing');
|
||||||
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,14 +173,6 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||||
}
|
}
|
||||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
|
||||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
|
||||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
|
||||||
// + dispatch are atomic under the sim lock.
|
|
||||||
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
|
|
||||||
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
|
||||||
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
|
|
||||||
await inst.db.update(schema.simState).set(sets);
|
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+96
-9
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
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,
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
REWARD_PER_KM,
|
REWARD_PER_KM,
|
||||||
rewardFor,
|
rewardFor,
|
||||||
STARTING_CASH,
|
STARTING_CASH,
|
||||||
|
STARTING_CLOCK_MS,
|
||||||
STARTING_REPUTATION,
|
STARTING_REPUTATION,
|
||||||
type Rng,
|
type Rng,
|
||||||
type SeedAirport,
|
type SeedAirport,
|
||||||
@@ -73,7 +77,7 @@ describe('buildSeed', () => {
|
|||||||
expect(c.originIcao).not.toBe(c.destIcao);
|
expect(c.originIcao).not.toBe(c.destIcao);
|
||||||
expect(c.status).toBe('open');
|
expect(c.status).toBe('open');
|
||||||
expect(c.planeId).toBeNull();
|
expect(c.planeId).toBeNull();
|
||||||
expect(c.createdSimTime).toBe(NOW);
|
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
|
||||||
}
|
}
|
||||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||||
});
|
});
|
||||||
@@ -101,22 +105,23 @@ describe('buildSeed', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
|
it('sets deadlines inside the 6–72h game-time window after the game-clock origin (scaled by any special status)', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
|
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
|
||||||
|
// ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
|
||||||
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
|
expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 72 * 3_600_000 * scale);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds the starting account and sim state', () => {
|
it('seeds the starting account and sim state', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
|
// ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
|
||||||
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
|
// the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
|
||||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
|
expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||||
@@ -186,7 +191,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
const expected: string[] = [];
|
const expected: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
|
||||||
|
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
|
||||||
}
|
}
|
||||||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
||||||
@@ -433,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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+31
-10
@@ -1,5 +1,7 @@
|
|||||||
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 { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
|
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)
|
||||||
@@ -75,6 +77,10 @@ export interface SeedData {
|
|||||||
export const STARTING_CASH = 20_000;
|
export const STARTING_CASH = 20_000;
|
||||||
export const STARTING_REPUTATION = 10;
|
export const STARTING_REPUTATION = 10;
|
||||||
|
|
||||||
|
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
|
||||||
|
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
|
||||||
|
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
|
||||||
|
|
||||||
// The starter plane grounds at a large central-European hub. If the ICAO is
|
// The starter plane grounds at a large central-European hub. If the ICAO is
|
||||||
// ever missing from the dataset we fall back to the first airport rather than
|
// ever missing from the dataset we fall back to the first airport rather than
|
||||||
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
||||||
@@ -118,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
|
||||||
@@ -152,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[];
|
||||||
@@ -225,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);
|
||||||
@@ -291,20 +311,21 @@ export function buildSeed(input: {
|
|||||||
|
|
||||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||||
// Stable index-based ids for the seed set; runtime-generated entities must use
|
// Stable index-based ids for the seed set; runtime-generated entities must use
|
||||||
// random UUIDs to avoid colliding with these.
|
// random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock
|
||||||
|
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate.
|
||||||
const packages: SeedPackage[] = [];
|
const packages: SeedPackage[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
|
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plane,
|
plane,
|
||||||
packages,
|
packages,
|
||||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
// ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
|
||||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
// is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
|
||||||
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
|
// install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
|
||||||
// selected manually yet, so resume would restore 1× anyway.
|
// stability — no game code reads them anymore.
|
||||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
|
simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -73,7 +73,9 @@ export interface PackageState {
|
|||||||
|
|
||||||
export interface StateSnapshot {
|
export interface StateSnapshot {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
||||||
|
// only the clock itself is reported here.
|
||||||
|
simState: { clockMs: number };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||||
@@ -135,7 +137,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
||||||
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
|
simState: { clockMs: simRow.clockMs },
|
||||||
catalog: catalogEntries(),
|
catalog: catalogEntries(),
|
||||||
fuelPricePerL: FUEL_PRICE_PER_L,
|
fuelPricePerL: FUEL_PRICE_PER_L,
|
||||||
sellFraction: SELL_FRACTION,
|
sellFraction: SELL_FRACTION,
|
||||||
|
|||||||
+77
-1
@@ -8,12 +8,30 @@ 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 },
|
||||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
simState: { clockMs: 0 },
|
||||||
planes: [],
|
planes: [],
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: [
|
catalog: [
|
||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
+3
-26
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
|
it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
'/api/airports',
|
'/api/airports',
|
||||||
'/api/market/buy-plane',
|
'/api/market/buy-plane',
|
||||||
@@ -15,7 +15,6 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
|
||||||
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
]);
|
]);
|
||||||
@@ -147,36 +146,14 @@ describe('OpenAPI spec', () => {
|
|||||||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ISS-117 (v0.52.0): the snapshot carries only the game clock — time controls are gone.
|
||||||
const sim = openApiSpec.components.schemas.SimState.properties;
|
const sim = openApiSpec.components.schemas.SimState.properties;
|
||||||
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
|
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
|
||||||
|
|
||||||
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
||||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
|
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
|
|
||||||
const post = openApiSpec.paths['/api/sim'].post;
|
|
||||||
expect(post.operationId).toBe('setSimControls');
|
|
||||||
|
|
||||||
const requestSchema = post.requestBody.content['application/json'].schema;
|
|
||||||
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
|
|
||||||
|
|
||||||
const ok = post.responses[200].content['application/json'].schema;
|
|
||||||
expect(ok.$ref).toBe('#/components/schemas/SimState');
|
|
||||||
const bad = post.responses[400].content['application/json'].schema;
|
|
||||||
expect(bad.$ref).toBe('#/components/schemas/Error');
|
|
||||||
|
|
||||||
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
|
|
||||||
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
|
|
||||||
expect(controls.timeScale.type).toBe('number');
|
|
||||||
expect(controls.timeScale.minimum).toBe(1);
|
|
||||||
expect(controls.timeScale.maximum).toBe(1000);
|
|
||||||
expect(controls.paused.type).toBe('boolean');
|
|
||||||
|
|
||||||
const error = openApiSpec.components.schemas.Error.properties;
|
|
||||||
expect(Object.keys(error)).toEqual(['error']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||||
// The spec object is a literal type; these helpers view it as plain JSON.
|
// The spec object is a literal type; these helpers view it as plain JSON.
|
||||||
const post = (p: string) =>
|
const post = (p: string) =>
|
||||||
|
|||||||
+3
-52
@@ -37,7 +37,7 @@ export const openApiSpec = {
|
|||||||
get: {
|
get: {
|
||||||
summary: 'Game state snapshot',
|
summary: 'Game state snapshot',
|
||||||
description:
|
description:
|
||||||
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. The world clock advances on a ~1 s background tick at a fixed rate (v0.52.0, ISS-117: no player-facing time controls), clamped per tick; while no plane is flying or in repair the clock stays frozen. Each call ticks first, so expiry/delivery/spawn are always applied before the snapshot is returned.',
|
||||||
operationId: 'getState',
|
operationId: 'getState',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
@@ -51,40 +51,6 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/sim': {
|
|
||||||
post: {
|
|
||||||
summary: 'Set sim time controls',
|
|
||||||
description:
|
|
||||||
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.',
|
|
||||||
operationId: 'setSimControls',
|
|
||||||
requestBody: {
|
|
||||||
required: false,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/SimControlsRequest' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: 'Updated sim state',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/SimState' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400: {
|
|
||||||
description: 'Invalid body (not an object, unknown field, or wrong types)',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/api/version': {
|
'/api/version': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'App version',
|
summary: 'App version',
|
||||||
@@ -493,24 +459,9 @@ export const openApiSpec = {
|
|||||||
SimState: {
|
SimState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time). The world runs at a fixed rate with no player-facing controls (v0.52.0, ISS-117).' },
|
||||||
timeScale: { type: 'number', minimum: 1, maximum: 1000 },
|
|
||||||
paused: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
required: ['clockMs', 'timeScale', 'paused'],
|
|
||||||
},
|
|
||||||
SimControlsRequest: {
|
|
||||||
type: 'object',
|
|
||||||
description: 'Partial update — omit a field to leave it unchanged.',
|
|
||||||
properties: {
|
|
||||||
timeScale: {
|
|
||||||
type: 'number',
|
|
||||||
minimum: 1,
|
|
||||||
maximum: 1000,
|
|
||||||
description: 'Game-time multiplier; values outside [1, 1000] are clamped. The UI offers the ladder 1/2/5/10/25/50/100/250/500/1000.',
|
|
||||||
},
|
|
||||||
paused: { type: 'boolean' },
|
|
||||||
},
|
},
|
||||||
|
required: ['clockMs'],
|
||||||
},
|
},
|
||||||
LoadPackageRequest: {
|
LoadPackageRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
+71
-143
@@ -10,26 +10,33 @@ 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 { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
clampTimeScale,
|
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expirePackages,
|
||||||
finishRepairs,
|
finishRepairs,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
MAX_TIME_SCALE,
|
|
||||||
MIN_TIME_SCALE,
|
|
||||||
reputationGainFor,
|
reputationGainFor,
|
||||||
|
shouldAdvanceTime,
|
||||||
spawnCoveragePackages,
|
spawnCoveragePackages,
|
||||||
spawnOpenPackages,
|
spawnOpenPackages,
|
||||||
tickOnce,
|
tickOnce,
|
||||||
|
WORLD_TIME_SCALE,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
|
|
||||||
// Engine tests run against throwaway DB files (openGameDb) with fake timers so
|
// Engine tests run against throwaway DB files (openGameDb) with fake timers so
|
||||||
// Date.now() — the only wall-clock input to a tick — is fully controlled. The
|
// Date.now() — the only wall-clock input to a tick — is fully controlled. The
|
||||||
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered
|
// public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
|
||||||
// by app/api/sim/route.test.ts and 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.
|
||||||
|
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
}
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
|
||||||
@@ -53,43 +60,46 @@ 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)
|
||||||
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 });
|
||||||
|
|
||||||
const sim = await tickOnce(inst);
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 5_000); // 1 s × 5
|
||||||
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('freezes the clock while paused but keeps tracking real time', async () => {
|
it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
|
||||||
const inst = await freshDb('pause.db');
|
const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
|
||||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
|
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 3_600_000); // an hour of real pause
|
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(T0); // frozen — no advance at all
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
|
||||||
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
|
||||||
|
|
||||||
// Unpause: resumes from where it stopped, with NO catch-up for the pause.
|
// A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
|
||||||
await inst.db.update(schema.simState).set({ paused: false });
|
await inst.db.update(schema.simState).set({ timeScale: 60 });
|
||||||
|
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(T0 + 500 * 60); // only the 500 ms since unpausing × 60
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const inst = await freshDb('clamp.db');
|
const inst = await freshDb('clamp.db');
|
||||||
|
await makeWorldActive(inst);
|
||||||
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
|
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
|
||||||
|
|
||||||
const sim = await tickOnce(inst); // timeScale is the seeded 1
|
const sim = await tickOnce(inst); // timeScale is the seeded WORLD_TIME_SCALE
|
||||||
|
|
||||||
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward
|
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * WORLD_TIME_SCALE); // exactly one clamped tick, not a fast-forward
|
||||||
vi.setSystemTime(T0); // reset for later tests in this file
|
vi.setSystemTime(T0); // reset for later tests in this file
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not advance when two ticks land in the same millisecond', async () => {
|
it('does not advance when two ticks land in the same millisecond', async () => {
|
||||||
const inst = await freshDb('same-ms.db');
|
const inst = await freshDb('same-ms.db');
|
||||||
|
await makeWorldActive(inst);
|
||||||
vi.setSystemTime(T0 + 1_000);
|
vi.setSystemTime(T0 + 1_000);
|
||||||
const a = await tickOnce(inst);
|
const a = await tickOnce(inst);
|
||||||
const b = await tickOnce(inst); // no real time passed since `a`
|
const b = await tickOnce(inst); // no real time passed since `a`
|
||||||
@@ -100,15 +110,17 @@ describe('tickOnce — clock advance', () => {
|
|||||||
describe('expirePackages', () => {
|
describe('expirePackages', () => {
|
||||||
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
|
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
|
||||||
const inst = await freshDb('expiry.db');
|
const inst = await freshDb('expiry.db');
|
||||||
const clock = T0 + 100_000;
|
// Game-clock anchor just after the seed moment — well inside every seeded package's deadline
|
||||||
|
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
|
||||||
|
const clock = STARTING_CLOCK_MS + 100_000;
|
||||||
|
|
||||||
await inst.db.insert(schema.packages).values([
|
await inst.db.insert(schema.packages).values([
|
||||||
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
|
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
|
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
|
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const n = await expirePackages(inst, clock);
|
const n = await expirePackages(inst, clock);
|
||||||
@@ -127,9 +139,9 @@ describe('expirePackages', () => {
|
|||||||
const inst = await freshDb('expiry-edge.db');
|
const inst = await freshDb('expiry-edge.db');
|
||||||
await inst.db.insert(schema.packages).values({
|
await inst.db.insert(schema.packages).values({
|
||||||
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||||
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
|
deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
|
||||||
});
|
});
|
||||||
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -516,11 +528,13 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
|||||||
|
|
||||||
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||||
const inst = await freshDb('repair-tick.db');
|
const inst = await freshDb('repair-tick.db');
|
||||||
|
// The repairing plane keeps the world advancing (ISS-117); scale pinned to 1 for clean math.
|
||||||
|
await inst.db.update(schema.simState).set({ timeScale: 1 });
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + 1_500 })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock STARTING_CLOCK_MS+2000 ≥ gate
|
||||||
await tickOnce(inst);
|
await tickOnce(inst);
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
@@ -560,101 +574,35 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
|
||||||
const FROM = 'EDDF';
|
it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
|
||||||
const TO = 'LEMD';
|
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: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
|
||||||
|
expect(shouldAdvanceTime([{ status: 'repairing' }])).toEqual({ advance: true, flying: 0, repairing: 1 });
|
||||||
|
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
|
it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
|
||||||
// any advancing tick past T0 finalizes it. timeScale is the value under test;
|
const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
|
||||||
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
|
vi.setSystemTime(T0 + 3_600_000);
|
||||||
async function setupLanding(name: string, timeScale: number) {
|
|
||||||
const inst = await freshDb(name);
|
|
||||||
const fc = await loadAirportsGeoJson();
|
|
||||||
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
|
||||||
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
|
|
||||||
await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repairing plane keeps the world advancing', async () => {
|
||||||
|
const inst = await freshDb('idle-repair.db');
|
||||||
await inst.db.update(schema.planes)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
.set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
return { inst, from, km, type };
|
vi.setSystemTime(T0 + 1_000);
|
||||||
}
|
|
||||||
|
|
||||||
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
|
|
||||||
const { inst } = await setupLanding('slow-last.db', 1000);
|
|
||||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
|
||||||
|
|
||||||
const sim = await tickOnce(inst);
|
const sim = await tickOnce(inst);
|
||||||
|
|
||||||
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
|
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
||||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
|
|
||||||
expect(sim.timeScale).toBe(1); // …slowed to the floor…
|
|
||||||
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
|
|
||||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
|
|
||||||
const { inst, from, type } = await setupLanding('slow-other.db', 1000);
|
|
||||||
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
|
|
||||||
await inst.db.insert(schema.planes).values({
|
|
||||||
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
|
|
||||||
locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 2_000);
|
|
||||||
const sim = await tickOnce(inst);
|
|
||||||
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
|
|
||||||
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
|
|
||||||
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
|
|
||||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
|
|
||||||
});
|
|
||||||
|
|
||||||
it('already at 1× → stays 1 (no-op slow)', async () => {
|
|
||||||
const { inst } = await setupLanding('slow-one.db', 1);
|
|
||||||
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
|
|
||||||
|
|
||||||
const sim = await tickOnce(inst);
|
|
||||||
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
expect(sim.timeScale).toBe(1);
|
|
||||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
|
|
||||||
const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0
|
|
||||||
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
|
|
||||||
await inst.db.update(schema.planes)
|
|
||||||
.set({ queue: JSON.stringify(['EDDV']) })
|
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
|
||||||
const sim = await tickOnce(inst);
|
|
||||||
|
|
||||||
expect(sim.paused).toBe(false);
|
|
||||||
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
|
||||||
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
|
|
||||||
expect(plane.destIcao).toBe('EDDV');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
|
|
||||||
const inst = await freshDb('slow-paused.db');
|
|
||||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
|
||||||
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
|
|
||||||
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
|
|
||||||
|
|
||||||
vi.setSystemTime(T0 + 5_000);
|
|
||||||
const sim = await tickOnce(inst);
|
|
||||||
|
|
||||||
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
|
|
||||||
expect(sim.timeScale).toBe(1000);
|
|
||||||
expect(sim.lastSelectedTimeScale).toBe(1000);
|
|
||||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -789,7 +737,13 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
|||||||
|
|
||||||
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
|
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
|
||||||
const inst = await freshDb('coverage-tick.db');
|
const inst = await freshDb('coverage-tick.db');
|
||||||
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
|
// A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
|
||||||
|
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here.
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
|
||||||
|
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
|
||||||
|
});
|
||||||
|
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
||||||
|
|
||||||
await tickOnce(inst);
|
await tickOnce(inst);
|
||||||
|
|
||||||
@@ -800,32 +754,6 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clampTimeScale — v0.14.0 ladder bounds [1, 1000]', () => {
|
|
||||||
it('clamps below-minimum values to MIN_TIME_SCALE', () => {
|
|
||||||
expect(clampTimeScale(0)).toBe(MIN_TIME_SCALE);
|
|
||||||
expect(clampTimeScale(-5)).toBe(MIN_TIME_SCALE);
|
|
||||||
expect(clampTimeScale(0.1)).toBe(MIN_TIME_SCALE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps above-maximum values to MAX_TIME_SCALE', () => {
|
|
||||||
expect(clampTimeScale(1001)).toBe(MAX_TIME_SCALE);
|
|
||||||
expect(clampTimeScale(2000)).toBe(MAX_TIME_SCALE);
|
|
||||||
expect(clampTimeScale(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIME_SCALE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes in-range values through unclamped — ladder and off-ladder alike', () => {
|
|
||||||
for (const s of [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]) {
|
|
||||||
expect(clampTimeScale(s)).toBe(s); // the full v0.14.0 slider ladder
|
|
||||||
}
|
|
||||||
expect(clampTimeScale(37)).toBe(37); // off-ladder but in range (e.g. a legacy value)
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the endpoints exact', () => {
|
|
||||||
expect(clampTimeScale(MIN_TIME_SCALE)).toBe(MIN_TIME_SCALE);
|
|
||||||
expect(clampTimeScale(MAX_TIME_SCALE)).toBe(MAX_TIME_SCALE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
||||||
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
||||||
function travelMs(km: number, speedKmh: number): number {
|
function travelMs(km: number, speedKmh: number): number {
|
||||||
|
|||||||
+48
-61
@@ -18,20 +18,20 @@ import {
|
|||||||
} from '@/lib/game/seed';
|
} from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
|
|
||||||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
// Server-authoritative sim engine (v0.7.0). ISS-117 (v0.52.0): the clock advances on a background
|
||||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
// tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
|
||||||
// (real ms since last tick) × timeScale, clamped per tick so long absences never
|
// same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
|
||||||
// fast-forward the game.
|
// 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.
|
||||||
|
|
||||||
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms —
|
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
|
||||||
// comfortably above a normal client poll interval (~1 s), so active play advances
|
// both the client poll interval (~1 s) and the background-tick interval, so active play advances
|
||||||
// ~real time × scale; small enough that one stale read after an absence moves game
|
// ~real time × scale; small enough that one stale read after an absence moves game time by at most
|
||||||
// time by at most 2 s × scale (≈ 33 min even at max scale 1000). The sim therefore
|
// 2 s × scale.
|
||||||
// only meaningfully progresses while actively polled.
|
|
||||||
export const MAX_TICK_ELAPSED_MS = 2_000;
|
export const MAX_TICK_ELAPSED_MS = 2_000;
|
||||||
|
|
||||||
export const MIN_TIME_SCALE = 1;
|
// ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home.
|
||||||
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0)
|
export { 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,11 +43,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimControlsInput {
|
|
||||||
timeScale?: number;
|
|
||||||
paused?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 tick re-reads sim_state after the previous one wrote it, so
|
// same process): each tick re-reads sim_state after the previous one wrote it, so
|
||||||
// no advance is ever applied twice.
|
// no advance is ever applied twice.
|
||||||
@@ -63,23 +58,36 @@ 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
|
||||||
|
// 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.
|
||||||
|
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
|
||||||
|
let flying = 0;
|
||||||
|
let repairing = 0;
|
||||||
|
for (const p of planes) {
|
||||||
|
if (p.status === 'flying') flying++;
|
||||||
|
else if (p.status === 'repairing') repairing++;
|
||||||
|
}
|
||||||
|
return { advance: flying > 0 || repairing > 0, flying, repairing };
|
||||||
|
}
|
||||||
|
|
||||||
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||||
const rows = await inst.db.select().from(schema.simState);
|
const rows = await inst.db.select().from(schema.simState);
|
||||||
const sim = rows[0];
|
const sim = rows[0];
|
||||||
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
|
||||||
|
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for 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 elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
|
||||||
// While paused the clock is frozen; lastRealTickMs still tracks real time so
|
const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
|
||||||
// unpausing resumes cleanly with no catch-up jump for the pause duration.
|
|
||||||
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
|
|
||||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||||
|
|
||||||
let landedThisTick = 0;
|
|
||||||
if (advanceMs > 0) {
|
if (advanceMs > 0) {
|
||||||
await expirePackages(inst, clockMs);
|
await expirePackages(inst, clockMs);
|
||||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
await deliverPackages(inst, clockMs);
|
||||||
landedThisTick = await deliverPackages(inst, clockMs);
|
|
||||||
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||||
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||||
await finishRepairs(inst, clockMs);
|
await finishRepairs(inst, clockMs);
|
||||||
@@ -89,25 +97,8 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
|||||||
await spawnCoveragePackages(inst, clockMs);
|
await spawnCoveragePackages(inst, clockMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
|
|
||||||
clockMs,
|
|
||||||
lastRealTickMs: nowMs,
|
|
||||||
};
|
|
||||||
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
|
|
||||||
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
|
|
||||||
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
|
|
||||||
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
|
|
||||||
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
|
|
||||||
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
|
|
||||||
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
|
|
||||||
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
|
|
||||||
if (landedThisTick > 0) {
|
|
||||||
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
|
||||||
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||||
await inst.db.update(schema.simState).set(sets);
|
await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
const updated = (await inst.db.select().from(schema.simState))[0];
|
const updated = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!updated) throw new Error('sim_state row missing after tick');
|
if (!updated) throw new Error('sim_state row missing after tick');
|
||||||
@@ -174,8 +165,7 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
|
|||||||
// remaining fuel and the queue kept intact for a later dispatch.
|
// remaining fuel and the queue kept intact for a later dispatch.
|
||||||
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||||||
// leg whose own arrival is in the future, so it can never be awarded twice.
|
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
// Returns planes landed.
|
||||||
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
|
|
||||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
const [planeRows, packageRows] = await Promise.all([
|
const [planeRows, packageRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.planes),
|
inst.db.select().from(schema.planes),
|
||||||
@@ -423,6 +413,17 @@ export function tickNow(): Promise<schema.SimStateRow> {
|
|||||||
return withLock(async () => tickOnce(await getGameDb()));
|
return withLock(async () => tickOnce(await getGameDb()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
|
||||||
|
// tick path as reads/actions. Started once at server boot from instrumentation.ts (Next ≥15); the
|
||||||
|
// guard makes double-starts harmless (dev HMR reloads).
|
||||||
|
let backgroundTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
export function startBackgroundTick(intervalMs = 1_000): void {
|
||||||
|
if (backgroundTimer !== null) return;
|
||||||
|
backgroundTimer = setInterval(() => {
|
||||||
|
tickNow().catch((err) => console.error('flight: background tick failed', err));
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
|
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
|
||||||
// advanced FIRST so validation and mutation see current game time, then fn runs —
|
// advanced FIRST so validation and mutation see current game time, then fn runs —
|
||||||
// all under the same promise lock as tickNow(), so no concurrent request can
|
// all under the same promise lock as tickNow(), so no concurrent request can
|
||||||
@@ -436,31 +437,17 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick first so the returned state reflects current time, then apply the new
|
// Test-hook backing (FLIGHT_TEST_HOOKS=1 only — see app/api/test/set-timescale/route.ts): set the
|
||||||
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
|
// effective time scale under the same lock as ticks so a background tick can never interleave
|
||||||
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
|
// between read and write. Input validation happens in the route; this trusts its number.
|
||||||
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
|
export function setTestTimeScale(scale: number): Promise<schema.SimStateRow> {
|
||||||
// the last grounded plane takes off. A {paused}-only post never touches it, so the
|
|
||||||
// last manual intent wins without an extra flag.
|
|
||||||
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
|
|
||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
await tickOnce(inst);
|
await tickOnce(inst);
|
||||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
|
await inst.db.update(schema.simState).set({ timeScale: scale });
|
||||||
if (input.timeScale !== undefined) {
|
|
||||||
const scale = clampTimeScale(input.timeScale);
|
|
||||||
sets.timeScale = scale;
|
|
||||||
sets.lastSelectedTimeScale = scale;
|
|
||||||
}
|
|
||||||
if (input.paused !== undefined) sets.paused = input.paused;
|
|
||||||
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
|
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
const updated = (await inst.db.select().from(schema.simState))[0];
|
const updated = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!updated) throw new Error('sim_state row missing');
|
if (!updated) throw new Error('sim_state row missing');
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampTimeScale(v: number): number {
|
|
||||||
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { MAX_SCALE, MIN_SCALE, TIME_SCALES, scaleToSlider, sliderToScale } from './timeScale';
|
|
||||||
|
|
||||||
const STEPS = TIME_SCALES.length - 1; // 9 intervals across the 10 ladder positions (v0.14.0)
|
|
||||||
|
|
||||||
describe('sliderToScale', () => {
|
|
||||||
it('is exact at the endpoints (0 → 1×, 1 → 1000×)', () => {
|
|
||||||
expect(sliderToScale(0)).toBe(MIN_SCALE);
|
|
||||||
expect(sliderToScale(1)).toBe(MAX_SCALE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps each of the 10 positions to the ladder', () => {
|
|
||||||
const expected = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000];
|
|
||||||
for (let i = 0; i < TIME_SCALES.length; i++) {
|
|
||||||
expect(sliderToScale(i / STEPS)).toBe(expected[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is monotonic non-decreasing across the whole range', () => {
|
|
||||||
let prev = sliderToScale(0);
|
|
||||||
for (let i = 1; i <= 2000; i++) {
|
|
||||||
const s = sliderToScale(i / 2000);
|
|
||||||
expect(s).toBeGreaterThanOrEqual(prev);
|
|
||||||
prev = s;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only ever returns a ladder value', () => {
|
|
||||||
for (let i = 0; i <= 2000; i++) {
|
|
||||||
expect(TIME_SCALES).toContain(sliderToScale(i / 2000));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps out-of-range and non-finite input', () => {
|
|
||||||
expect(sliderToScale(-0.5)).toBe(MIN_SCALE);
|
|
||||||
expect(sliderToScale(1.5)).toBe(MAX_SCALE);
|
|
||||||
expect(sliderToScale(NaN)).toBe(MIN_SCALE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scaleToSlider', () => {
|
|
||||||
it('is exact at the endpoints (1× → 0, 1000× → 1)', () => {
|
|
||||||
expect(scaleToSlider(1)).toBe(0);
|
|
||||||
expect(scaleToSlider(1000)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('places each ladder value on its own position', () => {
|
|
||||||
for (let i = 0; i < TIME_SCALES.length; i++) {
|
|
||||||
expect(scaleToSlider(TIME_SCALES[i])).toBeCloseTo(i / STEPS, 12);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays within [0, 1] for in-range scales', () => {
|
|
||||||
for (let s = MIN_SCALE; s <= MAX_SCALE; s++) {
|
|
||||||
const t = scaleToSlider(s);
|
|
||||||
expect(t).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(t).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps out-of-range and non-finite input', () => {
|
|
||||||
expect(scaleToSlider(0)).toBe(0);
|
|
||||||
expect(scaleToSlider(-3)).toBe(0);
|
|
||||||
expect(scaleToSlider(1001)).toBe(1); // above the ladder top → clamped to 1000× → t=1
|
|
||||||
expect(scaleToSlider(NaN)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('round-trip', () => {
|
|
||||||
it('sliderToScale(scaleToSlider(s)) === s for every ladder value', () => {
|
|
||||||
for (const s of TIME_SCALES) {
|
|
||||||
expect(sliderToScale(scaleToSlider(s))).toBe(s);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps off-ladder scales to the nearest ladder step by log distance', () => {
|
|
||||||
expect(sliderToScale(scaleToSlider(3))).toBe(2); // ln 3 ≈ 1.10 → closer to 2 (ln ≈ 0.69) than 5 (ln ≈ 1.61)
|
|
||||||
expect(sliderToScale(scaleToSlider(60))).toBe(50); // legacy max from the v0.9.x era sits between 50 and 100, nearer 50
|
|
||||||
expect(sliderToScale(scaleToSlider(200))).toBe(250); // ln 200 ≈ 5.30 → closer to 250 (ln ≈ 5.52) than 100 (ln ≈ 4.61)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Pure mapping helpers for the time-scale slider (v0.9.2, values reworked in v0.14.0) — no
|
|
||||||
// game logic, unit-testable in node. The slider has 10 discrete positions on a mixed
|
|
||||||
// ×2/×2.5 ladder: 1× → 2× → 5× → 10× → 25× → 50× → 100× → 250× → 500× → 1000×, so each
|
|
||||||
// step roughly doubles the speed (matching POST /api/sim's clamp range [1, 1000]).
|
|
||||||
|
|
||||||
export const TIME_SCALES = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000] as const;
|
|
||||||
export const MIN_SCALE: number = TIME_SCALES[0];
|
|
||||||
export const MAX_SCALE: number = TIME_SCALES[TIME_SCALES.length - 1];
|
|
||||||
|
|
||||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
||||||
|
|
||||||
// t → scale: the position maps to the nearest ladder step. Exact at the endpoints:
|
|
||||||
// sliderToScale(0) === 1, sliderToScale(1) === 1000.
|
|
||||||
export function sliderToScale(t: number): number {
|
|
||||||
const tt = clamp(Number.isFinite(t) ? t : 0, 0, 1);
|
|
||||||
return TIME_SCALES[clamp(Math.round(tt * (TIME_SCALES.length - 1)), 0, TIME_SCALES.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// scale → t: the nearest ladder position by log distance. Exact at the endpoints:
|
|
||||||
// scaleToSlider(1) === 0, scaleToSlider(1000) === 1; round-trips every value in TIME_SCALES.
|
|
||||||
export function scaleToSlider(scale: number): number {
|
|
||||||
const s = clamp(Number.isFinite(scale) ? scale : MIN_SCALE, MIN_SCALE, MAX_SCALE);
|
|
||||||
let best = 0;
|
|
||||||
let bestDist = Infinity;
|
|
||||||
for (let i = 0; i < TIME_SCALES.length; i++) {
|
|
||||||
const d = Math.abs(Math.log(s) - Math.log(TIME_SCALES[i]));
|
|
||||||
if (d < bestDist) {
|
|
||||||
bestDist = d;
|
|
||||||
best = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best / (TIME_SCALES.length - 1);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { fetchState } from './useGameState';
|
|||||||
|
|
||||||
const SNAPSHOT = {
|
const SNAPSHOT = {
|
||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false },
|
simState: { clockMs: 1_750_000_000_000 },
|
||||||
planes: [],
|
planes: [],
|
||||||
packages: [],
|
packages: [],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ export async function fetchState(): Promise<StateSnapshot> {
|
|||||||
(window as unknown as Record<string, unknown>).__flightLastSnapshot = {
|
(window as unknown as Record<string, unknown>).__flightLastSnapshot = {
|
||||||
planeIds: snapshot.planes.map((p) => p.id),
|
planeIds: snapshot.planes.map((p) => p.id),
|
||||||
cash: snapshot.account.cash,
|
cash: snapshot.account.cash,
|
||||||
timeScale: snapshot.simState.timeScale,
|
clockMs: snapshot.simState.clockMs,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-18
@@ -1,49 +1,45 @@
|
|||||||
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';
|
||||||
|
|
||||||
// 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 × timeScale, frozen while paused.
|
// this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
|
||||||
|
|
||||||
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
||||||
clockMs: 1_750_000_000_000,
|
clockMs: 1_750_000_000_000,
|
||||||
at: 1_000_000,
|
at: 1_000_000,
|
||||||
timeScale: 1,
|
advancing: true,
|
||||||
paused: false,
|
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('advanceSimClock', () => {
|
describe('advanceSimClock', () => {
|
||||||
it('advances by exactly elapsed real-time × timeScale while running', () => {
|
it('advances by exactly elapsed real-time × WORLD_TIME_SCALE while running', () => {
|
||||||
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000); // scale 1: 1 s real = 1 s game
|
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000 * WORLD_TIME_SCALE);
|
||||||
expect(advanceSimClock(anchor({ timeScale: 25 }), 1_002_500)).toBe(1_750_000_000_000 + 62_500);
|
// The e2e scenario: ~2.6 s real at the fixed world rate.
|
||||||
// The e2e scenario: scale 1000, 2.5 s real → 2 500 game seconds.
|
expect(advanceSimClock(anchor(), 1_002_600)).toBe(1_750_000_000_000 + 2_600 * WORLD_TIME_SCALE);
|
||||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_002_500)).toBe(1_750_000_000_000 + 2_500_000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the anchor value exactly at zero elapsed', () => {
|
it('returns the anchor value exactly at zero elapsed', () => {
|
||||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 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 paused regardless of elapsed or scale', () => {
|
it('is frozen while idle regardless of elapsed time', () => {
|
||||||
const a = anchor({ timeScale: 1000, paused: true });
|
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);
|
||||||
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still 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', () => {
|
||||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 999_999)).toBe(1_750_000_000_000);
|
expect(advanceSimClock(anchor(), 999_999)).toBe(1_750_000_000_000);
|
||||||
expect(advanceSimClock(anchor({ paused: true }), 999_999)).toBe(1_750_000_000_000);
|
expect(advanceSimClock(anchor({ advancing: false }), 999_999)).toBe(1_750_000_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mirrors the server tick model (tickOnce): advance = elapsed × timeScale while actively polled', () => {
|
it('mirrors the server tick model (tickOnce): advance = elapsed × WORLD_TIME_SCALE while actively polled', () => {
|
||||||
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
|
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
|
||||||
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
|
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
|
||||||
const scale = 50;
|
|
||||||
for (const gap of [1, 250, 999, 1_999]) {
|
for (const gap of [1, 250, 999, 1_999]) {
|
||||||
expect(advanceSimClock(anchor({ timeScale: scale }), 1_000_000 + gap)).toBe(
|
expect(advanceSimClock(anchor(), 1_000_000 + gap)).toBe(1_750_000_000_000 + gap * WORLD_TIME_SCALE);
|
||||||
1_750_000_000_000 + gap * scale,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-25
@@ -2,57 +2,51 @@
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
// 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 × timeScale
|
// 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.
|
// 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.
|
||||||
|
|
||||||
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.
|
||||||
clockMs: number;
|
clockMs: number;
|
||||||
// Date.now() when that snapshot was received.
|
// Date.now() when that snapshot was received.
|
||||||
at: number;
|
at: number;
|
||||||
timeScale: number;
|
// Whether the world clock is advancing (any plane flying or in repair).
|
||||||
paused: boolean;
|
advancing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by
|
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||||||
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to
|
// 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.
|
// 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.paused) return anchor.clockMs;
|
if (!anchor.advancing) return anchor.clockMs;
|
||||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale;
|
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_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.
|
||||||
const DISPLAY_TICK_MS = 100;
|
const DISPLAY_TICK_MS = 100;
|
||||||
|
|
||||||
export interface SimClockControls {
|
|
||||||
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
|
|
||||||
// UI changes take effect on click/drag without waiting for a confirming snapshot.
|
|
||||||
timeScale: number;
|
|
||||||
paused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the display clockMs to render, or null until the first snapshot arrives.
|
// Returns the display clockMs to render, or null until the first snapshot arrives.
|
||||||
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null {
|
export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
||||||
const anchorRef = useRef<SimClockAnchor | null>(null);
|
const anchorRef = useRef<SimClockAnchor | null>(null);
|
||||||
const [displayMs, setDisplayMs] = useState<number | null>(null);
|
const [displayMs, setDisplayMs] = useState<number | null>(null);
|
||||||
|
|
||||||
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot
|
// Re-sync on every snapshot: snap to server truth. The snapshot object is fresh per poll
|
||||||
// object is fresh per poll (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;
|
||||||
anchorRef.current = {
|
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
|
||||||
clockMs: snapshot.simState.clockMs,
|
// flying or in repair. One line on purpose — keep it in sync with that function.
|
||||||
at: Date.now(),
|
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||||||
timeScale: controls.timeScale,
|
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||||||
paused: controls.paused,
|
|
||||||
};
|
|
||||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||||
}, [snapshot, controls.timeScale, controls.paused]);
|
}, [snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// ISS-117 (v0.52.0): the world clock runs at a fixed rate — no player-facing time controls.
|
||||||
|
// 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;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.51.0",
|
"version": "0.54.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user