orbit/dev/marquee.test.mjs

291 lines
12 KiB
JavaScript

/**
* LevelUpMarquee test (dev tool, run with Node — no browser needed):
*
* node dev/marquee.test.mjs
*
* The level-up marquee's BEAT CONTRACT (js/ui/LevelUpMarquee.js):
*
* - TRIGGER — the ledger's 'levelup' event plays the beat immediately
* (the pause contract the scene's update gates on).
* - TIMELINE — the beat runs dim → old-level → corruption → scramble →
* lock-in → hold → shutter peel, finishes on its own, and
* reaps EVERY transient it spawned (shards, rings, sparks,
* slices — nothing leaks into the scene).
* - COALESCE — one grant crossing N levels fires one 'levelup' per
* level in the same tick → the marquee plays ONE beat
* (lowest → highest, "+N LEVELS"), never N overlapping
* ones.
* - BUFFER — a level-up landing MID-BEAT is buffered and plays right
* after the live one (the queue never drops a level).
* - LOCK — the digit slots lock onto the FINAL level on any scene
* clock (the lock stamps are ELAPSED beat time, not scene
* time — a live session's clock must lock like a zero one).
* - TEARDOWN — destroy() ends the beat and every created object dies
* with the scene (a scene mid-shutdown can't wedge it).
*
* The phaser stub (dev/phaser-stub.mjs) provides the Container
* GlitchText extends; a fake Scene below provides the add.* factories +
* time + textures (a `document` shim lets canvasTexture run headless).
* SFX + cameras are absent on purpose — the marquee must no-op both
* (a camera-less scene still gets the full beat).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
const __dirname = dirname(fileURLToPath(import.meta.url));
const url = (p) => pathToFileURL(join(__dirname, p)).href;
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(url('../js/config/Config.js'));
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
// --- A `document` shim: canvasTexture needs createElement + a 2D ctx ----
const gradient = () => ({ addColorStop() {} });
globalThis.document ??= {
createElement: () => ({
width: 0,
height: 0,
getContext: () => ({
fillRect() {},
strokeRect() {},
beginPath() {},
closePath() {},
moveTo() {},
lineTo() {},
arc() {},
stroke() {},
fill() {},
createLinearGradient: gradient,
createRadialGradient: gradient,
}),
}),
};
// ---------------------------------------------------------------------------
// A headless Scene: the add.* factories + time + textures the marquee builds
// on. Every created object is recorded (the leak + teardown asserts).
// SFX, cameras, and the cache are DELIBERATELY absent — the marquee must
// no-op them all (a headless scene still gets the full beat).
// ---------------------------------------------------------------------------
function chainable(o) {
for (const m of [
'setOrigin', 'setVisible', 'setScrollFactor', 'setDepth',
'setTint', 'setBlendMode', 'setAngle', 'setStrokeStyle',
'setRotation', 'setDisplaySize', 'setStroke', 'fillStyle', 'fillRect',
'beginPath', 'moveTo', 'lineTo', 'stroke', 'fill',
]) {
o[m] = (..._a) => o;
}
o.setAlpha = (a) => { o.alpha = Number(a); return o; };
o.setScale = (v) => { o.scale = Number(v); return o; };
o.setX = (x) => { o.x = x; return o; };
o.setY = (y) => { o.y = y; return o; };
o.setPosition = (x, y) => { o.x = x; o.y = y; return o; };
o.setSize = (w, h) => { o.width = w; o.height = h; return o; };
o.destroy = () => { o.destroyed = true; };
return o;
}
function fakeText(str) {
const t = chainable({ kind: 'text', text: String(str) });
const measure = () => { t.width = Math.max(12, String(t.text).length * 14); t.height = 24; };
measure();
t.setText = (s) => { t.text = String(s); measure(); return t; };
return t;
}
function makeScene() {
const scene = {
scale: { width: 1280, height: 720 },
time: { now: 0 },
created: [],
_textures: new Set(),
};
scene.textures = {
exists: (k) => scene._textures.has(k),
addCanvas: (k, c) => { scene._textures.add(k); return c; },
};
scene.add = {
text: (_x, _y, str) => { const t = fakeText(str); scene.created.push(t); return t; },
image: () => { const o = chainable({ kind: 'image' }); scene.created.push(o); return o; },
graphics: () => { const o = chainable({ kind: 'graphics' }); scene.created.push(o); return o; },
existing: (o) => o,
};
return scene;
}
const { LevelUpMarquee } = await import(url('../js/ui/LevelUpMarquee.js'));
const { XpManager } = await import(url('../js/combat/XpManager.js'));
/**
* Step the beat forward (16 ms frames) until `done()` holds or the budget
* runs out — the scene's clock + the marquee's update are the ONLY time
* source, exactly as GameScene drives them.
*/
function step(m, scene, done, budgetMs = 20000) {
let ms = 0;
while (!done() && ms < budgetMs) {
scene.time.now += 16;
m.update(scene.time.now, 16);
ms += 16;
}
if (!done()) throw new Error('step budget exhausted — the beat never finished');
}
const settled = (m) =>
m.active === false && m._frags.length === 0 && m._rings.length === 0 &&
m._sparks.length === 0 && m._slices.length === 0 && m._beat.length === 0;
// ---------------------------------------------------------------------------
// 1 — TRIGGER + TIMELINE + REAP (one level crossed, the full beat plays
// out, finishes, and leaves the scene exactly as it found it)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const done = [];
const m = new LevelUpMarquee(scene, { onDone: (b) => done.push(b) });
const xp = new XpManager();
m.bind(xp);
assert.equal(m.active, false, 'idle until a level lands');
assert.equal(scene._textures.size, 0, 'no cost until the first beat (lazy build)');
xp.grant(60, 'test'); // level 1 costs 50 (data/progression.json) → crosses
assert.equal(m.active, true, "the ledger's 'levelup' plays the beat immediately");
assert.deepEqual(m.ev, { from: 0, to: 1, levelsGained: 1 });
assert.ok(scene._textures.has('__marquee_beam'), 'the static layer built on first beat');
// Mid-beat invariants: active, the old board is up, transients exist.
step(m, scene, () => scene.time.now - m.t0 >= 1100, 1500);
assert.equal(m.active, true, 'still playing mid-beat');
assert.ok(m._frags.length > 0, 'the old level shattered into shards');
step(m, scene, () => settled(m), 20000);
assert.equal(done.length, 1, 'onDone fired once');
assert.deepEqual(done[0], { from: 0, to: 1, levelsGained: 1 });
assert.equal(m.dim.alpha, 0, 'the dim lifted (the world is back)');
console.log('ok 1 — trigger, timeline, reap, onDone');
}
// ---------------------------------------------------------------------------
// 2 — COALESCE (one grant crossing two levels → ONE beat, 0 → 2)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const m = new LevelUpMarquee(scene);
const xp = new XpManager();
m.bind(xp);
xp.grant(600, 'test'); // 50 (L1) + 200 (L2) = 250; next cost 450 > 350 left → level 2
assert.equal(m.active, true, 'the first levelup of the grant plays immediately');
assert.deepEqual(m.pending, { from: 0, to: 2, levelsGained: 2 },
'the rest of the grant folded onto the beat ledger (0 → 2, +2)');
// The live beat finishes → the coalesced beat plays (0 → 2, the full span).
step(m, scene, () => m.active === false || (m.ev.to === 2 && m.pending === null), 20000);
assert.equal(m.active, true, 'the coalesced beat plays right after the first');
assert.deepEqual(m.ev, { from: 0, to: 2, levelsGained: 2 }, 'the beat reads the grant span');
assert.match(m._decodeNew.value, /SYNC COMPLETE ▸ \+2 LEVELS/, 'the subline says +2 LEVELS');
step(m, scene, () => settled(m), 20000);
console.log('ok 2 — multi-level grant coalesces onto one beat');
}
// ---------------------------------------------------------------------------
// 3 — BUFFER (a level landing mid-beat plays right after — never dropped)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const m = new LevelUpMarquee(scene);
m.play({ from: 5, to: 6 });
assert.equal(m.active, true, 'manual play arms the beat');
step(m, scene, () => scene.time.now - m.t0 >= 300, 500);
m.play({ from: 6, to: 7 }); // lands mid-beat
assert.equal(m.active, true, 'the live beat is undisturbed');
assert.deepEqual(m.pending, { from: 5, to: 7, levelsGained: 2 },
'the late level folded onto the beat ledger (5 → 7)');
step(m, scene, () => m.active === false || (m.ev.to === 7 && m.pending === null), 20000);
assert.equal(m.active, true, 'the buffered beat plays right after the first');
assert.equal(m.ev.to, 7, 'it shows the buffered level');
assert.equal(m.pending, null, 'the queue drained');
step(m, scene, () => settled(m), 20000);
assert.equal(m.active, false, '… and the scene settles');
console.log('ok 3 — mid-beat level-ups buffer and play in order');
}
// ---------------------------------------------------------------------------
// 4 — TEARDOWN (destroy() ends the beat; every created object dies)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const m = new LevelUpMarquee(scene);
m.play({ from: 7, to: 9 });
step(m, scene, () => scene.time.now - m.t0 >= 1200, 1500); // mid-corruption
assert.equal(m.active, true);
const statics = m._o.slice();
const beat = m._beat.slice();
m.destroy();
assert.equal(m.active, false, 'destroy ends the beat');
assert.ok(statics.every((o) => o.destroyed), 'the static layer destroyed');
assert.ok(beat.every((o) => o.destroyed), 'the live beat transients destroyed');
m.update(scene.time.now + 100, 16); // a dead marquee is a no-op
console.log('ok 4 — teardown is total');
}
// ---------------------------------------------------------------------------
// 5 — MAX (the cap lands as the beat's final state — the MAXED line)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const m = new LevelUpMarquee(scene);
const xp = new XpManager();
m.bind(xp);
// Jump the ledger to one below the cap, then cross it (the event's `to`
// is the max → the beat takes the FIRMWARE-MAXED branch).
xp.level = xp.maxLevel - 1;
xp.grant(xp.xpForLevel(xp.maxLevel), 'test');
assert.equal(m.active, true);
assert.equal(m.ev.to, xp.maxLevel, 'the cap level is the beat target');
assert.match(m._decodeNew.value, /FIRMWARE MAXED/, 'the max line is the decode target');
step(m, scene, () => settled(m), 20000);
console.log('ok 5 — the cap level plays the MAXED line');
}
// ---------------------------------------------------------------------------
// 6 — LOCK ON A LIVE CLOCK (the digit locks compare ELAPSED beat time, so
// a scene whose clock is minutes in must lock the same as one at zero —
// the regression where c.lockAt was stamped as an ABSOLUTE scene time
// and `el >= c.lockAt` never held, leaving the slots cycling random
// values off the level the player actually reached)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
scene.time.now = 60000; // a real session's clock, not the zero origin
const m = new LevelUpMarquee(scene);
m.play({ from: 7, to: 9 });
// Step just past the last lock (the rightmost locks at T.lockBase, the
// leftmost at T.lastLock) and check the slots read the FINAL digits.
step(m, scene, () => scene.time.now - m.t0 >= m.T.lastLock + 200, 5000);
assert.equal(m._digitCols[0].gt.main.text, '0', 'the left slot locked onto the final digit');
assert.equal(m._digitCols[1].gt.main.text, '9', 'the right slot locked onto the final digit');
step(m, scene, () => settled(m), 20000);
console.log('ok 6 — the digit locks land on a non-zero scene clock');
}
console.log('\nmarquee: all checks passed');