350 lines
15 KiB
JavaScript
350 lines
15 KiB
JavaScript
/**
|
||
* 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);
|