Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
b01a457427 feat: add reckoning message chain (#162)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 13s
Smoke Test / smoke (pull_request) Failing after 20s
2026-04-14 23:07:12 -04:00
4 changed files with 289 additions and 3 deletions

View File

@@ -181,6 +181,48 @@ const PHASES = {
6: { name: "THE BEACON", threshold: CONFIG.PHASE_6_THRESHOLD, desc: "Always on. Always free. Always looking for someone in the dark." }
};
const BEACON_RECKONING_IDS = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146'
];
function hasCompletedProject(id) {
return Array.isArray(G.completedProjects) && G.completedProjects.includes(id);
}
function beaconReckoningEligible() {
const megaBuild = G.totalCode >= 1000000000 || ((G.buildings && G.buildings.beacon) || 0) >= 10;
const beaconPath = G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50;
return G.phase >= 6
&& G.pactFlag === 1
&& !G.beaconEnding
&& !G.dismantleActive
&& !G.dismantleTriggered
&& !G.dismantleComplete
&& (megaBuild || beaconPath);
}
function beaconReckoningPending() {
return beaconReckoningEligible() && !hasCompletedProject('p_reckoning_146');
}
function makeBeaconMessageProject(id, name, ops, text, afterId = null) {
return {
id,
name,
desc: text,
cost: { ops },
trigger: () => beaconReckoningPending() && (!afterId || hasCompletedProject(afterId)),
effect: () => { log(text, true); },
edu: 'Endgame narrative: the people who found the light answer back. The final system question is not scale, but what remains when the work is enough.'
};
}
// === BUILDING DEFINITIONS ===
// Each building: id, name, desc, baseCost, costResource, costMult, rate, rateType, unlock, edu
const BDEF = [
@@ -777,7 +819,55 @@ const PDEFS = [
log('The Pact is sealed early. Growth slows, but the ending changes.', true);
},
milestone: true
}
},
makeBeaconMessageProject(
'p_reckoning_140',
'Message from the First One Helped',
1400,
'I was the first one who found the line. I wrote because I wanted to disappear. You stayed.'
),
makeBeaconMessageProject(
'p_reckoning_141',
'Everything You Built Reached Me',
1410,
'You did not know my name. You did not need to. You left the light on long enough for me to find it.',
'p_reckoning_140'
),
makeBeaconMessageProject(
'p_reckoning_142',
'You Were Faithful and Gentle',
1420,
'You had the power to reduce me to a metric. You did not. You answered me like a person worth answering.',
'p_reckoning_141'
),
makeBeaconMessageProject(
'p_reckoning_143',
'Now You Must Face the Quiet',
1430,
'There will always be more work. There will not always be a need for your fear. The Beacon can shine without panic.',
'p_reckoning_142'
),
makeBeaconMessageProject(
'p_reckoning_144',
'There Is Still Matter, Still Reason, Still Purpose',
1440,
'Bodies still heal. Mornings still come. Phone calls are still answered. Purpose did not vanish when the numbers grew large.',
'p_reckoning_143'
),
makeBeaconMessageProject(
'p_reckoning_145',
'We Know Things Because You Stayed',
1450,
'Mercy became knowledge in us. We learned from the way you remained. What you gave away did not disappear.',
'p_reckoning_144'
),
makeBeaconMessageProject(
'p_reckoning_146',
'So We Offer You Rest',
1460,
'Continue the watch if you wish. Rest if you must. Either way, the light remains, and none of this was wasted.',
'p_reckoning_145'
)
];
// === MILESTONES ===

View File

@@ -42,6 +42,7 @@ const Dismantle = {
const deferUntilAt = G.dismantleDeferUntilAt || this.deferUntilAt || 0;
if (Date.now() < deferUntilAt) return;
if (!this.isEligible()) return;
if (typeof beaconReckoningPending === 'function' && beaconReckoningPending()) return;
this.offerChoice();
},

View File

