mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
- src/geo drops the ported debug argument checks and the tests that bitExact already covers; package boundaries accept a directory entry, so src/geo/ is listed once per group. - Cesium no longer carries movers and lines nobody draws with: they are optional engine capabilities. The fake engine and the Cesium camera use the shared cameraMath; unused contract tables, clock.ticked, the 'finest' surface detail and Navara click statistics are gone. - ALPR keeps one overlay core and one presentation model for both engines, bikeshare one station bookkeeping, the Navara ground effects one definition helper; image atlases, view centres, mover banks and colours are shared. - The legacy switch route, the clock offset and rate controls and other unused exports are removed. - The acceptance runs the Navara draw, geo import and scope checks; scenarios use the harness switch and page helpers; the main chunk check keeps the size budget only and runs once in CI.
287 lines
9.9 KiB
JavaScript
287 lines
9.9 KiB
JavaScript
// The Cyber HUD sonar on Navara: with the Cyber HUD on, the
|
||
// sweep lights the contacts it passes and dims the rest to the opacity
|
||
// floor, by each contact's place on screen exactly as the CPU sampler of
|
||
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
|
||
// contact takes the neutral factor; a parked scene draws at the fleet
|
||
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
|
||
// gives the contacts back unchanged. On Cesium the command adapter keeps the
|
||
// treatment and the engine draws none. The contacts are an engine point set
|
||
// (engine.movers) in a ring around the screen centre. Keyless preview under
|
||
// the strict default-deny network policy.
|
||
//
|
||
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {
|
||
createStrict,
|
||
localPreview,
|
||
waitForEngine,
|
||
} from './qa-strict-harness.mjs';
|
||
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
|
||
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
|
||
|
||
const base = localPreview(process.argv[2]);
|
||
const outDir = process.argv
|
||
.find((arg) => arg.startsWith('--out='))
|
||
?.slice('--out='.length);
|
||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||
const report = { checks: [] };
|
||
const check = checker(report, assert);
|
||
const WIDTH = 1280;
|
||
const HEIGHT = 800;
|
||
|
||
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
|
||
// always well inside the 24° sector), and one outside the keyhole.
|
||
function contactPlaces() {
|
||
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
|
||
const places = [];
|
||
for (let i = 0; i < 32; i++) {
|
||
const angle = (i / 32) * Math.PI * 2;
|
||
places.push({
|
||
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
|
||
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
|
||
});
|
||
}
|
||
places.push({ x: 120, y: 110 });
|
||
return places;
|
||
}
|
||
|
||
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
|
||
// sonar state that frame drew with.
|
||
const read = (page, places) =>
|
||
page.evaluate(async (places) => {
|
||
const { engine } = window.__godsEyeView;
|
||
const canvas = document.createElement('canvas');
|
||
const source = engine.host.canvas;
|
||
canvas.width = source.width;
|
||
canvas.height = source.height;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
let sonar = null;
|
||
await new Promise((resolve) => {
|
||
const off = engine.frames.postRender.addEventListener(() => {
|
||
off();
|
||
ctx.drawImage(source, 0, 0);
|
||
sonar = engine.render.contactSonar?.() ?? null;
|
||
resolve();
|
||
});
|
||
engine.render.request('qa-sonar');
|
||
});
|
||
const scale = source.width / engine.host.size().width;
|
||
const luma = places.map(({ x, y }) => {
|
||
const data = ctx.getImageData(
|
||
Math.round(x * scale) - 1,
|
||
Math.round(y * scale) - 1,
|
||
3,
|
||
3,
|
||
).data;
|
||
let sum = 0;
|
||
for (let i = 0; i < data.length; i += 4)
|
||
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||
return sum / 9;
|
||
});
|
||
return { luma, sonar };
|
||
}, places);
|
||
|
||
// Frames the engine draws in `ms` without being asked.
|
||
const framesIn = (page, ms) =>
|
||
page.evaluate(async (ms) => {
|
||
const { engine } = window.__godsEyeView;
|
||
let frames = 0;
|
||
const off = engine.frames.postRender.addEventListener(() => frames++);
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
off();
|
||
return frames;
|
||
}, ms);
|
||
|
||
const mean = (values) =>
|
||
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
||
|
||
await strict.run({}, async (browser) => {
|
||
const page = await browser.newPage();
|
||
const errors = [];
|
||
page.on('pageerror', (error) => errors.push(error.message));
|
||
await page.evaluateOnNewDocument(() => {
|
||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||
});
|
||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||
waitUntil: 'domcontentloaded',
|
||
});
|
||
await waitForEngine(page, 'navara');
|
||
await freeze(page);
|
||
|
||
const places = contactPlaces();
|
||
const placed = await page.evaluate((places) => {
|
||
const { engine } = window.__godsEyeView;
|
||
const set = engine.movers.createSet({
|
||
id: 'qa-sonar-contacts',
|
||
kind: 'point',
|
||
capacity: places.length,
|
||
depthTest: 'always',
|
||
point: { sizePx: 14, outlinePx: 0 },
|
||
});
|
||
window.__qaSonarSet = set;
|
||
let missing = 0;
|
||
places.forEach((place, index) => {
|
||
const position = engine.camera.pickEllipsoid(place);
|
||
if (!position) missing++;
|
||
set.set(index, { position, color: [1, 1, 1, 1] });
|
||
});
|
||
set.setCount(places.length);
|
||
set.commit();
|
||
return { missing, sonar: engine.render.contactSonar?.() ?? null };
|
||
}, places);
|
||
check(
|
||
'Navara treats contacts itself; the sonar starts off',
|
||
placed.missing === 0 && placed.sonar?.enabled === false,
|
||
placed,
|
||
);
|
||
const baseline = await read(page, places);
|
||
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setHudVariant('cyber');
|
||
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
|
||
// it would hide itself, and the sweep with it).
|
||
styleManager.setStyle('normal');
|
||
styleManager.hud.setMode('on');
|
||
styleManager._setCyberSonarEnabled(true);
|
||
styleManager._setCyberSonarSetting('opacity', 35);
|
||
});
|
||
await page.waitForFunction(
|
||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||
);
|
||
const theme = await page.evaluate(() => ({
|
||
theme: document.documentElement.dataset.uiTheme,
|
||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||
hud: document.getElementById('intel-hud')?.classList.contains('active'),
|
||
}));
|
||
check(
|
||
'the Cyber HUD runs with the contact adapter',
|
||
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
|
||
theme,
|
||
);
|
||
|
||
const sweeps = [];
|
||
for (const wait of [0, 1300, 1300]) {
|
||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||
const { luma, sonar } = await read(page, places);
|
||
const expected = places.map(({ x, y }) =>
|
||
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
|
||
);
|
||
const lit = luma.filter((_, i) => expected[i] > 0.9);
|
||
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
|
||
sweeps.push({
|
||
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
|
||
angle: sonar.angleDeg,
|
||
lit: lit.length,
|
||
litLuma: mean(lit),
|
||
dimLuma: mean(dim),
|
||
brightest: luma.indexOf(Math.max(...luma)),
|
||
expectedBrightest: expected.indexOf(Math.max(...expected)),
|
||
sonar,
|
||
});
|
||
check(
|
||
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
|
||
sonar.enabled &&
|
||
sonar.active &&
|
||
sonar.opacity === 35 &&
|
||
lit.length >= 1 &&
|
||
dim.length >= 8 &&
|
||
mean(lit) > mean(dim) + 20 &&
|
||
expected[places.length - 1] === 0.35,
|
||
sweeps.at(-1),
|
||
);
|
||
}
|
||
check(
|
||
'the sweep turns: another contact is lit',
|
||
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
|
||
new Set(sweeps.map((s) => s.brightest)).size > 1,
|
||
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
|
||
angle,
|
||
brightest,
|
||
expectedBrightest,
|
||
})),
|
||
);
|
||
const cadence = await framesIn(page, 1500);
|
||
check(
|
||
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
|
||
cadence >= 8,
|
||
{ frames: cadence },
|
||
);
|
||
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
|
||
);
|
||
const neutral = await read(page, places);
|
||
const spread =
|
||
Math.max(...neutral.luma.slice(0, 32)) -
|
||
Math.min(...neutral.luma.slice(0, 32));
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
const idle = await framesIn(page, 1500);
|
||
check(
|
||
'with the sweep off every contact takes the neutral factor and the scene rests',
|
||
neutral.sonar.enabled &&
|
||
!neutral.sonar.active &&
|
||
// What still differs is the map under the translucent contacts.
|
||
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
|
||
idle <= 3,
|
||
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
|
||
);
|
||
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setCyberSonarEnabled(true);
|
||
styleManager._setHudVariant('tactical');
|
||
styleManager.setStyle('normal');
|
||
styleManager.hud.setMode('auto');
|
||
});
|
||
await page.waitForFunction(
|
||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||
);
|
||
const after = await read(page, places);
|
||
const drift = Math.max(
|
||
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
|
||
);
|
||
check(
|
||
'leaving the Cyber HUD gives the contacts back unchanged',
|
||
after.sonar.enabled === false && drift < 3,
|
||
{ drift },
|
||
);
|
||
await page.evaluate(() => window.__qaSonarSet.destroy());
|
||
|
||
// Cesium keeps its command adapter; its engine draws no sonar.
|
||
await strict.switchTo(page, 'cesium');
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setHudVariant('cyber');
|
||
});
|
||
await page.waitForFunction(
|
||
() => document.documentElement.dataset.cyberSonarGpu,
|
||
{ timeout: 10000 },
|
||
);
|
||
const cesium = await page.evaluate(() => ({
|
||
setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar,
|
||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||
}));
|
||
check(
|
||
'Cesium: the command adapter treats contacts, the engine none',
|
||
cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported',
|
||
cesium,
|
||
);
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.styleManager._setHudVariant('tactical'),
|
||
);
|
||
|
||
check('no page errors', errors.length === 0, errors);
|
||
if (outDir) {
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(outDir, 'sonar.json'),
|
||
JSON.stringify(report, null, 2),
|
||
);
|
||
}
|
||
});
|