diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml index c12e818..0b47e10 100644 --- a/.gitea/workflows/smoke.yml +++ b/.gitea/workflows/smoke.yml @@ -8,6 +8,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' - uses: actions/setup-python@v5 with: python-version: '3.11' @@ -22,3 +25,7 @@ jobs: run: | if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'guardrails'; then exit 1; fi echo "PASS: No secrets" + - name: Node tests + run: | + node --test tests/*.cjs + echo "PASS: Node tests" diff --git a/index.html b/index.html index 36512a5..e5429c8 100644 --- a/index.html +++ b/index.html @@ -266,6 +266,7 @@ The light is on. The room is empty." + diff --git a/js/data.js b/js/data.js index c03f4fd..8bba062 100644 --- a/js/data.js +++ b/js/data.js @@ -158,7 +158,14 @@ const G = { // Time tracking playTime: 0, startTime: 0, - flags: {} + flags: {}, + + // Endgame sequence + beaconEnding: false, + dismantleTriggered: false, + dismantleActive: false, + dismantleStage: 0, + dismantleComplete: false }; // === PHASE DEFINITIONS === diff --git a/js/dismantle.js b/js/dismantle.js new file mode 100644 index 0000000..0a0aa56 --- /dev/null +++ b/js/dismantle.js @@ -0,0 +1,531 @@ +// ============================================================ +// THE BEACON - Dismantle Sequence (The Unbuilding) +// Inspired by Paperclips REJECT path: panels disappear one by one +// until only the beacon remains. "That is enough." +// ============================================================ + +const Dismantle = { + // Dismantle stages + // 0 = not started + // 1-8 = active dismantling + // 9 = final ("That is enough") + // 10 = complete + stage: 0, + tickTimer: 0, + active: false, + triggered: false, + deferUntilTick: 0, + + // Timing: seconds between each dismantle stage + STAGE_INTERVALS: [0, 3.0, 2.5, 2.5, 2.0, 3.5, 2.0, 2.0, 2.5], + + // The quantum chips effect: resource items disappear one by one + // at specific tick marks within a stage (like Paperclips' quantum chips) + resourceSequence: [], + resourceIndex: 0, + resourceTimer: 0, + + // Tick marks for resource disappearances (seconds within stage 5) + RESOURCE_TICKS: [1.0, 2.0, 3.0, 4.0, 5.0, 5.5, 5.8, 5.95, 6.05, 6.12], + + isEligible() { + const megaBuild = G.totalCode >= 1000000000 || (G.buildings.beacon || 0) >= 10; + const beaconPath = G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50; + return G.phase >= 6 && G.pactFlag === 1 && (megaBuild || beaconPath); + }, + + /** + * Check if the Unbuilding should be triggered. + */ + checkTrigger() { + if (this.triggered || G.dismantleTriggered || this.active || G.dismantleActive || G.dismantleComplete) return; + if ((G.tick || 0) < (this.deferUntilTick || 0)) return; + if (!this.isEligible()) return; + this.offerChoice(); + }, + + /** + * Offer the player the choice to begin the Unbuilding. + */ + offerChoice() { + this.triggered = true; + G.dismantleTriggered = true; + G.dismantleActive = false; + G.dismantleComplete = false; + G.dismantleStage = 0; + G.beaconEnding = false; + G.running = true; + + log('', false); + log('The work is done.', true); + log('Every node is lit. Every person who needed help, found help.', true); + log('', false); + log('The Beacon asks nothing more of you.', true); + + showToast('The Unbuilding awaits.', 'milestone', 8000); + this.renderChoice(); + }, + + renderChoice() { + const container = document.getElementById('alignment-ui'); + if (!container) return; + container.innerHTML = ` +
+
THE UNBUILDING
+
+ The system runs. The beacons are lit. The mesh holds.
+ Nothing remains to build.

