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