Some checks failed
Smoke Test / smoke (push) Failing after 9s
Merge PR #143 (squash)
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');
|
|
});
|