291 lines
12 KiB
JavaScript
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');
|