/** * PerimeterShimmer test (dev tool, run with Node — no browser): * * node dev/perimeter-shimmer.test.mjs * * Runs the REAL shimmer (js/ui/PerimeterShimmer.js) — its pure arc math * (perimeterPath / arcPoint / arcPolyline, no Phaser) and the class * against a stubbed scene (recording FakeGraphics — the * quest-tracker.test.mjs pattern) — and pins the READY row's * dual-shimmer rim contract: * * - the perimeter is the CUT-CORNER polygon (6 segments) when notched, * a plain rectangle (4) when not — and the notch is clamped (it can * never eat the whole plate); * - arcPoint / arcPolyline sample the perimeter in arc order, * wrap-safe, with consecutive duplicates removed; * - the base rim is drawn ONCE (a glow pass under a core pass); * - two comets in counter-streams (opposite dirs, half a lap apart — * the data/quests.json → tracker.shimmer defaults survive the map); * - update(t) re-streams the comets every frame, on the ABSOLUTE * clock (a repaint mid-flight never resets the phase — phase0 + * t/period give the same head at the same absolute time); * - a zero-alpha comet draws nothing; destroy() is idempotent. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Stub just enough of Phaser for the Color.js import chain ---------- const PhaserStub = { Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this const { PerimeterShimmer, perimeterPath, arcPoint, arcPolyline, } = await import(pathToFileURL(join(__dirname, '../js/ui/PerimeterShimmer.js')).href); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const approx = (a, b, eps = 1e-6) => Math.abs(a - b) < eps; const near = (p, x, y, eps = 1e-6) => approx(p.x, x, eps) && approx(p.y, y, eps); // =========================================================================== // 1) Pure: the perimeter — cut-corner vs plain, notch clamped // =========================================================================== { const rect = perimeterPath(0, 0, 100, 20, 0); check('plain rect: 4 perimeter segments, P = 2*(w+h)', rect.segs.length === 4 && approx(rect.P, 240)); const cut = perimeterPath(0, 0, 100, 20, 6); const diag = Math.hypot(6, 6); check('cut-corner: 6 perimeter segments (4 edges + 2 diagonals)', cut.segs.length === 6); check('…and P = 2*(w-n) + 2*(h-n) + 2*diag', approx(cut.P, 2 * 94 + 2 * 14 + 2 * diag, 1e-9)); check('the diagonals sit at top-left + bottom-right (the CyberShape corners)', near({ x: cut.segs[2].ax, y: cut.segs[2].ay }, 100, 14) && near({ x: cut.segs[2].bx, y: cut.segs[2].by }, 94, 20) && near({ x: cut.segs[5].ax, y: cut.segs[5].ay }, 0, 6) && near({ x: cut.segs[5].bx, y: cut.segs[5].by }, 6, 0)); const big = perimeterPath(0, 0, 100, 20, 1000); check('a runaway notch is CLAMPED (n <= h/2 — never eats the plate)', big.segs.length === 6 && Number.isFinite(big.P) && approx(big.segs[0].ax, 10)); const off = perimeterPath(30, 40, 100, 20, 0); check('absolute coords: the rect starts at (x, y)', near({ x: off.segs[0].ax, y: off.segs[0].ay }, 30, 40)); } // =========================================================================== // 2) Pure: arcPoint — arc order, corners, wrap, negatives // =========================================================================== { const { segs, P } = perimeterPath(0, 0, 100, 20, 0); check('arcPoint: along the top edge', near(arcPoint(segs, P, 10), 10, 0)); check('arcPoint: the top-right corner (d = w)', near(arcPoint(segs, P, 100), 100, 0)); check('arcPoint: down the right edge', near(arcPoint(segs, P, 110), 100, 10)); check('arcPoint: bottom-left corner (d = 2w + h)', near(arcPoint(segs, P, 220), 0, 20)); check('arcPoint: d == P wraps to the start', near(arcPoint(segs, P, 240), 0, 0)); check('arcPoint: negative d wraps the long way (−10 ≡ 230)', near(arcPoint(segs, P, -10), 0, 10)); } // =========================================================================== // 3) Pure: arcPolyline — in-segment, corner-crossing, wrap-safe, deduped // =========================================================================== { const { segs, P } = perimeterPath(0, 0, 100, 20, 0); const flat = (pl) => pl.map((p) => `${p.x},${p.y}`).join(' → '); const a = arcPolyline(segs, P, 10, 30); check('in one segment: a straight 2-point dash', a.length === 2 && near(a[0], 10, 0) && near(a[1], 40, 0)); const b = arcPolyline(segs, P, 95, 30); check('crossing the top-right corner: [95,0] → [100,0] → [100,20] → [95,20]', b.length === 4 && near(b[0], 95, 0) && near(b[1], 100, 0) && near(b[2], 100, 20) && near(b[3], 95, 20)); const c = arcPolyline(segs, P, 230, 30); check('WRAP-SAFE (crosses d = P): [0,10] → [0,0] → [20,0] (up the left edge, over the corner, 20 units along)', c.length === 3 && near(c[0], 0, 10) && near(c[1], 0, 0) && near(c[2], 20, 0)); const deduped = (pl) => pl.every((p, i) => i === 0 || !near(p, pl[i - 1])); check('no consecutive duplicates (shared boundary points are removed)', deduped(a) && deduped(b) && deduped(c)); check('zero length → an empty polyline', arcPolyline(segs, P, 50, 0).length === 0); // The notched plate: the dash rides the diagonal cleanly. const cut = perimeterPath(0, 0, 100, 20, 6); const { P: Pc } = cut; const topLen = 94; const d = arcPolyline(cut.segs, Pc, topLen + 6, 20); check('notched: the dash crosses the bottom-right diagonal (right edge → cut → bottom edge)', d.length === 4 && near(d[0], 100, 6, 1e-6) && near(d[1], 100, 14) && near(d[2], 94, 20) && approx(d[3].y, 20) && near(d[3], ...Object.values(arcPoint(cut.segs, Pc, topLen + 26)))); console.log(` (debug) ${flat(d)}`); } // =========================================================================== // 4) Class: the rim is drawn once; two counter-streaming comets // =========================================================================== function makeScene() { const glist = []; const container = { children: [], add(o) { this.children.push(o); return this; }, }; return { time: { now: 0 }, add: { graphics: () => { const g = { calls: [], destroyed: false, alpha: 1 }; for (const fn of ['clear', 'lineStyle', 'fillStyle', 'fillRect', 'fillPoints', 'strokePoints', 'lineBetween']) { g[fn] = (...args) => { if (fn === 'clear') g.calls.length = 0; else g.calls.push({ fn, args }); return g; }; } g.setScrollFactor = () => g; g.destroy = () => { g.destroyed = true; return g; }; glist.push(g); return g; }, existing: (o) => o, }, glist, container, }; } const linePts = (g) => g.calls.filter((c) => c.fn === 'lineBetween').map((c) => `${c.args[0]},${c.args[1]}→${c.args[2]},${c.args[3]}`); { const scene = makeScene(); const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, { container: scene.container, notch: 6 }); check('the rim + the comet layer join the container (they slide with the tracker)', scene.container.children.length === 2 && scene.container.children.includes(sh.rim) && scene.container.children.includes(sh.cometLayer)); const rimLines = sh.rim.calls.filter((c) => c.fn === 'lineStyle'); check('the base rim is drawn ONCE: a glow pass + a core pass (2 lineStyle + 2 strokePoints)', rimLines.length === 2 && sh.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2); const [glow, core] = rimLines.map((c) => c.args); check('the glow is the WIDER, fainter pass (width+3.5, low alpha)', glow[0] > core[0] && glow[2] < core[2] && glow[1] === 0xffc94d); check('the rim is the AMBER claimable color (0xffc94d)', core[1] === 0xffc94d); check('TWO comets in COUNTER-STREAMS: amber clockwise + cyan counter-clockwise', sh.comets.length === 2 && sh.comets[0].dir === 1 && sh.comets[1].dir === -1 && sh.comets[0].color === 0xffc94d && sh.comets[1].color === 0x00e5ff); check('…half a lap apart (the crossing lands twice per cycle)', Math.abs(sh.comets[0].phase - sh.comets[1].phase) === 0.5); check('the rim traces the CUT-CORNER plate (6 segments)', sh.segs.length === 6); // Per-frame: the comets re-stream (line passes land) and MOVE. sh.update(0); const at0 = linePts(sh.cometLayer); sh.update(sh.periodMs / 4); const atQuarter = linePts(sh.cometLayer); check('update(t) re-streams the comets (line passes on the comet layer)', at0.length > 0 && atQuarter.length > 0); check('…and they MOVE with the clock (a quarter lap shifts the dashes)', JSON.stringify(at0) !== JSON.stringify(atQuarter)); // ABSOLUTE phase: phase0 + t/period is the head — a "repaint" (a fresh // instance at the same absolute time) draws the SAME dashes. const A = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0, periodMs: 2400 }); A.update(1200); // half a lap by the clock const B = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0.5, periodMs: 2400 }); B.update(0); // the SAME head, reached by phase0 instead of the clock check('the phase is ABSOLUTE: phase0 0 @ t = T/2 ≡ phase0 0.5 @ t = 0', JSON.stringify(linePts(A.cometLayer)) === JSON.stringify(linePts(B.cometLayer))); // destroy() — idempotent, and update() after it is a safe no-op. sh.destroy(); sh.destroy(); check('destroy(): rim + comet layer gone, idempotent', sh.destroyed === true && sh.rim.destroyed === true && sh.cometLayer.destroyed === true); sh.update(100); // must not throw check('update() after destroy(): a safe no-op', true); } // =========================================================================== // 5) Config-driven: data/quests.json → tracker.shimmer survives the map // =========================================================================== { const quests = JSON.parse(fs.readFileSync(join(__dirname, '../data/quests.json'), 'utf8')); const cfg = quests.tracker.shimmer; check('the config exists and is enabled (with the tuned defaults)', cfg !== null && cfg.enabled === true && cfg.notch === 6 && cfg.periodMs === 2400 && cfg.dashFrac === 0.3 && cfg.claimFlashMs === 150); check('the config defines the dual comets (amber CW, neon CCW, half a lap apart)', cfg.comets.length === 2 && cfg.comets[0].dir === 1 && cfg.comets[1].dir === -1 && cfg.comets[0].phase === 0 && cfg.comets[1].phase === 0.5); const scene = makeScene(); const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, { container: scene.container, notch: cfg.notch, periodMs: cfg.periodMs, dashFrac: cfg.dashFrac, base: cfg.base, comets: cfg.comets, }); check('the tracker.shimmer base rim lands (amber, the config alpha)', (() => { const lines = sh.rim.calls.filter((c) => c.fn === 'lineStyle'); const core = lines[lines.length - 1].args; return core[1] === 0xffc94d && approx(core[2], 0.55); })()); sh.update(100); check('the tracker.shimmer comets stream (both colors drawn on the comet layer)', (() => { const colors = new Set(sh.cometLayer.calls.filter((c) => c.fn === 'lineStyle').map((c) => c.args[1])); return colors.has(0xffc94d) && colors.has(0x00e5ff); })()); sh.destroy(); } // =========================================================================== // 6) Degenerate configs stay safe // =========================================================================== { const scene = makeScene(); // A zero-alpha comet draws nothing (the rim still stands). const sh0 = new PerimeterShimmer(scene, 0, 0, 100, 20, { comets: [ { color: '#ffc94d', dir: 1, phase: 0, width: 2, alpha: 0, haloAlpha: 0.3 }, { color: '#00e5ff', dir: -1, phase: 0.5, width: 2, alpha: 0, haloAlpha: 0.3 }, ], }); sh0.update(0); check('zero-alpha comets draw NOTHING (the rim is untouched)', sh0.cometLayer.calls.filter((c) => c.fn === 'lineBetween').length === 0 && sh0.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2); sh0.destroy(); // Missing comets → the built-in dual pair (amber + neon). const sh1 = new PerimeterShimmer(scene, 0, 0, 100, 20, {}); check('no comet config → the built-in dual pair (2 comets, opposite dirs)', sh1.comets.length === 2 && sh1.comets[0].dir === 1 && sh1.comets[1].dir === -1); sh1.destroy(); // A degenerate rect throws loudly (never a silent NaN perimeter). let threw = false; try { new PerimeterShimmer(scene, 0, 0, 0, 0, {}); } catch { threw = true; } check('a degenerate rect (0x0) throws (no silent NaN perimeter)', threw === true); } if (failures > 0) { console.error(`\n${failures} perimeter-shimmer test(s) FAILED`); process.exit(1); } console.log('\nperimeter-shimmer: all checks passed');