337 lines
17 KiB
JavaScript
337 lines
17 KiB
JavaScript
/**
|
||
* 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);
|