test: enforce simulation and render budgets
This commit is contained in:
+13
-2
@@ -47,8 +47,8 @@ art; every shipped dependency, route, font, and asset has recorded provenance.
|
||||
|
||||
## M1 — California roads and passive Model X traffic
|
||||
|
||||
Status: **implemented and rendered on desktop and mobile; performance-budget
|
||||
instrumentation remains**.
|
||||
Status: **implemented and rendered on desktop and mobile; repeatable California
|
||||
and office browser performance-budget instrumentation is implemented**.
|
||||
|
||||
- Coarse California board plus detailed Bay Area and SoCal boards.
|
||||
- Serializable US-101 and I-5/I-580/I-80 route graphs.
|
||||
@@ -137,6 +137,17 @@ validation, and no regression to ambient live ADS-B rendering.
|
||||
|
||||
## Performance gates
|
||||
|
||||
Run `npm run build && npm run performance`. The harness serves the production
|
||||
bundle locally, uses system Chrome (real GPU when available, SwiftShader as the
|
||||
headless fallback), warms each scene, and writes `/tmp/tera-performance-budget.json`.
|
||||
Budgets live in `scripts/performance-budgets.json`; a threshold, console error,
|
||||
insufficient sample, or anonymous private endpoint request makes the command
|
||||
non-zero. Use `--output`, `--budgets`, `--warmup-ms`, `--sample-ms`, or
|
||||
`--software` for explicit CI/diagnostic runs. The named matrix is California and
|
||||
Office at 1440×900 desktop and 390×844 mobile viewports. Frame comparisons allow
|
||||
only 0.1 ms for Chrome's rAF timestamp quantization; the declared and reported
|
||||
budgets are not raised.
|
||||
|
||||
- Named benchmark scenes: p95 frame at or below 16.7 ms desktop and 33.3 ms on
|
||||
the selected supported mobile tier.
|
||||
- Hard budgets per scale for resident cells, triangles, draw calls, dynamic
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
|
||||
"test": "node --test \"src/test/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"performance": "node scripts/performance-budget.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"satellite.js": "^7.1.0",
|
||||
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env node
|
||||
/** Repeatable anonymous WebGL performance gate over a built Tera bundle. */
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const DIST = join(ROOT, "dist");
|
||||
const DEFAULT_BUDGETS = join(ROOT, "scripts", "performance-budgets.json");
|
||||
const PRIVATE_PATH = /^\/api\/v1\/(?:media|realtime|presence|offices)(?:\/|$)|^\/assets\/(?:remoteMedia|presenceIndicator|scenePeers)-/;
|
||||
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json" };
|
||||
const VIEWPORTS = {
|
||||
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
||||
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
||||
};
|
||||
const SCENES = {
|
||||
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
|
||||
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function option(name, fallback) {
|
||||
const at = args.indexOf(`--${name}`);
|
||||
return at < 0 ? fallback : args[at + 1];
|
||||
}
|
||||
function positive(name, fallback) {
|
||||
const value = Number(option(name, fallback));
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`);
|
||||
return value;
|
||||
}
|
||||
function sceneBudget(value, label) {
|
||||
if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`);
|
||||
for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) {
|
||||
if (typeof value[key] !== "number" || !Number.isFinite(value[key]) || value[key] <= 0) {
|
||||
throw new Error(`${label}.${key} must be a positive number`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const outputPath = resolve(option("output", "/tmp/tera-performance-budget.json"));
|
||||
const budgetPath = resolve(option("budgets", DEFAULT_BUDGETS));
|
||||
const warmupMs = positive("warmup-ms", 3_000);
|
||||
const sampleMs = positive("sample-ms", 8_000);
|
||||
const readyTimeoutMs = positive("ready-timeout-ms", 180_000);
|
||||
const softwareOnly = args.includes("--software");
|
||||
// Chrome exposes rAF timestamps at 0.1 ms precision, so an ideal 60 Hz cadence
|
||||
// can quantize to 16.8 ms. This tolerance is measurement resolution, not budget
|
||||
// headroom; reported values and declared budgets remain unchanged.
|
||||
const FRAME_COMPARISON_EPSILON_MS = 0.1;
|
||||
|
||||
function percentile(values, fraction) {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((a, b) => a - b);
|
||||
return ordered[Math.max(0, Math.ceil(ordered.length * fraction) - 1)];
|
||||
}
|
||||
const rounded = (value) => value === null ? null : Math.round(value * 1_000) / 1_000;
|
||||
|
||||
async function serve() {
|
||||
const requests = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://local.invalid");
|
||||
requests.push({ method: req.method ?? "GET", path: url.pathname });
|
||||
if (url.pathname === "/api/v1/health") {
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" } }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/session") {
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ authenticated: false, subject: null, passwordLogin: false, admin: false }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/v1/")) {
|
||||
res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
|
||||
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
|
||||
const target = resolve(DIST, `.${relative}`);
|
||||
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
|
||||
try {
|
||||
const body = await readFile(target);
|
||||
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
|
||||
res.end(body);
|
||||
return;
|
||||
} catch { /* SPA fallback */ }
|
||||
}
|
||||
res.writeHead(404).end("not found");
|
||||
});
|
||||
await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); });
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("performance server did not expose a TCP port");
|
||||
return { server, requests, port: address.port };
|
||||
}
|
||||
|
||||
const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"];
|
||||
async function rendererOf(browser) {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto("about:blank");
|
||||
return await page.evaluate(() => {
|
||||
const gl = document.createElement("canvas").getContext("webgl2");
|
||||
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
|
||||
return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
|
||||
});
|
||||
} finally { await page.close(); }
|
||||
}
|
||||
async function launchBrowser(port) {
|
||||
const resolver = `--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1, MAP office.lumbridgecorp.com 127.0.0.1`;
|
||||
const attempts = softwareOnly
|
||||
? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]
|
||||
: [["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]];
|
||||
let last;
|
||||
for (const [backend, flags] of attempts) {
|
||||
try {
|
||||
const browser = await chromium.launch({ channel: "chrome", args: [...COMMON_ARGS, resolver, ...flags] });
|
||||
const renderer = await rendererOf(browser);
|
||||
const softwareRenderer = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
|
||||
if (renderer && (backend === "swiftshader" || !softwareRenderer)) return { browser, backend, renderer, port };
|
||||
await browser.close();
|
||||
} catch (error) { last = error; }
|
||||
}
|
||||
for (const executablePath of ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]) {
|
||||
try {
|
||||
await access(executablePath);
|
||||
const browser = await chromium.launch({ executablePath, args: [...COMMON_ARGS, resolver, "--use-gl=angle", "--use-angle=swiftshader"] });
|
||||
return { browser, backend: "system-chrome-swiftshader", renderer: await rendererOf(browser), port };
|
||||
} catch (error) { last = error; }
|
||||
}
|
||||
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
|
||||
}
|
||||
|
||||
function instrumentation() {
|
||||
// Custom production hostnames mapped to loopback are not secure contexts over
|
||||
// HTTP, so Chrome withholds randomUUID even though getRandomValues remains.
|
||||
// Production is HTTPS; this shim only restores that API in the local harness.
|
||||
if (typeof crypto.randomUUID !== "function") {
|
||||
crypto.randomUUID = () => {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
};
|
||||
}
|
||||
const state = { frames: [], longTasks: [], drawCalls: [], triangles: [], currentCalls: 0, currentTriangles: 0, last: 0, longTaskSupported: false };
|
||||
Object.defineProperty(globalThis, "__teraPerformanceBudget", { value: state });
|
||||
const triangleCount = (mode, count) => mode === 4 ? count / 3 : (mode === 5 || mode === 6) ? Math.max(0, count - 2) : 0;
|
||||
const patch = (prototype, method, countAt, instancesAt = null) => {
|
||||
if (!prototype || typeof prototype[method] !== "function") return;
|
||||
const original = prototype[method];
|
||||
if (original.__teraPerformancePatched) return;
|
||||
const wrapped = function (...values) {
|
||||
state.currentCalls += 1;
|
||||
const instances = instancesAt === null ? 1 : Number(values[instancesAt]) || 0;
|
||||
state.currentTriangles += triangleCount(Number(values[0]), Number(values[countAt]) || 0) * instances;
|
||||
return original.apply(this, values);
|
||||
};
|
||||
Object.defineProperty(wrapped, "__teraPerformancePatched", { value: true });
|
||||
prototype[method] = wrapped;
|
||||
};
|
||||
for (const prototype of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) {
|
||||
patch(prototype, "drawArrays", 2);
|
||||
patch(prototype, "drawElements", 1);
|
||||
patch(prototype, "drawArraysInstanced", 2, 3);
|
||||
patch(prototype, "drawElementsInstanced", 1, 4);
|
||||
}
|
||||
try {
|
||||
new PerformanceObserver((list) => state.longTasks.push(...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })))).observe({ type: "longtask", buffered: true });
|
||||
state.longTaskSupported = true;
|
||||
} catch { /* Long Tasks API is optional. */ }
|
||||
requestAnimationFrame(function sample(now) {
|
||||
if (state.last > 0) {
|
||||
state.frames.push(now - state.last);
|
||||
state.drawCalls.push(state.currentCalls);
|
||||
state.triangles.push(state.currentTriangles);
|
||||
}
|
||||
state.last = now;
|
||||
state.currentCalls = 0;
|
||||
state.currentTriangles = 0;
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
}
|
||||
|
||||
async function measure(browser, port, sceneName, viewportName, budget, requestLog) {
|
||||
const viewport = VIEWPORTS[viewportName];
|
||||
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch });
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => consoleErrors.push(String(error)));
|
||||
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
const before = requestLog.length;
|
||||
await page.addInitScript(instrumentation);
|
||||
const scene = SCENES[sceneName];
|
||||
const url = `http://${scene.host}:${port}/`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs });
|
||||
try {
|
||||
await page.waitForFunction(scene.ready, null, { timeout: readyTimeoutMs });
|
||||
} catch (error) {
|
||||
const state = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
bootHidden: document.getElementById("boot")?.hidden ?? null,
|
||||
bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null,
|
||||
chapters: document.querySelectorAll("#chapters .chapter").length,
|
||||
enter: document.getElementById("enter")?.textContent?.trim() ?? null,
|
||||
})).catch(() => null);
|
||||
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
|
||||
}
|
||||
await page.waitForTimeout(warmupMs);
|
||||
await page.evaluate(() => {
|
||||
const state = globalThis.__teraPerformanceBudget;
|
||||
state.frames.length = state.drawCalls.length = state.triangles.length = state.longTasks.length = 0;
|
||||
state.last = performance.now(); state.currentCalls = 0; state.currentTriangles = 0;
|
||||
});
|
||||
await page.waitForTimeout(sampleMs);
|
||||
const raw = await page.evaluate(() => {
|
||||
const state = globalThis.__teraPerformanceBudget;
|
||||
const canvas = document.querySelector("canvas");
|
||||
const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl");
|
||||
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
|
||||
return {
|
||||
frames: [...state.frames], drawCalls: [...state.drawCalls], triangles: [...state.triangles],
|
||||
longTasks: [...state.longTasks], longTaskSupported: state.longTaskSupported,
|
||||
renderer: extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null,
|
||||
};
|
||||
});
|
||||
const relevantRequests = requestLog.slice(before);
|
||||
const privateRequests = relevantRequests.filter((request) => PRIVATE_PATH.test(request.path));
|
||||
const metrics = {
|
||||
frameSamples: raw.frames.length,
|
||||
p50FrameIntervalMs: rounded(percentile(raw.frames, 0.5)),
|
||||
p95FrameIntervalMs: rounded(percentile(raw.frames, 0.95)),
|
||||
p99FrameIntervalMs: rounded(percentile(raw.frames, 0.99)),
|
||||
maxFrameIntervalMs: rounded(raw.frames.length ? Math.max(...raw.frames) : null),
|
||||
p95DrawCalls: rounded(percentile(raw.drawCalls, 0.95)),
|
||||
maxDrawCalls: raw.drawCalls.length ? Math.max(...raw.drawCalls) : null,
|
||||
p95Triangles: rounded(percentile(raw.triangles, 0.95)),
|
||||
maxTriangles: raw.triangles.length ? Math.max(...raw.triangles) : null,
|
||||
longTasksSupported: raw.longTaskSupported,
|
||||
longTaskCount: raw.longTasks.length,
|
||||
longTaskTotalMs: raw.longTasks.reduce((sum, task) => sum + task.duration, 0),
|
||||
};
|
||||
const checks = {
|
||||
p95FrameIntervalMs: metrics.p95FrameIntervalMs !== null &&
|
||||
Math.round(metrics.p95FrameIntervalMs * 10) <=
|
||||
Math.round((budget.p95FrameIntervalMs + FRAME_COMPARISON_EPSILON_MS) * 10),
|
||||
maxDrawCalls: metrics.maxDrawCalls !== null && metrics.maxDrawCalls <= budget.maxDrawCalls,
|
||||
maxTriangles: metrics.maxTriangles !== null && metrics.maxTriangles <= budget.maxTriangles,
|
||||
anonymousPrivateRequests: privateRequests.length === 0,
|
||||
consoleErrors: consoleErrors.length === 0,
|
||||
enoughFrameSamples: metrics.frameSamples >= Math.max(30, Math.floor(sampleMs / 100)),
|
||||
};
|
||||
return { scene: sceneName, viewport: viewportName, url, viewportPixels: viewport, renderer: raw.renderer, budget, metrics, checks, passed: Object.values(checks).every(Boolean), privateRequests, consoleErrors };
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
|
||||
let server;
|
||||
let browser;
|
||||
try {
|
||||
await access(join(DIST, "index.html"));
|
||||
const budgets = JSON.parse(await readFile(budgetPath, "utf8"));
|
||||
const hosted = await serve(); server = hosted.server;
|
||||
const launched = await launchBrowser(hosted.port); browser = launched.browser;
|
||||
const results = [];
|
||||
for (const scene of Object.keys(SCENES)) for (const viewport of Object.keys(VIEWPORTS)) {
|
||||
process.stderr.write(`performance-budget: ${scene}/${viewport}\n`);
|
||||
const budget = sceneBudget(budgets?.scenes?.[scene]?.[viewport], `${scene}.${viewport}`);
|
||||
results.push(await measure(browser, hosted.port, scene, viewport, budget, hosted.requests));
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1, generatedAt: new Date().toISOString(), browserPlugin: "not available; Playwright system Chrome fallback used",
|
||||
browser: { backend: launched.backend, renderer: launched.renderer }, warmupMs, sampleMs,
|
||||
frameComparisonEpsilonMs: FRAME_COMPARISON_EPSILON_MS,
|
||||
budgets: budgetPath, passed: results.every((result) => result.passed), results,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stderr.write(`performance-budget: ${report.passed ? "PASS" : "FAIL"} — ${outputPath}\n`);
|
||||
if (!report.passed) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
console.error(`performance-budget: ERROR — ${error instanceof Error ? error.stack : String(error)}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser?.close().catch(() => undefined);
|
||||
if (server) await new Promise((resolveClose) => server.close(resolveClose));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"scenes": {
|
||||
"california": {
|
||||
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 },
|
||||
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 }
|
||||
},
|
||||
"office": {
|
||||
"desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 },
|
||||
"mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ const PROVIDER_CREDENTIAL: ScreenShareCredential = {
|
||||
sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value",
|
||||
};
|
||||
const iceRequest = (credential: ScreenShareCredential = PROVIDER_CREDENTIAL, binding: ScreenShareBinding = BINDING) => ({
|
||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", binding, credential,
|
||||
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1",
|
||||
binding: { ...binding }, credential: { ...credential },
|
||||
} as const);
|
||||
const request = iceRequest();
|
||||
const rateKeys = (subject = "auth-subject", trustedIp = "203.0.113.8") => ({ subject, trustedIp });
|
||||
@@ -221,7 +222,8 @@ describe("authenticated ICE route", () => {
|
||||
const missing = { ...iceRequest(presenter) } as Record<string, unknown>;
|
||||
delete missing.credential;
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: missing })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) })).statusCode, 401);
|
||||
const stolen = await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) });
|
||||
assert.equal(stolen.statusCode, 401, stolen.body);
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter, SECOND_BINDING) })).statusCode, 401);
|
||||
|
||||
const joined = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Fast deterministic lifecycle soak for the in-memory realtime/media services.
|
||||
* It intentionally uses fake clocks and direct service APIs: no sockets, sleeps,
|
||||
* filesystem writes, or environment-specific infrastructure.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { ActorPoseSnapshot, ResumeRequest } from "../../../src/realtime/types.ts";
|
||||
import type {
|
||||
ScreenShareBinding,
|
||||
ScreenShareCredential,
|
||||
ScreenShareCreateRequest,
|
||||
ScreenShareJoinRequest,
|
||||
} from "../../../src/media/signalingTypes.ts";
|
||||
import type { IceConfigRequest } from "../../../src/media/iceTypes.ts";
|
||||
import type { IceConfig } from "../config.ts";
|
||||
import { createIceCredentialProvider, createMediaSignalService } from "../media/index.ts";
|
||||
import { createRealtimeService } from "../realtime/index.ts";
|
||||
|
||||
const MEDIA_SESSIONS = 32;
|
||||
const VIEWERS_PER_SHARE = 3;
|
||||
const REALTIME_SESSIONS = 128;
|
||||
|
||||
describe("bounded in-memory service soak", () => {
|
||||
it("rotates media grants, releases listeners/queues, and drains every session", () => {
|
||||
let now = 1_000_000;
|
||||
const service = createMediaSignalService({
|
||||
now: () => now,
|
||||
maximumSessions: MEDIA_SESSIONS,
|
||||
maximumParticipantsPerSession: VIEWERS_PER_SHARE + 1,
|
||||
maximumQueuedMessagesPerParticipant: 8,
|
||||
maximumSignalsPerWindow: 16,
|
||||
grantTtlMs: 90_000,
|
||||
});
|
||||
const credentials: Array<{ credential: ScreenShareCredential; subject: string }> = [];
|
||||
const unsubscribedDeliveries: number[] = [];
|
||||
const presenters: Array<{
|
||||
binding: ScreenShareBinding;
|
||||
subject: string;
|
||||
credential: ScreenShareCredential<"presenter">;
|
||||
}> = [];
|
||||
|
||||
for (let sessionIndex = 0; sessionIndex < MEDIA_SESSIONS; sessionIndex += 1) {
|
||||
const binding = mediaBinding(sessionIndex);
|
||||
const presenterSubject = `presenter-subject-${sessionIndex}`;
|
||||
const created = service.join(mediaCreate(binding, sessionIndex), presenterSubject);
|
||||
assert.ok(created.ok && created.value.type === "screen-share-create-grant");
|
||||
if (!created.ok || created.value.type !== "screen-share-create-grant") continue;
|
||||
let presenterCredential = created.value.grant.credential;
|
||||
const viewers: Array<{ subject: string; credential: ScreenShareCredential; sequence: number }> = [];
|
||||
|
||||
for (let viewerIndex = 0; viewerIndex < VIEWERS_PER_SHARE; viewerIndex += 1) {
|
||||
const subject = `viewer-subject-${sessionIndex}-${viewerIndex}`;
|
||||
const joined = service.join(mediaJoin(binding, sessionIndex, viewerIndex), subject);
|
||||
assert.ok(joined.ok && joined.value.type === "screen-share-join-grant");
|
||||
if (!joined.ok || joined.value.type !== "screen-share-join-grant") continue;
|
||||
viewers.push({ subject, credential: joined.value.grant.credential, sequence: joined.value.sequence });
|
||||
}
|
||||
assert.equal(viewers.length, VIEWERS_PER_SHARE);
|
||||
|
||||
const presenterResume = service.resume({
|
||||
type: "screen-share-resume-request", protocolVersion: 1,
|
||||
requestId: `presenter-resume-${sessionIndex}`, sequence: 2, timestampMs: now,
|
||||
binding, credential: presenterCredential, lastReceivedSequence: created.value.sequence,
|
||||
}, presenterSubject);
|
||||
assert.equal(presenterResume.ok, true);
|
||||
if (!presenterResume.ok) continue;
|
||||
assert.notEqual(presenterResume.value.grant.credential.grantToken, presenterCredential.grantToken);
|
||||
assert.equal(presenterResume.value.grant.credential.role, "presenter");
|
||||
if (presenterResume.value.grant.credential.role !== "presenter") continue;
|
||||
presenterCredential = presenterResume.value.grant.credential as ScreenShareCredential<"presenter">;
|
||||
credentials.push({ credential: presenterCredential, subject: presenterSubject });
|
||||
|
||||
for (const [viewerIndex, viewer] of viewers.entries()) {
|
||||
const resumed = service.resume({
|
||||
type: "screen-share-resume-request", protocolVersion: 1,
|
||||
requestId: `viewer-resume-${sessionIndex}-${viewerIndex}`, sequence: 2, timestampMs: now,
|
||||
binding, credential: viewer.credential, lastReceivedSequence: viewer.sequence,
|
||||
}, viewer.subject);
|
||||
assert.equal(resumed.ok, true);
|
||||
if (!resumed.ok) continue;
|
||||
const rotated = resumed.value.grant.credential;
|
||||
credentials.push({ credential: rotated, subject: viewer.subject });
|
||||
let deliveries = 0;
|
||||
const subscription = service.subscribe(rotated, viewer.subject, () => { deliveries += 1; });
|
||||
assert.equal(subscription.ok, true);
|
||||
if (!subscription.ok) continue;
|
||||
subscription.value();
|
||||
const before = deliveries;
|
||||
const offered = service.signal({
|
||||
type: "screen-share-signal-request", protocolVersion: 1, sequence: 3 + viewerIndex, timestampMs: now,
|
||||
binding, credential: presenterCredential, targetParticipantId: rotated.participantId,
|
||||
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
|
||||
}, presenterSubject);
|
||||
assert.equal(offered.ok, true);
|
||||
unsubscribedDeliveries.push(deliveries - before);
|
||||
}
|
||||
|
||||
presenters.push({ binding, subject: presenterSubject, credential: presenterCredential });
|
||||
now += 1;
|
||||
}
|
||||
|
||||
assert.equal(service.sessionCount(), MEDIA_SESSIONS);
|
||||
assert.equal(credentials.length, MEDIA_SESSIONS * (VIEWERS_PER_SHARE + 1));
|
||||
for (const [index, presenter] of presenters.entries()) {
|
||||
const stopped = service.leave({
|
||||
type: "screen-share-stop-request", protocolVersion: 1,
|
||||
requestId: `stop-${index}`, sequence: 3 + VIEWERS_PER_SHARE,
|
||||
timestampMs: now, binding: presenter.binding,
|
||||
credential: presenter.credential, reason: "presenter-stopped",
|
||||
}, presenter.subject, false);
|
||||
assert.equal(stopped.ok, true);
|
||||
}
|
||||
|
||||
assert.ok(unsubscribedDeliveries.every((count) => count === 0), "unsubscribed listeners receive no later offers");
|
||||
assert.equal(service.sessionCount(), 0);
|
||||
assert.ok(credentials.every(({ credential, subject }) =>
|
||||
!service.revalidate(credential, subject).ok
|
||||
), "destroyed sessions retain no usable grant for its original owner");
|
||||
});
|
||||
|
||||
it("rotates realtime tokens, disconnects listeners, rate-limits, and expires all sessions", () => {
|
||||
let now = 2_000_000;
|
||||
const service = createRealtimeService({
|
||||
now: () => now,
|
||||
maximumSessions: REALTIME_SESSIONS,
|
||||
maximumSessionsPerCell: REALTIME_SESSIONS,
|
||||
maximumPoseUpdatesPerWindow: 4,
|
||||
rateWindowMs: 10_000,
|
||||
disconnectGraceMs: 500,
|
||||
sessionTtlMs: 30_000,
|
||||
});
|
||||
const sessions: Array<{ sessionId: string; token: string; actorId: string; subject: string }> = [];
|
||||
let disconnectedDeliveries = 0;
|
||||
|
||||
for (let index = 0; index < REALTIME_SESSIONS; index += 1) {
|
||||
const actorId = `soak-actor-${index}`;
|
||||
const subject = `soak-subject-${index}`;
|
||||
const joined = service.join({
|
||||
requestId: `soak-join-${index}`, actorId, subject, role: "member",
|
||||
interests: [{ kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" }],
|
||||
});
|
||||
assert.equal(joined.ok, true);
|
||||
if (!joined.ok) continue;
|
||||
let token = joined.value.token;
|
||||
const connected = service.connect(joined.value.sessionId, token, () => { disconnectedDeliveries += 1; });
|
||||
assert.equal(connected.ok, true);
|
||||
if (connected.ok) connected.value.disconnect();
|
||||
|
||||
for (let rotation = 0; rotation < 3; rotation += 1) {
|
||||
const request: ResumeRequest = {
|
||||
type: "resume-request", protocolVersion: 1,
|
||||
requestId: `soak-resume-${index}-${rotation}`,
|
||||
sessionId: joined.value.sessionId, resumeToken: token,
|
||||
serverEpoch: joined.value.grant.serverEpoch, lastReceivedSequence: 0,
|
||||
interests: joined.value.grant.interests,
|
||||
};
|
||||
const resumed = service.resume(request, subject);
|
||||
assert.equal(resumed.ok, true);
|
||||
if (!resumed.ok) break;
|
||||
assert.notEqual(resumed.value.resumeToken, token);
|
||||
token = resumed.value.resumeToken;
|
||||
}
|
||||
sessions.push({ sessionId: joined.value.sessionId, token, actorId, subject });
|
||||
}
|
||||
|
||||
assert.equal(sessions.length, REALTIME_SESSIONS);
|
||||
assert.equal(service.sessionCount(), REALTIME_SESSIONS);
|
||||
const publisher = sessions[0] as (typeof sessions)[number];
|
||||
for (let sequence = 1; sequence <= 5; sequence += 1) {
|
||||
now += 100;
|
||||
const result = service.submitPose(
|
||||
publisher.sessionId,
|
||||
publisher.token,
|
||||
actorPose(publisher.actorId, sequence, now, sequence / 100),
|
||||
);
|
||||
assert.equal(result.ok, sequence <= 4);
|
||||
if (sequence === 5 && !result.ok) assert.equal(result.code, "rate-limited");
|
||||
}
|
||||
assert.equal(disconnectedDeliveries, 0, "disconnected realtime listeners receive no broadcast");
|
||||
|
||||
for (let index = 0; index < sessions.length / 2; index += 1) {
|
||||
const session = sessions[index] as (typeof sessions)[number];
|
||||
assert.equal(service.leave(session.sessionId, session.token).ok, true);
|
||||
}
|
||||
assert.equal(service.sessionCount(), REALTIME_SESSIONS / 2);
|
||||
now += 501;
|
||||
assert.equal(service.cleanup(), REALTIME_SESSIONS / 2);
|
||||
assert.equal(service.sessionCount(), 0);
|
||||
assert.ok(sessions.every((session) => !service.authenticate(session.sessionId, session.token).ok));
|
||||
});
|
||||
|
||||
it("bounds ICE issuance across subject and address churn without retaining credentials", () => {
|
||||
let now = 3_000_000;
|
||||
const provider = createIceCredentialProvider(iceConfig(), { now: () => now });
|
||||
const credential: ScreenShareCredential = {
|
||||
sessionId: "soak-media-session", participantId: "soak-participant",
|
||||
role: "presenter", grantToken: "soak-private-grant-token",
|
||||
};
|
||||
const binding = mediaBinding(999);
|
||||
const request: IceConfigRequest = {
|
||||
type: "ice-config-request", protocolVersion: 1, requestId: "soak-ice",
|
||||
binding, credential,
|
||||
};
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const result = provider.issue(request, {
|
||||
subject: "same-subject",
|
||||
trustedIp: `203.0.113.${attempt}`,
|
||||
}, now + 90_000);
|
||||
assert.equal(result.ok, attempt <= 4, "subject limit survives address rotation");
|
||||
}
|
||||
now += 10_000;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const result = provider.issue(request, {
|
||||
subject: `rotating-subject-${attempt}`,
|
||||
trustedIp: "198.51.100.10",
|
||||
}, now + 90_000);
|
||||
assert.equal(result.ok, attempt <= 4, "address limit survives subject rotation");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function mediaBinding(index: number): ScreenShareBinding {
|
||||
return { officeId: "soak-office", levelId: "level-1", roomId: "room", screenId: `screen-${index}` };
|
||||
}
|
||||
|
||||
function mediaCreate(binding: ScreenShareBinding, index: number): ScreenShareCreateRequest {
|
||||
return {
|
||||
type: "screen-share-create-request", protocolVersion: 1, requestId: `create-${index}`,
|
||||
sequence: 1, timestampMs: 1_000_000, binding, role: "presenter",
|
||||
};
|
||||
}
|
||||
|
||||
function mediaJoin(binding: ScreenShareBinding, session: number, viewer: number): ScreenShareJoinRequest {
|
||||
return {
|
||||
type: "screen-share-join-request", protocolVersion: 1, requestId: `join-${session}-${viewer}`,
|
||||
sequence: 1, timestampMs: 1_000_000, binding, role: "viewer", viewerOptIn: true,
|
||||
};
|
||||
}
|
||||
|
||||
function actorPose(actorId: string, sequence: number, timestampMs: number, xM: number): ActorPoseSnapshot {
|
||||
return {
|
||||
entity: "actor", actorId, kind: "humanoid", sequence, timestampMs,
|
||||
pose: {
|
||||
space: "local", cell: { kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" },
|
||||
xM, yM: 0, zM: 0, headingDeg: 0, pitchDeg: 0,
|
||||
},
|
||||
velocity: { xMps: 0.1, yMps: 0, zMps: 0, yawDegPerSec: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function iceConfig(): IceConfig {
|
||||
return {
|
||||
configured: true,
|
||||
urls: ["turn:relay.example.test:3478"],
|
||||
sharedSecret: "soak-turn-shared-secret-with-at-least-thirty-two-bytes",
|
||||
credentialTtlSeconds: 60,
|
||||
rateAttempts: 4,
|
||||
rateWindowSeconds: 10,
|
||||
};
|
||||
}
|
||||
@@ -50,12 +50,17 @@ function screenBinding(value: unknown): boolean {
|
||||
|
||||
function screenCredential(value: unknown): boolean {
|
||||
return exact(value, ["sessionId", "participantId", "role", "grantToken"]) &&
|
||||
identifier(value.sessionId) && identifier(value.participantId) &&
|
||||
opaqueIdentifier(value.sessionId) && opaqueIdentifier(value.participantId) &&
|
||||
(value.role === "presenter" || value.role === "viewer") &&
|
||||
typeof value.grantToken === "string" && value.grantToken.length >= 16 &&
|
||||
value.grantToken.length <= 512;
|
||||
}
|
||||
|
||||
/** Server credentials use unpadded base64url and may begin with `_` or `-`. */
|
||||
function opaqueIdentifier(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-zA-Z0-9_-]{1,256}$/.test(value);
|
||||
}
|
||||
|
||||
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
|
||||
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
|
||||
return failure("invalid response envelope");
|
||||
|
||||
@@ -38,6 +38,10 @@ const grant = (): IceConfigGrant => ({
|
||||
describe("ICE configuration wire contract", () => {
|
||||
it("accepts exact requests and bounded ephemeral grants", () => {
|
||||
assert.equal(parseIceConfigRequest(request()).ok, true);
|
||||
assert.equal(parseIceConfigRequest({
|
||||
...request(),
|
||||
credential: { ...request().credential, sessionId: "_opaque-session", participantId: "-opaque-participant" },
|
||||
}).ok, true, "accepts every server-generated base64url prefix");
|
||||
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
|
||||
assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
|
||||
assert.equal(isIceConfigGrantActive(grant(), 601_000), false);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { ActorController, type ActorControllerOptions } from "../actors/controller.ts";
|
||||
import { AircraftController, type AircraftWaypoint } from "../aircraft/controller.ts";
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import type { Level, Office, Room, Wall } from "../interiors/types.ts";
|
||||
import { createWalker } from "../interiors/walker.ts";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import { VehicleController } from "../transport/vehicleController.ts";
|
||||
import { buildRoutePath, VehicleSimulation } from "../transport/vehicleSim.ts";
|
||||
|
||||
const SOAK_TIMEOUT_MS = 5_000;
|
||||
const HOUR_AT_TEN_HZ = 36_000;
|
||||
|
||||
function finiteNumbers(value: unknown, path = "state"): void {
|
||||
if (typeof value === "number") {
|
||||
assert.ok(Number.isFinite(value), `${path} must remain finite`);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return;
|
||||
for (const [key, nested] of Object.entries(value)) finiteNumbers(nested, `${path}.${key}`);
|
||||
}
|
||||
|
||||
function inside(value: number, minimum: number, maximum: number, label: string): void {
|
||||
assert.ok(value >= minimum && value <= maximum, `${label} ${value} outside [${minimum}, ${maximum}]`);
|
||||
}
|
||||
|
||||
function countForwardWrap(previous: number, next: number): number {
|
||||
return previous > 0.9 && next < 0.1 ? 1 : 0;
|
||||
}
|
||||
|
||||
describe("accelerated long-duration deterministic simulation", () => {
|
||||
it("runs both California routes and ambient traffic through repeated completions", { timeout: SOAK_TIMEOUT_MS }, () => {
|
||||
for (const routeId of ["la-sf-us-101", "la-sf-i-5"] as const) {
|
||||
const path = buildRoutePath(CALIFORNIA_TRANSPORT, routeId);
|
||||
const first = new VehicleSimulation(CALIFORNIA_TRANSPORT, { routeId, count: 24, seed: 115, timeScale: 900 });
|
||||
const replay = new VehicleSimulation(CALIFORNIA_TRANSPORT, { routeId, count: 24, seed: 115, timeScale: 900 });
|
||||
let wraps = 0;
|
||||
let prior = first.poses()[0]?.progress ?? 0;
|
||||
|
||||
// 15 rendered minutes at the production 900x traffic scale is more than
|
||||
// nine simulated days, advanced in bounded 20 Hz fixed steps.
|
||||
for (let frame = 0; frame < 3_600; frame += 1) {
|
||||
first.tick(0.25);
|
||||
replay.tick(0.25);
|
||||
const hero = first.poses()[0];
|
||||
assert.ok(hero);
|
||||
wraps += countForwardWrap(prior, hero.progress);
|
||||
prior = hero.progress;
|
||||
if (frame % 60 !== 0) continue;
|
||||
for (const pose of first.poses()) {
|
||||
finiteNumbers(pose, `${routeId}.${pose.id}`);
|
||||
inside(pose.progress, 0, 1, `${pose.id}.progress`);
|
||||
inside(pose.distanceM, 0, path.lengthM, `${pose.id}.distanceM`);
|
||||
inside(pose.lat, 32, 43, `${pose.id}.lat`);
|
||||
inside(pose.lng, -125, -113, `${pose.id}.lng`);
|
||||
}
|
||||
}
|
||||
assert.ok(wraps >= 10, `${routeId} hero completed only ${wraps} circuits`);
|
||||
assert.deepEqual(first.poses(), replay.poses(), `${routeId} seeded traffic drifted on replay`);
|
||||
}
|
||||
});
|
||||
|
||||
it("drives the playable vehicle for an accelerated hour without drift or escape", { timeout: SOAK_TIMEOUT_MS }, () => {
|
||||
const options = {
|
||||
routeId: "la-sf-us-101",
|
||||
fixedStepSeconds: 0.1,
|
||||
initialSpeedMps: 20,
|
||||
travelScale: 900,
|
||||
guardrailOffsetM: 5,
|
||||
} as const;
|
||||
const first = new VehicleController(CALIFORNIA_TRANSPORT, options);
|
||||
const replay = new VehicleController(CALIFORNIA_TRANSPORT, options);
|
||||
const path = buildRoutePath(CALIFORNIA_TRANSPORT, options.routeId);
|
||||
let prior = first.state().progress;
|
||||
let completions = 0;
|
||||
|
||||
for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) {
|
||||
const actions = step % 1_200 < 60
|
||||
? { throttle: 0.75, steering: Math.sin(step / 20) * 0.35 }
|
||||
: step % 1_200 === 60 ? { modeRequest: "assisted" as const } : undefined;
|
||||
first.stepFixed(actions);
|
||||
replay.stepFixed(actions);
|
||||
const state = first.state();
|
||||
completions += countForwardWrap(prior, state.progress);
|
||||
prior = state.progress;
|
||||
if (step % 300 !== 0) continue;
|
||||
finiteNumbers(state);
|
||||
inside(state.progress, 0, 1, "vehicle.progress");
|
||||
inside(state.distanceM, 0, path.lengthM, "vehicle.distanceM");
|
||||
inside(state.lateralOffsetM, -5, 5, "vehicle.lateralOffsetM");
|
||||
inside(state.speedMps, 0, 58, "vehicle.speedMps");
|
||||
}
|
||||
assert.ok(completions >= 20, `playable vehicle completed only ${completions} route circuits`);
|
||||
assert.ok(first.state().elapsedSteps === HOUR_AT_TEN_HZ);
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
});
|
||||
|
||||
it("flies a crow for an accelerated hour inside strict 3D bounds", { timeout: SOAK_TIMEOUT_MS }, () => {
|
||||
const options: ActorControllerOptions = {
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
identity: { id: "soak-crow", displayName: "Soak Crow", authenticated: false, profile: {} },
|
||||
position: { x: 0, y: 25, z: 0 },
|
||||
groundY: 0,
|
||||
minFlightAltitude: 5,
|
||||
maxFlightAltitude: 50,
|
||||
horizontalBounds: { minX: -200, maxX: 200, minZ: -200, maxZ: 200 },
|
||||
fixedStepSeconds: 0.1,
|
||||
};
|
||||
const first = new ActorController(options);
|
||||
const replay = new ActorController(options);
|
||||
const visited = new Set<string>();
|
||||
|
||||
for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) {
|
||||
const phase = step % 1_800;
|
||||
const actions = {
|
||||
forward: 0.65 + 0.3 * Math.sin(step / 173),
|
||||
turn: phase < 900 ? 0.23 : -0.31,
|
||||
pitch: Math.sin(step / 257) * 0.7,
|
||||
climb: Math.sin(step / 401) * 0.8,
|
||||
glide: phase >= 1_500,
|
||||
};
|
||||
first.stepFixed(actions);
|
||||
replay.stepFixed(actions);
|
||||
const state = first.state();
|
||||
if (step % 120 !== 0) continue;
|
||||
finiteNumbers(state);
|
||||
inside(state.x, -200, 200, "crow.x");
|
||||
inside(state.z, -200, 200, "crow.z");
|
||||
inside(state.y, 5, 50, "crow.y");
|
||||
inside(state.posePhase, 0, Math.PI * 2, "crow.posePhase");
|
||||
visited.add(`${Math.round(state.x / 20)}:${Math.round(state.z / 20)}:${Math.round(state.y / 5)}`);
|
||||
}
|
||||
assert.ok(first.state().distanceM > 20_000,
|
||||
`crow accumulated only ${first.state().distanceM.toFixed(1)} m of flight distance`);
|
||||
assert.ok(visited.size > 25, `crow explored only ${visited.size} coarse cells`);
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
});
|
||||
|
||||
it("walks against office walls for an accelerated hour without tunnelling or sticking", { timeout: SOAK_TIMEOUT_MS }, () => {
|
||||
const room: Room = {
|
||||
id: "soak-room", name: "Soak Room", floor: "floor" as never,
|
||||
outline: [{ x: 0, z: 0 }, { x: 30, z: 0 }, { x: 30, z: 20 }, { x: 0, z: 20 }],
|
||||
};
|
||||
const walls: Wall[] = [
|
||||
{ id: "vertical", from: { x: 15, z: 0 }, to: { x: 15, z: 20 },
|
||||
openings: [{ kind: "door", start: 8.5, width: 3, sill: 0, head: 2.2 }] },
|
||||
{ id: "horizontal", from: { x: 0, z: 10 }, to: { x: 12, z: 10 } },
|
||||
];
|
||||
const level: Level = {
|
||||
id: "ground", name: "Ground", elevation: 0, wallHeight: 3, wallThickness: 0.12,
|
||||
floorplan: { rooms: [room], walls },
|
||||
};
|
||||
const plan = new Plan({ id: "soak-office", name: "Soak Office", levels: [level], viewpoints: [] } as Office,
|
||||
{ warn: false });
|
||||
const create = () => createWalker(plan, {
|
||||
levelId: "ground", position: { x: 4, z: 4 }, radius: 0.3,
|
||||
speed: 2.4, fixedStep: 0.1, maxCatchUpSteps: 1,
|
||||
});
|
||||
const first = create();
|
||||
const replay = create();
|
||||
const directions = [
|
||||
{ x: 1, z: 0.23 }, { x: 0.18, z: 1 }, { x: -1, z: -0.17 }, { x: -0.11, z: -1 },
|
||||
] as const;
|
||||
const visited = new Set<string>();
|
||||
|
||||
for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) {
|
||||
const action = directions[Math.floor(step / 450) % directions.length]!;
|
||||
const a = first.tick(0.1, action);
|
||||
replay.tick(0.1, action);
|
||||
if (step % 30 === 0) visited.add(`${Math.round(a.position.x)}:${Math.round(a.position.z)}`);
|
||||
if (step % 120 !== 0) continue;
|
||||
finiteNumbers(a);
|
||||
inside(a.position.x, 0.3, 29.7, "walker.x");
|
||||
inside(a.position.z, 0.3, 19.7, "walker.z");
|
||||
assert.equal(plan.blocked("ground", a.position, a.position, 0.3), false, "walker entered collision geometry");
|
||||
}
|
||||
assert.ok(first.state().distance > 200,
|
||||
`walker accumulated only ${first.state().distance.toFixed(1)} m of distance`);
|
||||
assert.ok(visited.size > 20, `walker visited only ${visited.size} cells`);
|
||||
assert.deepEqual(first.state(), replay.state());
|
||||
});
|
||||
|
||||
it("circuits an assisted aircraft route for an accelerated hour within its envelope", { timeout: SOAK_TIMEOUT_MS }, () => {
|
||||
const route: readonly AircraftWaypoint[] = [
|
||||
{ id: "west", lat: 34.05, lng: -118.28, altitudeM: 1_200 },
|
||||
{ id: "north", lat: 34.13, lng: -118.20, altitudeM: 1_650 },
|
||||
{ id: "east", lat: 34.05, lng: -118.12, altitudeM: 1_350 },
|
||||
];
|
||||
const options = {
|
||||
route,
|
||||
initialPosition: route[0],
|
||||
initialAltitudeM: 1_200,
|
||||
initialHeadingDeg: 45,
|
||||
initialSpeedMps: 55,
|
||||
fixedStepSeconds: 0.1,
|
||||
} as const;
|
||||
const first = new AircraftController(options);
|
||||
const replay = new AircraftController(options);
|
||||
let priorIndex = first.state().routeWaypointIndex;
|
||||
let waypointTransitions = 0;
|
||||
let routeCompletions = 0;
|
||||
|
||||
for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) {
|
||||
first.stepFixed();
|
||||
replay.stepFixed();
|
||||
const state = first.state();
|
||||
if (state.routeWaypointIndex !== priorIndex) {
|
||||
waypointTransitions += 1;
|
||||
if (priorIndex === route.length - 1 && state.routeWaypointIndex === 0) routeCompletions += 1;
|
||||
priorIndex = state.routeWaypointIndex;
|
||||
}
|
||||
if (step % 120 !== 0) continue;
|
||||
finiteNumbers(state);
|
||||
inside(state.lat, 32.4, 42.1, "aircraft.lat");
|
||||
inside(state.lng, -124.6, -114, "aircraft.lng");
|
||||
inside(state.altitudeM, 75, 6_000, "aircraft.altitudeM");
|
||||
inside(state.speedMps, 20, 95, "aircraft.speedMps");
|
||||
inside(state.headingDeg, 0, 360, "aircraft.headingDeg");
|
||||
}
|
||||
assert.ok(waypointTransitions >= 6, `aircraft made only ${waypointTransitions} waypoint transitions`);
|
||||
assert.ok(routeCompletions >= 2, `aircraft completed only ${routeCompletions} circuits`);
|
||||
assert.equal(first.state().elapsedSteps, HOUR_AT_TEN_HZ);
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user