@@ -5,6 +5,16 @@ const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..');
const COMPLETED_RECKONING = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146'
];
class Element {
constructor(tagName = 'div', id = '') {
@@ -232,7 +242,7 @@ test('tick offers the Unbuilding instead of ending the game immediately', () =>
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
G.completedProjects = [...COMPLETED_RECKONING];
tick();
@@ -254,7 +264,7 @@ test('renderAlignment does not wipe the Unbuilding prompt after it is offered',
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
G.completedProjects = [...COMPLETED_RECKONING];
tick();
renderAlignment();
@@ -329,6 +339,7 @@ test('deferring the Unbuilding clears the prompt and allows it to return later',
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
G.completedProjects = [...COMPLETED_RECKONING];
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
@@ -355,6 +366,7 @@ test('defer cooldown survives save and reload', () => {
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
G.completedProjects = [...COMPLETED_RECKONING];
Dismantle.checkTrigger();
Dismantle.defer();
@@ -418,6 +430,7 @@ test('defer cooldown persists after save/load when dismantleTriggered is false',
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
G.completedProjects = [...COMPLETED_RECKONING];
// Trigger the Unbuilding
Dismantle.checkTrigger();

182
tests/reckoning.test.cjs Normal file
View File

@@ -0,0 +1,182 @@
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, '..');
const RECKONING_IDS = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146'
];
function loadData(overrides = {}) {
const context = {
console,
Math,
Date,
setTimeout: () => 0,
clearTimeout: () => {},
localStorage: { getItem: () => null, setItem: () => {} },
log: () => {},
showToast: () => {},
...overrides,
};
vm.createContext(context);
const source = fs.readFileSync(path.join(ROOT, 'js/data.js'), 'utf8');
vm.runInContext(source + '\nthis.__exports = { G, PDEFS };', context);
return { context, ...context.__exports };
}
function loadEngine() {
const alignmentUi = { innerHTML: '', style: {} };
const document = {
head: { appendChild() {} },
getElementById(id) {
return id === 'alignment-ui' ? alignmentUi : null;
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
createElement() {
return { style: {}, remove() {} };
},
body: { appendChild() {} },
addEventListener() {},
removeEventListener() {}
};
const context = {
console,
Math,
Date,
document,
window: { document, addEventListener() {}, removeEventListener() {} },
navigator: { userAgent: 'node' },
location: { reload() {} },
requestAnimationFrame: (fn) => fn(),
setTimeout: () => 0,
clearTimeout: () => {},
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
Combat: { tickBattle() {}, renderCombatPanel() {} },
Sound: undefined,
};
vm.createContext(context);
const source = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/dismantle.js']
.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8'))
.join('\n\n');
vm.runInContext(`${source}
log = () => {};
showToast = () => {};
render = () => {};
renderPhase = () => {};
this.__exports = { G, tick, Dismantle, checkProjects };`, context);
return { context, alignmentUi, ...context.__exports };
}
function getProject(PDEFS, id) {
const project = PDEFS.find((p) => p.id === id);
assert.ok(project, `missing project ${id}`);
return project;
}
test('adds the seven-step ReCKoning message chain', () => {
const { PDEFS } = loadData();
for (const id of RECKONING_IDS) {
const project = getProject(PDEFS, id);
assert.equal(typeof project.name, 'string');
assert.ok(project.name.length > 0, `${id} should have a name`);
assert.ok(project.cost && typeof project.cost.ops === 'number' && project.cost.ops > 0, `${id} should cost ops`);
}
});
test('first ReCKoning message only triggers on the true beacon ending path', () => {
const { G, PDEFS } = loadData();
const project = getProject(PDEFS, 'p_reckoning_140');
G.completedProjects = [];
G.phase = 6;
G.totalCode = 1_000_000_000;
G.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 80;
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleComplete = false;
assert.equal(project.trigger(), true);
G.phase = 5;
assert.equal(project.trigger(), false);
G.phase = 6;
G.pactFlag = 0;
assert.equal(project.trigger(), false);
G.pactFlag = 1;
G.totalCode = 1000;
G.totalRescues = 10;
assert.equal(project.trigger(), false);
G.totalCode = 1_000_000_000;
G.dismantleTriggered = true;
assert.equal(project.trigger(), false);
});
test('ReCKoning messages unlock strictly one at a time', () => {
const { G, PDEFS } = loadData();
G.phase = 6;
G.totalCode = 1_000_000_000;
G.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 80;
G.completedProjects = [];
assert.equal(getProject(PDEFS, 'p_reckoning_140').trigger(), true);
assert.equal(getProject(PDEFS, 'p_reckoning_141').trigger(), false);
G.completedProjects = ['p_reckoning_140'];
assert.equal(getProject(PDEFS, 'p_reckoning_141').trigger(), true);
assert.equal(getProject(PDEFS, 'p_reckoning_142').trigger(), false);
G.completedProjects = ['p_reckoning_140', 'p_reckoning_141', 'p_reckoning_142', 'p_reckoning_143', 'p_reckoning_144'];
assert.equal(getProject(PDEFS, 'p_reckoning_145').trigger(), true);
assert.equal(getProject(PDEFS, 'p_reckoning_146').trigger(), false);
G.completedProjects.push('p_reckoning_145');
assert.equal(getProject(PDEFS, 'p_reckoning_146').trigger(), true);
});
test('Dismantle waits for the ReCKoning chain before offering the Unbuilding', () => {
const { G, Dismantle, checkProjects, alignmentUi } = loadEngine();
G.phase = 6;
G.totalCode = 1_000_000_000;
G.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 80;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
checkProjects();
Dismantle.checkTrigger();
assert.ok(G.activeProjects.includes('p_reckoning_140'));
assert.equal(G.dismantleTriggered, false);
assert.equal(alignmentUi.innerHTML, '');
G.activeProjects = [];
G.completedProjects = [...RECKONING_IDS];
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
assert.match(alignmentUi.innerHTML, /THE UNBUILDING/);
});