- Added G.dismantleDeferUntilAt to restore() condition in main.js - Added defer cooldown restoration in dismantle.js restore() method - Added new test to verify cooldown persistence - Fixed issue where defer cooldown was bypassed after reload The bug occurred because: 1. When player defers, G.dismantleTriggered is set to false 2. On load, Dismantle.restore() was not called because condition only checked G.dismantleTriggered 3. This.deferUntilAt was not restored from G.dismantleDeferUntilAt 4. checkTrigger() would trigger immediately instead of honoring cooldown Fix: - Added G.dismantleDeferUntilAt > 0 to restore() condition in main.js - Added defer cooldown restoration in dismantle.js restore() method - Now defer cooldown properly survives save/load cycles All tests pass including new test for this specific scenario.
455 lines
13 KiB
JavaScript
455 lines
13 KiB
JavaScript
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');
|
|
});
|