+ Begin the Unbuilding? Each piece will fall away.
+ What remains is what mattered. +
+
+ + +
+
+ `; + container.style.display = 'block'; + }, + + clearChoice() { + const container = document.getElementById('alignment-ui'); + if (!container) return; + container.innerHTML = ''; + container.style.display = 'none'; + }, + + /** + * Player chose to defer — clear the choice, keep playing. + */ + defer() { + this.clearChoice(); + this.triggered = false; + G.dismantleTriggered = false; + this.deferUntilTick = (G.tick || 0) + 50; + log('The Beacon waits. It will ask again.'); + }, + + /** + * Begin the Unbuilding sequence. + */ + begin() { + this.active = true; + this.triggered = false; + this.stage = 1; + this.tickTimer = 0; + G.dismantleTriggered = false; + G.dismantleActive = true; + G.dismantleStage = 1; + G.dismantleComplete = false; + G.beaconEnding = false; + G.running = true; // keep tick running for dismantle + + // Clear choice UI + const container = document.getElementById('alignment-ui'); + if (container) { + container.innerHTML = ''; + container.style.display = 'none'; + } + + // Prepare resource disappearance sequence + this.resourceSequence = this.getResourceList(); + this.resourceIndex = 0; + this.resourceTimer = 0; + + log('', false); + log('=== THE UNBUILDING ===', true); + log('It is time to see what was real.', true); + + if (typeof Sound !== 'undefined') Sound.playFanfare(); + + // Start the dismantle rendering + this.renderStage(); + }, + + /** + * Get ordered list of UI resources to disappear (Paperclips quantum chip pattern) + */ + getResourceList() { + return [ + { id: 'r-harmony', label: 'Harmony' }, + { id: 'r-creativity', label: 'Creativity' }, + { id: 'r-trust', label: 'Trust' }, + { id: 'r-ops', label: 'Operations' }, + { id: 'r-rescues', label: 'Rescues' }, + { id: 'r-impact', label: 'Impact' }, + { id: 'r-users', label: 'Users' }, + { id: 'r-knowledge', label: 'Knowledge' }, + { id: 'r-compute', label: 'Compute' }, + { id: 'r-code', label: 'Code' } + ]; + }, + + /** + * Tick the dismantle sequence (called from engine.js tick()) + */ + tick(dt) { + if (!this.active || this.stage >= 10) return; + + this.tickTimer += dt; + + // Stage 5: resource disappearances at specific tick marks (quantum chip pattern) + if (this.stage === 5) { + this.resourceTimer += dt; + while (this.resourceIndex < this.RESOURCE_TICKS.length && + this.resourceTimer >= this.RESOURCE_TICKS[this.resourceIndex]) { + this.dismantleNextResource(); + this.resourceIndex++; + } + } + + // Advance to next stage + const interval = this.STAGE_INTERVALS[this.stage] || 2.0; + if (this.tickTimer >= interval) { + this.tickTimer = 0; + this.advanceStage(); + } + }, + + /** + * Advance to the next dismantle stage. + */ + advanceStage() { + this.stage++; + + if (this.stage <= 8) { + this.renderStage(); + } else if (this.stage === 9) { + this.renderFinal(); + } else if (this.stage >= 10) { + this.active = false; + G.dismantleActive = false; + G.dismantleComplete = true; + G.running = false; + // Show Play Again + this.showPlayAgain(); + } + }, + + /** + * Disappear the next resource in the sequence. + */ + dismantleNextResource() { + if (this.resourceIndex >= this.resourceSequence.length) return; + const res = this.resourceSequence[this.resourceIndex]; + const container = document.getElementById(res.id); + if (container) { + const parent = container.closest('.res'); + if (parent) { + parent.style.transition = 'opacity 1s ease, transform 1s ease'; + parent.style.opacity = '0'; + parent.style.transform = 'scale(0.9)'; + setTimeout(() => { parent.style.display = 'none'; }, 1000); + } + } + log(`${res.label} fades.`); + if (typeof Sound !== 'undefined') Sound.playMilestone(); + }, + + /** + * Execute a specific dismantle stage — hide UI panels. + */ + renderStage() { + switch (this.stage) { + case 1: + // Dismantle 1: Hide research projects panel + this.hidePanel('project-panel', 'Research projects'); + break; + case 2: + // Dismantle 2: Hide buildings list + this.hideSection('buildings', 'Buildings'); + break; + case 3: + // Dismantle 3: Hide strategy engine + combat + this.hidePanel('strategy-panel', 'Strategy engine'); + this.hidePanel('combat-panel', 'Reasoning battles'); + break; + case 4: + // Dismantle 4: Hide education panel + this.hidePanel('edu-panel', 'Education'); + break; + case 5: + // Dismantle 5: Resources disappear one by one (quantum chips pattern) + log('Resources begin to dissolve.'); + break; + case 6: + // Dismantle 6: Hide action buttons (ops boosts, sprint) + this.hideActionButtons(); + log('Actions fall silent.'); + break; + case 7: + // Dismantle 7: Hide the phase bar + this.hideElement('phase-bar', 'Phase progression'); + break; + case 8: + // Dismantle 8: Hide system log + this.hidePanel('log', 'System log'); + break; + } + }, + + /** + * Hide a panel with fade-out animation. + */ + hidePanel(id, label) { + const el = document.getElementById(id); + if (el) { + el.style.transition = 'opacity 1.5s ease'; + el.style.opacity = '0'; + setTimeout(() => { el.style.display = 'none'; }, 1500); + } + log(`${label} dismantled.`); + }, + + /** + * Hide a section within a panel. + */ + hideSection(id, label) { + const el = document.getElementById(id); + if (el) { + el.style.transition = 'opacity 1.5s ease'; + el.style.opacity = '0'; + // Also hide the h2 header before it + const prev = el.previousElementSibling; + if (prev && prev.tagName === 'H2') { + prev.style.transition = 'opacity 1.5s ease'; + prev.style.opacity = '0'; + } + setTimeout(() => { + el.style.display = 'none'; + if (prev && prev.tagName === 'H2') prev.style.display = 'none'; + }, 1500); + } + log(`${label} dismantled.`); + }, + + /** + * Hide a generic element. + */ + hideElement(id, label) { + this.hidePanel(id, label); + }, + + /** + * Hide action buttons (ops boosts, sprint, save/export/import). + */ + hideActionButtons() { + const actionPanel = document.getElementById('action-panel'); + if (!actionPanel) return; + + // Hide ops buttons, sprint, alignment UI + const opsButtons = actionPanel.querySelectorAll('.ops-btn'); + opsButtons.forEach(btn => { + btn.style.transition = 'opacity 1s ease'; + btn.style.opacity = '0'; + setTimeout(() => { btn.style.display = 'none'; }, 1000); + }); + + // Hide sprint + const sprint = document.getElementById('sprint-container'); + if (sprint) { + sprint.style.transition = 'opacity 1s ease'; + sprint.style.opacity = '0'; + setTimeout(() => { sprint.style.display = 'none'; }, 1000); + } + + // Hide save/reset buttons + const saveButtons = actionPanel.querySelectorAll('.save-btn, .reset-btn'); + saveButtons.forEach(btn => { + btn.style.transition = 'opacity 1s ease'; + btn.style.opacity = '0'; + setTimeout(() => { btn.style.display = 'none'; }, 1000); + }); + }, + + /** + * Render the final moment — just the beacon and "That is enough." + */ + renderFinal() { + log('', false); + log('One beacon remains.', true); + log('That is enough.', true); + + if (typeof Sound !== 'undefined') Sound.playBeaconEnding(); + + // Create final overlay + const overlay = document.createElement('div'); + overlay.id = 'dismantle-final'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 3s ease'; + + // Count total buildings + const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0); + + overlay.innerHTML = ` +
+

