/** * GalaxyView star-name label test (dev tool, run with Node — no browser): * * node dev/galaxy-labels.test.mjs * * Regression for the "star names blink on and off" bug: MapWindow.update * polls the galaxy every 500 ms (js/ui/MapWindow.js) and calls * `gv.setVisible(true)` + `gv.applySnapshot(snap)` while open. That used to * force EVERY in-plate name label visible for one frame (setVisible(true) * shows all labels; _applyCull only hid OFF-plate ones), and the next * frame's GalaxyView.update() hid the sub-threshold ones again → every star * name flashed on at ~8 Hz. * * Asserts: * - at 1× zoom only HOME/SHIP carry a name; visited/unvisited are quiet; * - the 500 ms poll (setVisible(true) + applySnapshot, end-of-frame * state, no update() after) NEVER flashes sub-threshold labels on; * - a hidden view stays hidden through a poll; * - labels earn in with zoom: visited at visitedZoom×, uncharted at * anyZoom× (data/map.json → galaxy.labels). */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); // ── browser stubs (canvas, Phaser) BEFORE importing the game modules ───── const gradStub = { addColorStop() {} }; const ctx2d = { createRadialGradient: () => gradStub, createLinearGradient: () => gradStub, fillRect() {}, beginPath() {}, arc() {}, fill() {}, fillStyle: null, }; globalThis.document = { createElement: () => ({ width: 0, height: 0, getContext: () => ctx2d }), }; /** A chainable stand-in for a Phaser game object (records visibility). */ function makeObj(props = {}) { const o = { x: 0, y: 0, alpha: 1, visible: true, rotation: 0, text: '', width: 40, height: 10, frame: { width: 2, height: 2 }, ...props, }; o.setScrollFactor = () => o; o.setDepth = () => o; o.setAlpha = (a) => ((o.alpha = a), o); o.setVisible = (v) => ((o.visible = !!v), o); o.setOrigin = () => o; o.setPosition = (x, y) => ((o.x = x), (o.y = y), o); o.setDisplaySize = (w, h) => ((o.width = w), (o.height = h), o); o.setText = (t) => ((o.text = String(t)), o); o.setColor = () => o; o.setBlendMode = () => o; o.setInteractive = () => ((o.input = { enabled: true }), o); o.on = () => o; o.removeAllListeners = () => o; // graphics API o.clear = () => o; o.fillStyle = () => o; o.lineStyle = () => o; o.fillCircle = () => o; o.strokeCircle = () => o; o.fillPoints = () => o; o.strokePoints = () => o; o.lineBetween = () => o; o.fillRoundedRect = () => o; o.strokeRoundedRect = () => o; return o; } globalThis.window = { Phaser: { GameObjects: { Container: class { add() {} setVisible() { return this; } setDepth() { return this; } } }, Math: { Clamp: (v, a, b) => Math.min(b, Math.max(a, v)) }, BlendModes: { ADD: 'ADD' }, Display: { Color: { ValueToColor: (v) => { let s = String(v).trim().replace(/^#/, ''); if (s.length === 3) s = s.split('').map((c) => c + c).join(''); return { color: parseInt(s.slice(0, 6), 16) || 0 }; }, }, }, Geom: { Rectangle: class { constructor(x = 0, y = 0, w = 0, h = 0) { Object.assign(this, { x, y, w, h }); } contains(px, py) { return px >= this.x && px <= this.x + this.w && py >= this.y && py <= this.y + this.h; } }, }, }, }; const { config } = await import('../js/config/Config.js'); const mapJson = JSON.parse(fs.readFileSync(path.join(root, 'data/map.json'), 'utf8')); config.init({ map: mapJson }); const scene = { textures: { exists: () => false, addCanvas() {} }, add: { image: (x, y, key) => makeObj({ key }), text: (x, y, str) => makeObj({ text: String(str) }), graphics: () => makeObj(), }, time: { now: 1000 }, input: { setDefaultCursor() {} }, }; const win = { scene, geo: { mapX: 100, mapY: 100, mapW: 400, mapH: 300 }, add() {}, dialog: null, sfx() {}, isOpen: true, }; const { GalaxyView } = await import('../js/ui/GalaxyView.js'); // ── helpers ──────────────────────────────────────────────────────────────── let passed = 0; let failed = 0; function ok(cond, label) { if (cond) { passed++; console.log(` ok ${label}`); } else { failed++; console.error(`FAIL ${label}`); } } /** A fresh copy of the snapshot (MapWindow polls for a FRESH object). */ function freshSnap() { return { seed: 'labeltest', homeSystemId: 'home', currentSystemId: 'home', stats: { visited: 1, total: 3 }, systems: [ { id: 'home', name: 'Home', type: 'habitable', x: 0, y: 0, visited: true, isHome: true, isCurrent: true, gates: 3 }, { id: 'a', name: 'Alpha', type: 'main', x: 300, y: 100, visited: true, gates: 2 }, { id: 'b', name: 'Beta', type: 'redDwarf', x: -200, y: -150, visited: false, gates: 1 }, ], edges: [ { key: 'home-a', a: 'home', b: 'a', used: true, live: true, frontier: false }, { key: 'home-b', a: 'home', b: 'b', used: false, live: false, frontier: false }, ], }; } const LABELS = mapJson.galaxy.labels; const visitedZoom = LABELS.visitedZoom; const anyZoom = LABELS.anyZoom; const gv = new GalaxyView(win, { getSnapshot: freshSnap }); gv.applySnapshot(freshSnap()); gv.reveal(0); const byId = Object.fromEntries(gv._stars.map((s) => [s.id, s])); // ── 1× zoom: only HOME/SHIP label ───────────────────────────────────────── console.log('at 1× zoom'); gv.setVisible(true); gv.update(2000); ok(byId.home.label.visible === true, 'HOME star keeps its name at 1×'); ok(byId.a.label.visible === false, 'visited star stays quiet at 1×'); ok(byId.b.label.visible === false, 'unvisited star stays quiet at 1×'); // ── THE BUG: the 500 ms poll must not flash sub-threshold labels on ─────── console.log('the 500 ms snapshot poll (end-of-frame state)'); gv.setVisible(true); // MapWindow.update → gv.setVisible(true) gv.applySnapshot(freshSnap()); // …then gv.applySnapshot(snap) — no update() after ok(byId.a.label.visible === false, 'poll does NOT flash a visited label on at 1× (was the blink)'); ok(byId.b.label.visible === false, 'poll does NOT flash an unvisited label on at 1× (was the blink)'); ok(byId.home.label.visible === true, 'HOME name survives the poll at 1×'); // a hidden view stays hidden even through a poll gv.setVisible(false); gv.setVisible(true); gv.applySnapshot(freshSnap()); ok(byId.a.label.visible === false, 'poll keeps sub-threshold labels off (view visible)'); gv.setVisible(false); ok(byId.home.label.visible === false, 'setVisible(false) hides even the HOME name'); gv.setVisible(true); gv.update(2500); // ── labels earn in with zoom (on-plate + threshold, like a real cursor zoom) ── console.log('zoom thresholds'); ok(Number.isFinite(visitedZoom) && visitedZoom >= 2, `visitedZoom is a fairly close zoom (${visitedZoom}×)`); ok(Number.isFinite(anyZoom) && anyZoom > visitedZoom, `anyZoom > visitedZoom (${anyZoom}× > ${visitedZoom}×)`); // the threshold rule itself (culling-agnostic) function setZoom(z) { gv._view = { z, cx: gv._bounds.cx, cy: gv._bounds.cy }; } setZoom(1); ok(gv._labelVisible(byId.a) === false, 'visited rule: off below visitedZoom'); setZoom(visitedZoom); ok(gv._labelVisible(byId.a) === true, 'visited rule: on at visitedZoom'); ok(gv._labelVisible(byId.b) === false, 'unvisited rule: off below anyZoom'); setZoom(anyZoom); ok(gv._labelVisible(byId.b) === true, 'unvisited rule: on at anyZoom'); // end-to-end: zoom ONTO a star (the cursor-zoom idiom) and its label appears function viewTo(z, wx, wy) { gv._view = { z, cx: wx, cy: wy }; gv._relayout(); gv.update(2500); } viewTo(1, 0, 0); ok(byId.a.label.visible === false && byId.b.label.visible === false, 'at 1× only HOME/SHIP carry names'); viewTo(visitedZoom, 300, 100); // zoom onto the visited star ok(byId.a.label.visible === true, `visited label shows at ${visitedZoom}× when zoomed onto it`); viewTo(1, 300, 100); ok(byId.a.label.visible === false, 'zoom back out → the label drops again'); viewTo(anyZoom, -200, -150); // zoom onto the unvisited star ok(byId.b.label.visible === true, `unvisited label shows at ${anyZoom}× when zoomed onto it`); viewTo(1, 0, 0); ok(byId.home.label.visible === true, 'HOME stays labeled at every zoom'); console.log(failed ? `\n${failed} label check(s) FAILED` : '\nAll galaxy label checks passed ✓'); process.exit(failed ? 1 : 0);