orbit/dev/tether.test.mjs

350 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tether test (dev tool, run with Node — no browser needed):
*
* node dev/tether.test.mjs
*
* Runs the REAL pure tether math (js/tether/Tether.js, no Phaser) against
* the real data/tether.json config and asserts the range rules:
* - level 1 radius = 5120 px from the anchor center (design rule),
* radius grows monotonically with level;
* - the ship is allowed anywhere in the UNION of its tethers' zones —
* a point inside one tether is free even when outside another
* (overlap = no wall, no line);
* - outside the union, the clamp lands EXACTLY on the closest boundary
* point (checked against a dense numeric sample of the union rim)
* with the correct outward normal;
* - visibleArcs() = each tether's share of the union boundary, both
* directions: nothing visible is inside another zone, and nothing on
* a rim outside all other zones is missing from the visible arcs
* (no gaps in the line, no line in the overlap).
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton -------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const { Tether } = await import(pathToFileURL(join(__dirname, '../js/tether/Tether.js')).href);
const TAU = Math.PI * 2;
const wrap = (a) => {
const w = a % TAU;
return w < 0 ? w + TAU : w;
};
const insideArcs = (arcs, th) => {
const w = wrap(th);
return arcs.some((a) => w >= a.a0 - 1e-9 && w <= a.a1 + 1e-9);
};
/** Deterministic PRNG (mulberry32) — stable test points. */
const mulberry32 = (seed) => () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rand = mulberry32(20260903);
const dist = (x, y, t) => Math.hypot(x - t.x, y - t.y);
// --- 1. The level-1 rule: 5120 px from the anchor center ----------------
{
const r1 = Tether.radiusForLevel(1);
check(`level 1 radius is 5120 px (got ${r1})`, r1 === 5120);
const growth = config.get('tether.radiusGrowth', 2.0);
const r2 = Tether.radiusForLevel(2);
check(`level 2 = 5120 × growth (${growth}) (got ${r2})`, Math.abs(r2 - 5120 * growth) < 1e-9);
const maxLevel = config.get('tether.maxLevel', 3);
let mono = true;
let prev = 0;
for (let l = 1; l <= maxLevel; l++) {
const r = Tether.radiusForLevel(l);
if (!(r > prev)) mono = false;
prev = r;
}
check(`radius grows monotonically through level ${maxLevel}`, mono);
const t = new Tether('home', 0, 0, 1);
check(`Tether record: id/anchor/level/radius (got ${t.id} @${t.x},${t.y} lv${t.level} r${t.radius})`,
t.id === 'home' && t.x === 0 && t.y === 0 && t.level === 1 && t.radius === 5120);
const frac = new Tether('x', 10, -10, 2.7);
check(`level rounds to an integer (2.7 → ${frac.level}, radius ${frac.radius})`,
frac.level === 3 && Math.abs(frac.radius - 5120 * growth * growth) < 1e-9);
}
// --- 2. Single tether: membership + the barrier clamp -------------------
{
const A = new Tether('home', 0, 0, 1); // r = 5120 at the origin
const t = A;
check('inside the zone: free (clamped: false)',
!Tether.clampPoint(1000, 2000, [t]).clamped && Tether.containsAny(1000, 2000, [t]));
// On the rim exactly: allowed ("within 5120 px" includes 5120 px).
const on = Tether.clampPoint(5120, 0, [t]);
check('exactly on the rim: allowed, untouched', !on.clamped && on.x === 5120 && on.y === 0);
// Outside: clamped to the rim, along the ray, with the outward normal.
const out = Tether.clampPoint(7000, 0, [t]);
check(`outside: clamped to exactly 5120 along the ray (got ${out.x.toFixed(3)}, ${out.y.toFixed(3)})`,
out.clamped && out.id === 'home' && Math.abs(out.x - 5120) < 1e-9 && Math.abs(out.y) < 1e-9);
check('outward normal is +x (got ' + out.nx.toFixed(3) + ')', out.nx === 1 && Math.abs(out.ny) < 1e-12);
const outN = Tether.clampPoint(0, -9000, [t]);
check('south approach: normal is y (got ny=' + outN.ny.toFixed(3) + ')',
outN.clamped && Math.abs(outN.y + 5120) < 1e-9 && outN.ny === -1);
// No tethers at all: free space (seam for pre-tether / debug states).
const free = Tether.clampPoint(12345, -678, []);
check('no tethers: everything is free', !free.clamped && free.id === null && free.x === 12345);
}
// --- 3. Overlap = the union (no wall, no line, inside the overlap) ------
{
// Home tether r=5120 at origin + a second tether r=4000 at (6000, 0):
// the rims cross; their overlap is fully open space.
const A = new Tether('home', 0, 0, 1);
const B = new Tether('base', 6000, 0, 2);
B.radius = 4000; // (a lower-level-ish radius for a clean test)
const field = [A, B];
// 5500 is OUTSIDE the home tether but INSIDE the second → free.
const p1 = Tether.clampPoint(5500, 0, field);
check('outside home tether but inside the second: free (union rule)', !p1.clamped);
check('…and it really is inside the second tether', Tether.containsAny(5500, 0, field));
// Deep in the lens: free, even though ~9000 from home's center.
const p2 = Tether.clampPoint(6000, 2000, field);
check('deep in the overlap (6000, 2000): free', !p2.clamped);
// Outside BOTH: clamped to the CLOSEST rim — the second tether's, not
// home's (500 px gap vs 5380 px).
const p3 = Tether.clampPoint(10500, 0, field);
check(`outside both: clamps to the nearer rim (10000, 0), id=${p3.id} (got ${p3.x}, ${p3.y}, ${p3.id})`,
p3.clamped && p3.id === 'base' && Math.abs(p3.x - 10000) < 1e-9 && Math.abs(p3.y) < 1e-9);
}
// --- 4. Nearest-boundary clamp — numeric cross-check --------------------
{
const A = new Tether('home', 0, 0, 1);
const B = new Tether('base', 6000, 0, 2);
B.radius = 4000;
const field = [A, B];
const SAMPLES = 20000; // rim sampling density (≈1.6 px on the 5120 rim)
let worst = 0;
let worstPt = null;
for (let i = 0; i < 80; i++) {
const ang = rand() * TAU;
const scale = 1.03 + rand() * 0.6;
const R = Math.max(A.radius, B.radius) * scale;
const px = Math.cos(ang) * R;
const py = Math.sin(ang) * R;
// Only points truly outside the union.
if (Tether.containsAny(px, py, field)) continue;
const got = Tether.clampPoint(px, py, field);
// Dense numeric min over the union rim.
let bestD = Infinity;
let bestQ = null;
for (const t of field) {
for (let s = 0; s < SAMPLES; s++) {
const a = (s / SAMPLES) * TAU;
const qx = t.x + Math.cos(a) * t.radius;
const qy = t.y + Math.sin(a) * t.radius;
const d = Math.hypot(px - qx, py - qy);
if (d < bestD) {
bestD = d;
bestQ = { x: qx, y: qy };
}
}
}
const err = Math.hypot(got.x - bestQ.x, got.y - bestQ.y);
if (err > worst) {
worst = err;
worstPt = { px, py };
}
if (!got.clamped || err > 2.5) {
check(`clamp(×${(px).toFixed(0)}, ${py.toFixed(0)}) → nearest union rim (err ${err.toFixed(2)} px)`, false);
break;
}
}
check(`80 random outside points: clamp lands on the nearest union rim (worst ${worst.toFixed(2)} px)`, worst <= 2.5);
// And the normal always points from the owning anchor.
const chk = Tether.clampPoint(2000, 6400, field);
const own = field.find((t) => t.id === chk.id);
const dot = (chk.nx * (chk.x - own.x) + chk.ny * (chk.y - own.y)) / own.radius;
check('normal is radial from the owning anchor (dot=1, got ' + dot.toFixed(4) + ')', Math.abs(dot - 1) < 1e-9);
}
// --- 5. visibleArcs — the union boundary, per tether --------------------
{
const soloT = new Tether('a', 0, 0, 1);
const solo = Tether.visibleArcs(soloT, [soloT]);
check('solo tether: one full-circle arc',
solo.length === 1 && Math.abs(solo[0].a0) < 1e-9 && Math.abs(solo[0].a1 - TAU) < 1e-9);
// B strictly contains A → A's rim is fully covered → no line at all.
const small = new Tether('a', 500, 0, 1);
small.radius = 1000;
const big = new Tether('b', 0, 0, 1);
big.radius = 5000;
check('fully contained: no visible arcs (no line inside a bigger zone)',
Tether.visibleArcs(small, [small, big]).length === 0);
// B strictly inside A (rim untouched) → A stays a full circle.
const inner = new Tether('b', 3000, 0, 1);
inner.radius = 1000; // d + rB = 4000 ≤ rA
check('inner tether not touching the rim: outer stays a full circle',
(() => {
const arcs = Tether.visibleArcs(big, [big, inner]);
return arcs.length === 1 && Math.abs(arcs[0].a1 - arcs[0].a0 - TAU) < 1e-9;
})());
// INTERNAL TANGENCY — the gate case: d + rInner = rOuter EXACTLY (a
// level-1 gate tether placed at exactly the level-2 anchor's range:
// 5120 + 5120 = 10240). The inner rim lies entirely inside the outer
// zone, touching at one point — it contributes nothing to the union
// boundary, so the inner tether draws NO line; the outer stays full.
// Regression: at k == 1 exactly the old branch fell into "no covered
// arc" and drew the whole inner circle as a spurious border inside the
// union (visible in-game and on the map).
const outerT = new Tether('outer', 0, 0, 1); outerT.radius = 10240;
const innerT = new Tether('inner', 5120, 0, 1); innerT.radius = 5120;
const fieldT = [outerT, innerT];
check('internal tangency: inner rim fully covered → no line inside the union',
Tether.visibleArcs(innerT, fieldT).length === 0);
check('internal tangency: outer tether stays a full circle',
(() => {
const arcs = Tether.visibleArcs(outerT, fieldT);
return arcs.length === 1 && Math.abs(arcs[0].a1 - arcs[0].a0 - TAU) < 1e-9;
})());
// Partial overlap — EXACT covered length (k from the circle geometry):
// covered(θ) where cos(θ−β) ≥ k, k = (rA²+d²rB²)/(2·rA·d)
const A = new Tether('a', 0, 0, 1); // r = 5120
const B = new Tether('b', 6000, 0, 1);
B.radius = 4000;
const field = [A, B];
const coveredLen = (t, o) => {
const r = t.radius;
const d = Math.hypot(o.x - t.x, o.y - t.y);
const k = (r * r + d * d - o.radius * o.radius) / (2 * r * d);
if (k < -1) return TAU;
if (k >= 1) return 0;
return 2 * Math.acos(k);
};
const visA = Tether.visibleArcs(A, field);
const visB = Tether.visibleArcs(B, field);
const arcLen = (arcs) => arcs.reduce((s, a) => s + (a.a1 - a.a0), 0);
check(
`A: visible = TAU covered (got ${(arcLen(visA) / TAU).toFixed(5)} of circle, want ${(1 - coveredLen(A, B) / TAU).toFixed(5)})`,
Math.abs(arcLen(visA) - (TAU - coveredLen(A, B))) < 1e-6,
);
check(
`B: visible = TAU covered (got ${(arcLen(visB) / TAU).toFixed(5)} of circle, want ${(1 - coveredLen(B, A) / TAU).toFixed(5)})`,
Math.abs(arcLen(visB) - (TAU - coveredLen(B, A))) < 1e-6,
);
check('partial overlap: visible arcs are well-formed (sorted, in [0,TAU), non-overlapping)',
(() => {
const ok = (arcs) =>
arcs.length > 0 &&
arcs.every((a) => a.a0 >= 0 && a.a1 <= TAU + 1e-9 && a.a1 > a.a0) &&
arcs.every((a, i) => i === 0 || a.a0 >= arcs[i - 1].a1 - 1e-9);
return ok(visA) && ok(visB);
})());
// External tangent (d = rA + rB): a single contact point, no covered arc.
const T1 = new Tether('a', 0, 0, 1);
T1.radius = 3000;
const T2 = new Tether('b', 7000, 0, 1);
T2.radius = 4000; // d = 7000 = 3000 + 4000
const tangA = Tether.visibleArcs(T1, [T1, T2]);
let noInside = true;
for (let s = 0; s < 4000; s++) {
const a = (s / 4000) * TAU;
const px = T1.x + Math.cos(a) * T1.radius;
const py = T1.y + Math.sin(a) * T1.radius;
if (Math.hypot(px - T2.x, py - T2.y) < T2.radius - 1e-6) noInside = false;
}
check('external tangent: no rim point inside the other zone',
noInside && Math.abs(arcLen(tangA) - TAU) < 1e-6);
// B's center inside A, partial overlap (k = 0.65): exact length again.
const C1 = new Tether('a', 0, 0, 1);
C1.radius = 5000;
const C2 = new Tether('b', 2000, 0, 1);
C2.radius = 4000;
const visC = Tether.visibleArcs(C1, [C1, C2]);
check(
`center-inside overlap: A visible = ${(arcLen(visC) / TAU).toFixed(5)} of circle (want ${(1 - coveredLen(C1, C2) / TAU).toFixed(5)})`,
Math.abs(arcLen(visC) - (TAU - coveredLen(C1, C2))) < 1e-6,
);
}
// --- 6. The two-direction boundary property (2 + 3 tethers) -------------
// (a) nothing on a visible arc is inside another zone (no line in the
// overlap), and
// (b) every rim point NOT inside any other zone sits on a visible arc
// (no gaps in the line).
{
const A = new Tether('home', 0, 0, 1); // r = 5120
const B = new Tether('base', 6000, 0, 1);
B.radius = 4000;
const C = new Tether('relay', 6000, 3500, 1);
C.radius = 2200;
const field = [A, B, C];
const arcs = Object.fromEntries(field.map((t) => [t.id, Tether.visibleArcs(t, field)]));
let aOk = true;
let bOk = true;
const SAMPLES = 6000;
for (const t of field) {
for (let s = 0; s < SAMPLES; s++) {
const ang = (s / SAMPLES) * TAU;
const px = t.x + Math.cos(ang) * t.radius;
const py = t.y + Math.sin(ang) * t.radius;
const inOther = field.some(
(o) => o !== t && Math.hypot(px - o.x, py - o.y) < o.radius - 1e-6,
);
const onVisible = insideArcs(arcs[t.id], ang);
if (onVisible && inOther) aOk = false;
if (!inOther && !onVisible) bOk = false;
}
}
check('(a) visible arcs are never inside another zone (no line in the overlap)', aOk);
check('(b) every exposed rim point is on a visible arc (no gaps in the line)', bOk);
// And where a rim point sits inside another zone, the union is still
// "owned" by the other tether's boundary or interior — i.e. the point
// is inside the union (free space), so the wall is nowhere near it.
let unionOk = true;
for (const t of field) {
for (let s = 0; s < 2000; s++) {
const ang = (s / 2000) * TAU;
const px = t.x + Math.cos(ang) * t.radius;
const py = t.y + Math.sin(ang) * t.radius;
const inOther = field.some((o) => o !== t && Math.hypot(px - o.x, py - o.y) < o.radius);
if (inOther && !Tether.containsAny(px, py, field)) unionOk = false;
}
}
check('covered rim points are inside the union (overlap is open space)', unionOk);
}
console.log(failures === 0 ? '\nAll tether tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);