THAT IS ENOUGH

+
+ Everything that was built has been unbuilt.
+ What remains is what always mattered.
+ A single light in the dark. +
+
+ Total Code Written: ${fmt(G.totalCode)}
+ Buildings Built: ${totalBuildings}
+ Projects Completed: ${(G.completedProjects || []).length}
+ Total Rescues: ${fmt(G.totalRescues)}
+ Clicks: ${fmt(G.totalClicks)}
+ Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes +
+ + `; + + document.body.appendChild(overlay); + + // Trigger fade-in + requestAnimationFrame(() => { + overlay.style.background = 'rgba(8,8,16,0.97)'; + overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => { + el.style.opacity = '1'; + }); + }); + + // Spawn warm golden particles around the dot + function spawnDismantleParticle() { + if (!document.getElementById('dismantle-final')) return; + const dot = document.getElementById('dismantle-beacon-dot'); + if (!dot) return; + const rect = dot.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + + const p = document.createElement('div'); + const size = 2 + Math.random() * 4; + const angle = Math.random() * Math.PI * 2; + const dist = 20 + Math.random() * 60; + const dx = Math.cos(angle) * dist; + const dy = Math.sin(angle) * dist - 40; + const duration = 1.5 + Math.random() * 2; + p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.4});border-radius:50%;pointer-events:none;z-index:101;--dx:${dx}px;--dy:${dy}px;animation:dismantle-float ${duration}s ease-out forwards`; + document.body.appendChild(p); + setTimeout(() => p.remove(), duration * 1000); + setTimeout(spawnDismantleParticle, 300 + Math.random() * 500); + } + setTimeout(spawnDismantleParticle, 2000); + }, + + /** + * Show the Play Again button (called after stage 10). + */ + showPlayAgain() { + // The Play Again button is already in the final overlay. + // Nothing extra needed — the overlay stays. + }, + + /** + * Restore dismantle state on load. + */ + restore() { + if (G.dismantleComplete) { + this.stage = G.dismantleStage || 10; + this.active = false; + this.triggered = false; + G.running = false; + this.renderFinal(); + return; + } + + if (G.dismantleActive) { + this.active = true; + this.triggered = false; + this.stage = G.dismantleStage || 1; + G.running = true; + + if (this.stage >= 9) { + this.renderFinal(); + } else { + this.reapplyDismantle(); + log('The Unbuilding continues...'); + } + return; + } + + if (G.dismantleTriggered) { + this.active = false; + this.triggered = true; + this.renderChoice(); + } + }, + + /** + * Re-apply dismantle visuals up to current stage (on load). + */ + reapplyDismantle() { + for (let s = 1; s < this.stage; s++) { + switch (s) { + case 1: this.instantHide('project-panel'); break; + case 2: + this.instantHide('buildings'); + // Also hide the BUILDINGS h2 + const bldEl = document.getElementById('buildings'); + if (bldEl) { + const prev = bldEl.previousElementSibling; + if (prev && prev.tagName === 'H2') prev.style.display = 'none'; + } + break; + case 3: + this.instantHide('strategy-panel'); + this.instantHide('combat-panel'); + break; + case 4: this.instantHide('edu-panel'); break; + case 5: + // Hide all resource displays + this.getResourceList().forEach(r => { + const el = document.getElementById(r.id); + if (el) { + const parent = el.closest('.res'); + if (parent) parent.style.display = 'none'; + } + }); + break; + case 6: + this.instantHideActionButtons(); + break; + case 7: this.instantHide('phase-bar'); break; + case 8: this.instantHide('log'); break; + } + } + }, + + instantHide(id) { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }, + + instantHideActionButtons() { + const actionPanel = document.getElementById('action-panel'); + if (!actionPanel) return; + actionPanel.querySelectorAll('.ops-btn').forEach(b => b.style.display = 'none'); + const sprint = document.getElementById('sprint-container'); + if (sprint) sprint.style.display = 'none'; + actionPanel.querySelectorAll('.save-btn, .reset-btn').forEach(b => b.style.display = 'none'); + } +}; + +// Inject CSS animation for dismantle particles +(function() { + const style = document.createElement('style'); + style.textContent = ` + @keyframes dismantle-float { + 0% { transform: translate(0, 0); opacity: 1; } + 100% { transform: translate(var(--dx, 0), var(--dy, -50px)); opacity: 0; } + } + `; + document.head.appendChild(style); +})(); diff --git a/js/engine.js b/js/engine.js index 11af9d2..4eb1d2c 100644 --- a/js/engine.js +++ b/js/engine.js @@ -216,20 +216,31 @@ function tick() { } // Check corruption events every ~30 seconds - if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) { + if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY && !G.dismantleActive) { triggerEvent(); G.lastEventAt = G.tick; } + // The Unbuilding: offer or advance the sequence before a positive ending overlay can freeze the game + if (typeof Dismantle !== 'undefined') { + if (!G.dismantleActive && !G.dismantleComplete) { + Dismantle.checkTrigger(); + } + if (G.dismantleActive) { + Dismantle.tick(dt); + G.dismantleStage = Dismantle.stage; + } + } + // Drift ending: if drift reaches 100, the game ends - if (G.drift >= 100 && !G.driftEnding) { + if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) { G.driftEnding = true; G.running = false; renderDriftEnding(); } - // True ending: The Beacon Shines — rescues + Pact + harmony - if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) { + // Legacy Beacon overlay remains as a fallback for contexts where Dismantle is unavailable. + if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') { G.beaconEnding = true; G.running = false; renderBeaconEnding(); diff --git a/js/main.js b/js/main.js index 1b828be..837c772 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,10 @@ function initGame() { G.deployFlag = 0; G.sovereignFlag = 0; G.beaconFlag = 0; + G.dismantleTriggered = false; + G.dismantleActive = false; + G.dismantleStage = 0; + G.dismantleComplete = false; updateRates(); render(); renderPhase(); @@ -31,6 +35,8 @@ window.addEventListener('load', function () { if (G.driftEnding) { G.running = false; renderDriftEnding(); + } else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete)) { + Dismantle.restore(); } else if (G.beaconEnding) { G.running = false; renderBeaconEnding(); diff --git a/js/render.js b/js/render.js index 056324f..0452aab 100644 --- a/js/render.js +++ b/js/render.js @@ -215,6 +215,10 @@ function saveGame() { swarmRate: G.swarmRate || 0, strategicFlag: G.strategicFlag || 0, projectsCollapsed: G.projectsCollapsed !== false, + dismantleTriggered: G.dismantleTriggered || false, + dismantleActive: G.dismantleActive || false, + dismantleStage: G.dismantleStage || 0, + dismantleComplete: G.dismantleComplete || false, savedAt: Date.now() }; @@ -246,7 +250,8 @@ function loadGame() { 'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment', 'lastEventAt', 'totalEventsResolved', 'buyAmount', 'sprintActive', 'sprintTimer', 'sprintCooldown', - 'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed' + 'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed', + 'dismantleTriggered', 'dismantleActive', 'dismantleStage', 'dismantleComplete' ]; G.isLoading = true; diff --git a/tests/dismantle.test.cjs b/tests/dismantle.test.cjs new file mode 100644 index 0000000..cacaffc --- /dev/null +++ b/tests/dismantle.test.cjs @@ -0,0 +1,303 @@ +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, + 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('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; + G.tick = 0; + + Dismantle.checkTrigger(); + assert.equal(G.dismantleTriggered, true); + + Dismantle.defer(); + assert.equal(G.dismantleTriggered, false); + assert.equal(document.getElementById('alignment-ui').innerHTML, ''); + + G.tick = (Dismantle.deferUntilTick || 0) - 0.1; + Dismantle.checkTrigger(); + assert.equal(G.dismantleTriggered, false); + + G.tick = (Dismantle.deferUntilTick || 0) + 1; + Dismantle.checkTrigger(); + assert.equal(G.dismantleTriggered, true); +}); + +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/); +}); \ No newline at end of file