/** * READY-RESEARCH attention-cue test (dev tool, run with Node — no browser): * * node dev/research-cue.test.mjs * * Pins the contract behind the "research is ready" cue (one cue on screen * at a time — GameScene._applyResearchCue picks the surface): * - the deck's RESEARCH slot (ActionBar.setShimmer): a periodic yellow * band sweeps the face, then a comet runs the cut-corner edge — the * cycle model (js/ui/ShimmerCue.js: shimmerPhase/drawShimmer) is pure * and is checked here, and the slot's draw/hold behavior through the * REAL ActionBar with the headless scene (standby / slot lock / the * tutorial's finger all suppress the shimmer; unknown ids no-op); * - the console's half (ResearchWindow._paintCues): the category tabs' * shimmers ask ResearchModel.hasAvailable (the any-ready rule), and * the jump-gate tech's ACK pulse asks SystemCategory.gatesReady (the * chart gate + the in-flight gate) — both checked here against the * real ResearchState. (The window's per-frame paint is the same * ShimmerCue recipe + the existing ACK-pulse painter; its Phaser * wiring is exercised in the browser.) */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const dataDir = join(root, 'data'); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // ---------------------------------------------------------------------- // Config — the cue's knobs exist in the right files, yellow family // ---------------------------------------------------------------------- const comms = read('comms.json'); const actionbar = read('actionbar.json'); const research = read('research.json'); check('comms: the ACK pulse color is the cue yellow (the family both surfaces borrow)', comms.box?.pulse?.color === '#ffd84d'); check('actionbar: animation.shimmer exists (the RESEARCH slot ready cue)', !!actionbar.animation?.shimmer && typeof actionbar.animation.shimmer === 'object'); const abShim = actionbar.animation?.shimmer ?? {}; check('actionbar: the shimmer is enabled + yellow + a sane cycle', abShim.enabled === true && abShim.color === '#ffd84d' && abShim.periodMs > 0 && abShim.faceMs > 0 && abShim.edgeMs > 0 && abShim.faceMs + abShim.gapMs + abShim.edgeMs < abShim.periodMs); check('research: fx.tabShimmer exists (the console tabs ready cue)', !!research.fx?.tabShimmer && typeof research.fx.tabShimmer === 'object'); const rwShim = research.fx?.tabShimmer ?? {}; check('research: the tab shimmer is enabled + yellow (deck-only face → faceMs 0)', rwShim.enabled === true && rwShim.color === '#ffd84d' && rwShim.faceMs === 0 && rwShim.edgeMs > 0); // ---------------------------------------------------------------------- // The pure cycle model (js/ui/ShimmerCue.js) // ---------------------------------------------------------------------- const { shimmerPhase, drawShimmer } = await import('../js/ui/ShimmerCue.js'); const { config } = await import('../js/config/Config.js'); config.init({ research, gates: read('gates.json'), exploration: read('research/exploration.json') }); const { categories, loadCategory, isAvailable, hasAvailable } = await import('../js/research/ResearchModel.js'); const { buildSystemTree, gatesReady, gatesNodeId, SYSTEM_CATEGORY } = await import('../js/research/SystemCategory.js'); const { ResearchState } = await import('../js/research/ResearchState.js'); const { ActionBar } = await import('../js/ui/ActionBar.js'); // -- shimmerPhase: the windows + envelopes ------------------------------ { const cfg = abShim; // the deck's cycle (face → edge → rest) const P = cfg.periodMs, F = cfg.faceMs, G = cfg.gapMs, E = cfg.edgeMs; const at = (t) => shimmerPhase(t, cfg); check('phase: t=0 is the face start (envelope 0 — the band is born)', at(0).faceU === 0 && at(0).faceA === 0); check('phase: the face window runs [0, faceMs)', at(1).faceU !== null && at(F - 1).faceU !== null && at(F).faceU === null); check('phase: the face envelope peaks mid-window (sine)', Math.abs(at(F / 2).faceA - 1) < 1e-9); check('phase: the gap is silent (no face, no edge)', at(F).faceA === 0 && at(F).edgeA === 0 && at(F + G / 2).faceA === 0 && at(F + G / 2).edgeA === 0 && at(F + G - 1).edgeA === 0); check('phase: the edge window runs [faceMs+gapMs, +edgeMs)', at(F + G).edgeU === 0 && at(F + G).edgeA === 0 && at(F + G + E - 1).edgeU !== null && at(F + G + E).edgeU === null); check('phase: the edge envelope peaks mid-lap (sine)', Math.abs(at(F + G + E / 2).edgeA - 1) < 1e-9); const rest = F + G + E; check('phase: the rest of the period is silence', at(rest).faceA === 0 && at(rest).edgeA === 0 && at(P - 1).faceA === 0 && at(P - 1).edgeA === 0 && at(P).faceU === 0, `rest ${rest}..${P}`); check('phase: the cycle is periodic (pure in time)', JSON.stringify(at(123)) === JSON.stringify(at(123 + P))); check('phase: an offset staggers the cycle (per-slot / per-tab phase)', JSON.stringify(at(0)) !== JSON.stringify(shimmerPhase(0, cfg, 500))); // The tab cycle: faceMs 0 — the comet owns the window from t=0. const tab = shimmerPhase(1, rwShim); check('phase: faceMs 0 (the tabs) → no face, the edge runs from t=0', tab.faceU === null && tab.edgeU !== null && tab.edgeA > 0); } // -- drawShimmer: the call surface (a recorder stands in for Graphics) --- { const calls = []; const g = { fillStyle: (c, a) => calls.push(['fillStyle', c, a]), fillRect: (x, y, w, h) => calls.push(['fillRect', x, y, w, h]), lineStyle: (w, c, a) => calls.push(['lineStyle', w, c, a]), strokePoints: (pts, close) => calls.push(['strokePoints', pts.length, close]), }; const cfg = abShim; const COLOR = 0xffd84d; calls.length = 0; drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), {}); const faces = calls.filter((c) => c[0] === 'fillRect'); const edges = calls.filter((c) => c[0] === 'strokePoints'); check('draw: the face pass paints band + core + white line (3 fillRects), no edge', faces.length === 3 && edges.length === 0); check('draw: the face rects stay clamped to the plate face', faces.every(([, x, y, w, h]) => x >= 100 - 0.5 && x + w <= 100 + 160 + 0.5 && y === 50 && h === 44)); calls.length = 0; drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs + cfg.gapMs + cfg.edgeMs / 2, cfg), {}); const rim = calls.filter((c) => c[0] === 'strokePoints' && c[2] === true); const bands = calls.filter((c) => c[0] === 'strokePoints' && c[2] === false); check('draw: the edge pass paints the rim glow (1 closed stroke) + the comet (3 bands × halo+core)', rim.length === 1 && bands.length === 6); check('draw: the face is absent during the edge window', calls.filter((c) => c[0] === 'fillRect').length === 0); calls.length = 0; drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.periodMs - 1, cfg), {}); check('draw: the rest of the cycle paints nothing', calls.length === 0); calls.length = 0; drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), { face: false }); check('draw: face:false (the tabs) skips the band even mid-face-window', calls.filter((c) => c[0] === 'fillRect').length === 0); } // ---------------------------------------------------------------------- // The any-ready rule (ResearchModel.hasAvailable — the deck cue + the // tabs' shimmers ask it) // ---------------------------------------------------------------------- { const state = new ResearchState(); const tree = loadCategory('exploration'); check('hasAvailable: fresh run — the first tech is ready (true)', hasAvailable(tree, state) === true); state.unlock('exploration', tree.order[0]); check('hasAvailable: after the first tech, its child is ready (true)', hasAvailable(tree, state) === true); for (const id of tree.order) state.unlock('exploration', id); check('hasAvailable: everything researched — nothing ready (false)', hasAvailable(tree, state) === false); check('hasAvailable: null/missing state is a safe false', hasAvailable(tree, null) === false); } // ---------------------------------------------------------------------- // The jump-gate rule (SystemCategory.gatesReady — the ACK pulse target) // ---------------------------------------------------------------------- { const mk = () => buildSystemTree({ id: 'testsys', name: 'Testys' }); const state = new ResearchState(); const tree = mk(); check('gatesReady: fresh system (no map) — not ready (null)', gatesReady(tree, state) === null); state.unlock(SYSTEM_CATEGORY, tree.order[0]); // the map tech (granted on entry) check('gatesReady: map owned but chart incomplete — not ready (null)', gatesReady(tree, state) === null); check('gatesReady: no gates node on a foreign tree — safe null', gatesReady({ id: 'x', order: ['a'], nodes: { a: {} } }, state) === null); // Chart complete: the availability hook passes (the scene's isNavComplete). const done = { ...mk(), available: () => true }; state.unlock(SYSTEM_CATEGORY, done.order[0]); check('gatesReady: chart complete + map owned → the gates node id', gatesReady(done, state) === gatesNodeId(done)); check('hasAvailable: the ready gates count as an available tech', hasAvailable(done, state) === true); // In flight — maxConcurrent 1: the pulse must rest. state.start(SYSTEM_CATEGORY, gatesNodeId(done), 60000, 0); check('gatesReady: the gates tech IN FLIGHT — not ready (null)', gatesReady(done, state) === null); // A different project in flight (the same gate, through the deck). const state2 = new ResearchState(); const done2 = { ...mk(), available: () => true }; state2.unlock(SYSTEM_CATEGORY, done2.order[0]); const ex = loadCategory('exploration'); state2.start('exploration', ex.order[0], 60000, 0); check('gatesReady: ANOTHER project in flight — not ready (null)', gatesReady(done2, state2) === null); } // ---------------------------------------------------------------------- // The deck's RESEARCH slot (ActionBar.setShimmer, real bar + headless scene) // ---------------------------------------------------------------------- { const stubObject = (extra = {}) => ({ x: 0, y: 0, width: 60, height: 18, alpha: 1, setOrigin() { return this; }, setAlpha(a) { this.alpha = a; return this; }, setScale() { return this; }, setTint() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; }, setX(v) { this.x = v; return this; }, setY(v) { this.y = v; return this; }, setStrokeStyle() { return this; }, setSize(w, h) { this.width = w; this.height = h; return this; }, setBlendMode() { return this; }, setScrollFactor() { return this; }, setDepth() { return this; }, setInteractive(opts) { this._interactive = opts; return this; }, disableInteractive() { this._interactive = null; return this; }, setDisplaySize() { return this; }, on() { return this; }, once() { return this; }, off() { return this; }, setText(s) { this.text = String(s); return this; }, setColor(c) { this.color = c; return this; }, setFontSize() { return this; }, clear() { this._clears = (this._clears ?? 0) + 1; return this; }, fillStyle() { return this; }, lineStyle() { return this; }, fillPoints() { return this; }, strokePoints() { return this; }, fillRect() { return this; }, strokeRect() { return this; }, lineBetween() { return this; }, destroy() { this.destroyed = true; }, ...extra, }); const scene = { scale: { width: 1280, height: 720 }, add: { existing: (o) => o, text: (x, y, str) => stubObject({ x, y, text: String(str) }), image: (x, y, key) => stubObject({ x, y, key }), graphics: () => stubObject({}), rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }), tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }), circle: (x, y, r) => stubObject({ x, y, radius: r }), }, tweens: { add: (t) => { const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; }; if (Array.isArray(t?.targets)) t.targets.forEach(apply); else if (t?.targets) apply(t.targets); return { destroy() {}, stop() {} }; }, killTweensOf() {}, }, time: { now: 1000, delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; }, }, textures: { exists: () => true }, }; // The FLIGHT deck (the default buttons — it has the RESEARCH slot). const bar = new ActionBar(scene, {}); const slot = bar.slots.find((s) => s.id === 'research'); check('bar: the flight deck has a live RESEARCH slot', !!slot && slot.live === true); // A recorder over the shimmer layer (the bar's own — the real methods, // wrapped to count). const g = bar.shimmerG; let rects = 0, strokes = 0, clears = 0; const orig = { fillRect: g.fillRect, strokePoints: g.strokePoints, clear: g.clear }; g.fillRect = (...a) => { rects += 1; return orig.fillRect.apply(g, a); }; g.strokePoints = (...a) => { strokes += 1; return orig.strokePoints.apply(g, a); }; g.clear = (...a) => { clears += 1; return orig.clear.apply(g, a); }; const step = (t) => { rects = 0; strokes = 0; const before = clears; bar.update(t, 16); return { rects, strokes, cleared: clears > before }; }; // The bar staggers the cycle per slot (phase * 350 ms — the RGB idiom); // the test times are the cycle windows shifted by that same offset. const P = abShim.periodMs; const off = (slot.phase ?? 0) * 350; const at = (u) => (((u % P) + P) % P); const T_FACE = at(abShim.faceMs / 2 - off); const T_EDGE = at(abShim.faceMs + abShim.gapMs + abShim.edgeMs / 2 - off); const T_REST = at(abShim.periodMs - 1 - off); check('bar: no shimmer at construction (nothing drawn, no live flag)', bar.shimmerId === null && bar._shimLive === false); let r = step(100); check('bar: update draws nothing on the shimmer layer when disarmed', r.rects === 0 && r.strokes === 0); // Arm the cue — like GameScene._applyResearchCue does per frame. bar.setShimmer('research'); check('bar: setShimmer arms the RESEARCH slot', bar.shimmerId === 'research'); bar.setShimmer('research'); check('bar: setShimmer is idempotent (the scene re-applies per frame)', bar.shimmerId === 'research'); r = step(T_FACE); check('bar: the face window paints the band (fillRects) on the shimmer layer', r.rects >= 2 && r.strokes === 0); r = step(T_EDGE); check('bar: the edge window paints the comet (strokes) on the shimmer layer', r.strokes >= 4 && r.rects === 0); r = step(T_REST); check('bar: the rest of the cycle is quiet (the layer idles)', r.rects === 0 && r.strokes === 0); // The holds: standby (the 'ack' comm), a locked slot, the tutorial's finger. bar.setEnabled(false); r = step(T_FACE); check('bar: the deck standby suppresses the shimmer (the ACK hold owns the screen)', r.rects === 0 && r.strokes === 0); bar.setEnabled(true); bar.setSlotEnabled('research', false); r = step(T_FACE); check('bar: a locked slot suppresses the shimmer', r.rects === 0 && r.strokes === 0); bar.setSlotEnabled('research', true); bar.setHighlight('research'); r = step(T_FACE); check('bar: the tutorial finger wins the shared slot (no double cue)', r.rects === 0 && r.strokes === 0); bar.setHighlight(null); r = step(T_FACE); check('bar: the shimmer returns once the finger lifts', r.rects >= 2); // Clearing + unknown ids. bar.setShimmer(null); check('bar: null clears the shimmer', bar.shimmerId === null); r = step(T_FACE); check('bar: …and the layer goes quiet', r.rects === 0 && r.strokes === 0); bar.setShimmer('nope'); check('bar: an unknown id (not on this deck) is a safe no-op', bar.shimmerId === null); // The SURFACE deck — the slot is 'shop', not 'research': the scene's // cue must degrade to a clear (SurfaceScene replaces Research). const surface = [ { id: 'shop', label: 'Shop' }, { id: 'build', label: 'Build' }, { id: 'quests', label: 'Quests' }, { id: 'menu', label: 'Menu' }, ]; const bar2 = new ActionBar(scene, { buttons: surface }); bar2.setShimmer('research'); check('bar: the surface deck has no RESEARCH slot — the cue degrades to clear', bar2.shimmerId === null); } console.log(failures === 0 ? '\nAll research-cue checks passed ✔' : `\n${failures} check(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);