orbit/dev/research-cue.test.mjs

337 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);