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