/** * 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', 1.25); 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', 12); 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; })()); // 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);