const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ROOT = path.resolve(__dirname, '..'); class Element { constructor(tagName = 'div', id = '') { this.tagName = String(tagName).toUpperCase(); this.id = id; this.style = {}; this.children = []; this.parentNode = null; this.previousElementSibling = null; this.innerHTML = ''; this.textContent = ''; this.className = ''; this.dataset = {}; this.attributes = {}; this._queryMap = new Map(); this.classList = { add: (...names) => { const set = new Set(this.className.split(/\s+/).filter(Boolean)); names.forEach((name) => set.add(name)); this.className = Array.from(set).join(' '); }, remove: (...names) => { const remove = new Set(names); this.className = this.className .split(/\s+/) .filter((name) => name && !remove.has(name)) .join(' '); } }; } appendChild(child) { child.parentNode = this; this.children.push(child); return child; } removeChild(child) { this.children = this.children.filter((candidate) => candidate !== child); if (child.parentNode === this) child.parentNode = null; return child; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } setAttribute(name, value) { this.attributes[name] = value; if (name === 'id') this.id = value; if (name === 'class') this.className = value; } querySelectorAll(selector) { return this._queryMap.get(selector) || []; } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } closest(selector) { if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this; return this.parentNode && typeof this.parentNode.closest === 'function' ? this.parentNode.closest(selector) : null; } getBoundingClientRect() { return { left: 0, top: 0, width: 12, height: 12 }; } } function buildDom() { const byId = new Map(); const body = new Element('body', 'body'); const head = new Element('head', 'head'); const document = { body, head, createElement(tagName) { return new Element(tagName); }, getElementById(id) { return byId.get(id) || null; }, addEventListener() {}, removeEventListener() {}, querySelector() { return null; }, querySelectorAll() { return []; } }; function register(element) { if (element.id) byId.set(element.id, element); return element; } const alignmentUi = register(new Element('div', 'alignment-ui')); const actionPanel = register(new Element('div', 'action-panel')); const sprintContainer = register(new Element('div', 'sprint-container')); const projectPanel = register(new Element('div', 'project-panel')); const buildingsHeader = new Element('h2'); const buildings = register(new Element('div', 'buildings')); buildings.previousElementSibling = buildingsHeader; const strategyPanel = register(new Element('div', 'strategy-panel')); const combatPanel = register(new Element('div', 'combat-panel')); const eduPanel = register(new Element('div', 'edu-panel')); const phaseBar = register(new Element('div', 'phase-bar')); const logPanel = register(new Element('div', 'log')); const logEntries = register(new Element('div', 'log-entries')); const toastContainer = register(new Element('div', 'toast-container')); body.appendChild(alignmentUi); body.appendChild(actionPanel); body.appendChild(sprintContainer); body.appendChild(projectPanel); body.appendChild(buildingsHeader); body.appendChild(buildings); body.appendChild(strategyPanel); body.appendChild(combatPanel); body.appendChild(eduPanel); body.appendChild(phaseBar); body.appendChild(logPanel); logPanel.appendChild(logEntries); body.appendChild(toastContainer); const opsBtn = new Element('button'); opsBtn.className = 'ops-btn'; const saveBtn = new Element('button'); saveBtn.className = 'save-btn'; const resetBtn = new Element('button'); resetBtn.className = 'reset-btn'; actionPanel._queryMap.set('.ops-btn', [opsBtn]); actionPanel._queryMap.set('.save-btn, .reset-btn', [saveBtn, resetBtn]); const resourceIds = [ 'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact', 'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony' ]; for (const id of resourceIds) { const wrapper = new Element('div'); wrapper.className = 'res'; const value = register(new Element('div', id)); wrapper.appendChild(value); body.appendChild(wrapper); } return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } }; } function loadBeacon({ includeRender = false } = {}) { const { document, window } = buildDom(); const storage = new Map(); const timerQueue = []; const context = { console, Math, Date, document, window, navigator: { userAgent: 'node' }, location: { reload() {} }, confirm: () => false, requestAnimationFrame: (fn) => fn(), setTimeout: (fn) => { timerQueue.push(fn); return timerQueue.length; }, clearTimeout: () => {}, localStorage: { getItem: (key) => (storage.has(key) ? storage.get(key) : null), setItem: (key, value) => storage.set(key, String(value)), removeItem: (key) => storage.delete(key) }, Combat: { tickBattle() {}, startBattle() {} }, Sound: undefined, }; vm.createContext(context); const files = ['js/data.js', 'js/utils.js', 'js/engine.js']; if (includeRender) files.push('js/render.js'); files.push('js/dismantle.js'); const source = files .map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')) .join('\n\n'); vm.runInContext(`${source} log = () => {}; showToast = () => {}; render = () => {}; renderPhase = () => {}; showOfflinePopup = () => {}; showSaveToast = () => {}; this.__exports = { G, Dismantle, tick, renderAlignment: typeof renderAlignment === 'function' ? renderAlignment : null, saveGame: typeof saveGame === 'function' ? saveGame : null, loadGame: typeof loadGame === 'function' ? loadGame : null };`, context); return { context, document, ...context.__exports, }; } test('tick offers the Unbuilding instead of ending the game immediately', () => { const { G, Dismantle, tick, document } = loadBeacon(); G.totalCode = 1_000_000_000; G.totalRescues = 100_000; G.phase = 6; G.pactFlag = 1; G.harmony = 60; G.beaconEnding = false; G.running = true; G.activeProjects = []; G.completedProjects = []; tick(); assert.equal(typeof Dismantle, 'object'); assert.equal(G.dismantleTriggered, true); assert.equal(G.beaconEnding, false); assert.equal(G.running, true); assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/); }); test('renderAlignment does not wipe the Unbuilding prompt after it is offered', () => { const { G, tick, renderAlignment, document } = loadBeacon({ includeRender: true }); G.totalCode = 1_000_000_000; G.totalRescues = 100_000; G.phase = 6; G.pactFlag = 1; G.harmony = 60; G.beaconEnding = false; G.running = true; G.activeProjects = []; G.completedProjects = []; tick(); renderAlignment(); assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/); }); test('active Unbuilding suppresses pending alignment event UI', () => { const { G, Dismantle, renderAlignment, document } = loadBeacon({ includeRender: true }); G.pendingAlignment = true; G.dismantleActive = true; Dismantle.active = true; renderAlignment(); assert.equal(document.getElementById('alignment-ui').innerHTML, ''); assert.equal(document.getElementById('alignment-ui').style.display, 'none'); }); test('stage five lasts long enough to dissolve every resource card', () => { const { G, Dismantle } = loadBeacon(); Dismantle.begin(); Dismantle.stage = 5; Dismantle.tickTimer = 0; Dismantle.resourceSequence = Dismantle.getResourceList(); Dismantle.resourceIndex = 0; Dismantle.resourceTimer = 0; G.dismantleActive = true; G.dismantleStage = 5; for (let i = 0; i < 63; i++) Dismantle.tick(0.1); assert.equal(Dismantle.resourceIndex, Dismantle.resourceSequence.length); }); test('save/load restores partial stage-five dissolve progress', () => { const { G, Dismantle, saveGame, loadGame, document } = loadBeacon({ includeRender: true }); G.startedAt = Date.now(); G.dismantleTriggered = true; G.dismantleActive = true; G.dismantleStage = 5; G.dismantleComplete = false; G.dismantleResourceIndex = 4; G.dismantleResourceTimer = 4.05; saveGame(); G.dismantleTriggered = false; G.dismantleActive = false; G.dismantleStage = 0; G.dismantleComplete = false; G.dismantleResourceIndex = 0; G.dismantleResourceTimer = 0; Dismantle.resourceIndex = 0; Dismantle.resourceTimer = 0; assert.equal(loadGame(), true); Dismantle.restore(); assert.equal(Dismantle.resourceIndex, 4); assert.equal(document.getElementById('r-harmony').closest('.res').style.display, 'none'); assert.equal(document.getElementById('r-ops').closest('.res').style.display, 'none'); assert.notEqual(document.getElementById('r-rescues').closest('.res').style.display, 'none'); }); test('deferring the Unbuilding clears the prompt and allows it to return later', () => { const { G, Dismantle, document } = loadBeacon(); G.totalCode = 1_000_000_000; G.phase = 6; G.pactFlag = 1; Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, true); Dismantle.defer(); assert.equal(G.dismantleTriggered, false); assert.equal(document.getElementById('alignment-ui').innerHTML, ''); Dismantle.deferUntilAt = Date.now() + 1000; G.dismantleDeferUntilAt = Dismantle.deferUntilAt; Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, false); Dismantle.deferUntilAt = Date.now() - 1; G.dismantleDeferUntilAt = Dismantle.deferUntilAt; Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, true); }); test('defer cooldown survives save and reload', () => { const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true }); G.startedAt = Date.now(); G.totalCode = 1_000_000_000; G.phase = 6; G.pactFlag = 1; Dismantle.checkTrigger(); Dismantle.defer(); assert.ok((Dismantle.deferUntilAt || 0) > Date.now()); saveGame(); G.dismantleTriggered = false; G.dismantleActive = false; G.dismantleComplete = false; G.dismantleDeferUntilAt = 0; Dismantle.triggered = false; Dismantle.deferUntilAt = 0; assert.equal(loadGame(), true); Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, false); }); test('save and load preserve dismantle progress', () => { const { G, saveGame, loadGame } = loadBeacon({ includeRender: true }); G.startedAt = Date.now(); G.dismantleTriggered = true; G.dismantleActive = true; G.dismantleStage = 4; G.dismantleComplete = false; saveGame(); G.dismantleTriggered = false; G.dismantleActive = false; G.dismantleStage = 0; G.dismantleComplete = true; assert.equal(loadGame(), true); assert.equal(G.dismantleTriggered, true); assert.equal(G.dismantleActive, true); assert.equal(G.dismantleStage, 4); assert.equal(G.dismantleComplete, false); }); test('restore re-renders an offered but not-yet-started Unbuilding prompt', () => { const { G, Dismantle, document } = loadBeacon(); G.dismantleTriggered = true; G.dismantleActive = false; G.dismantleComplete = false; Dismantle.triggered = true; Dismantle.restore(); assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/); }); test('defer cooldown persists after save/load when dismantleTriggered is false', () => { const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true }); G.startedAt = Date.now(); G.totalCode = 1_000_000_000; G.phase = 6; G.pactFlag = 1; // Trigger the Unbuilding Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, true); // Defer it Dismantle.defer(); assert.equal(G.dismantleTriggered, false); assert.ok((Dismantle.deferUntilAt || 0) > Date.now()); assert.ok((G.dismantleDeferUntilAt || 0) > Date.now()); // Save the game saveGame(); // Clear state (simulate reload) G.dismantleTriggered = false; G.dismantleActive = false; G.dismantleComplete = false; G.dismantleDeferUntilAt = 0; Dismantle.triggered = false; Dismantle.deferUntilAt = 0; // Load the game assert.equal(loadGame(), true); Dismantle.restore(); // Call restore to restore defer cooldown // The cooldown should be restored assert.ok((Dismantle.deferUntilAt || 0) > Date.now(), 'deferUntilAt should be restored'); assert.ok((G.dismantleDeferUntilAt || 0) > Date.now(), 'G.dismantleDeferUntilAt should be restored'); // checkTrigger should not trigger because cooldown is active Dismantle.checkTrigger(); assert.equal(G.dismantleTriggered, false, 'dismantleTriggered should remain false during cooldown'); });