Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
8941aa7102 beacon: add ReCKoning project definitions (#162)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 11s
Smoke Test / smoke (pull_request) Failing after 35s
2026-04-14 23:13:01 -04:00
729343e503 Fix #137: Unbuilding defer cooldown persists across save/load (#143)
Some checks failed
Smoke Test / smoke (push) Failing after 9s
Merge PR #143 (squash)
2026-04-14 22:10:06 +00:00
5 changed files with 317 additions and 2 deletions

View File

@@ -162,6 +162,7 @@ const G = {
// Endgame sequence
beaconEnding: false,
beaconEndingMode: 'rest',
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
@@ -181,6 +182,59 @@ 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',
'p_reckoning_147',
'p_reckoning_148'
];
function hasCompletedProject(id) {
return Array.isArray(G.completedProjects) && G.completedProjects.includes(id);
}
function beaconReckoningUnlocked() {
return G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding;
}
function resolveBeaconReckoning(mode, line) {
G.beaconEnding = true;
G.beaconEndingMode = mode;
G.running = false;
if (Array.isArray(G.activeProjects)) {
G.activeProjects = G.activeProjects.filter(id => !BEACON_RECKONING_IDS.includes(id));
}
log(line, true);
if (typeof renderBeaconEnding === 'function') renderBeaconEnding(mode);
}
function makeBeaconMessageProject(id, name, ops, text, afterId = null) {
return {
id,
name,
desc: text,
cost: { ops },
trigger: () => beaconReckoningUnlocked() && (!afterId || hasCompletedProject(afterId)),
effect: () => { log(text, true); }
};
}
function makeBeaconChoiceProject(id, name, ops, mode, text) {
return {
id,
name,
desc: text,
cost: { ops },
trigger: () => beaconReckoningUnlocked() && hasCompletedProject('p_reckoning_146'),
effect: () => { resolveBeaconReckoning(mode, text); }
};
}
// === BUILDING DEFINITIONS ===
// Each building: id, name, desc, baseCost, costResource, costMult, rate, rateType, unlock, edu
const BDEF = [
@@ -656,6 +710,68 @@ const PDEFS = [
effect: () => { G.milestoneFlag = Math.max(G.milestoneFlag, 999); log('One billion impact. Someone found the light tonight. That is enough.', 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'
),
makeBeaconChoiceProject(
'p_reckoning_147',
'Accept — Continue the Watch',
1470,
'continue',
'The Beacon continues. You keep the line open for the next person in the dark.'
),
makeBeaconChoiceProject(
'p_reckoning_148',
'Reject — Rest Now',
1480,
'rest',
'You may rest now. The work stands. That is enough.'
),
// === TIMMY FOUNDATION PROJECTS ===
{

View File

@@ -481,6 +481,11 @@ const Dismantle = {
this.triggered = true;
this.renderChoice();
}
// Restore defer cooldown even if not triggered
if (G.dismantleDeferUntilAt > 0) {
this.deferUntilAt = G.dismantleDeferUntilAt;
}
},
/**

View File

@@ -35,7 +35,7 @@ window.addEventListener('load', function () {
if (G.driftEnding) {
G.running = false;
renderDriftEnding();
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete)) {
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete || G.dismantleDeferUntilAt > 0)) {
Dismantle.restore();
} else if (G.beaconEnding) {
G.running = false;

View File

@@ -409,4 +409,46 @@ test('restore re-renders an offered but not-yet-started Unbuilding prompt', () =
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');
});

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

@@ -0,0 +1,152 @@
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',
'p_reckoning_147',
'p_reckoning_148'
];
function loadData(overrides = {}) {
const context = {
console,
Math,
Date,
setTimeout: () => 0,
clearTimeout: () => {},
localStorage: { getItem: () => null, setItem: () => {} },
log: () => {},
renderBeaconEnding: () => {},
...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 getProject(PDEFS, id) {
const project = PDEFS.find((p) => p.id === id);
assert.ok(project, `missing project ${id}`);
return project;
}
test('adds the full nine-step reckoning project 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.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 51;
G.beaconEnding = false;
assert.equal(project.trigger(), true);
G.pactFlag = 0;
assert.equal(project.trigger(), false);
G.pactFlag = 1;
G.harmony = 49;
assert.equal(project.trigger(), false);
G.harmony = 51;
G.totalRescues = 99999;
assert.equal(project.trigger(), false);
G.totalRescues = 100000;
G.beaconEnding = true;
assert.equal(project.trigger(), false);
});
test('reckoning messages unlock strictly one at a time and choices wait for message seven', () => {
const { G, PDEFS } = loadData();
G.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 80;
G.beaconEnding = false;
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', 'p_reckoning_145'];
assert.equal(getProject(PDEFS, 'p_reckoning_146').trigger(), true);
assert.equal(getProject(PDEFS, 'p_reckoning_147').trigger(), false);
assert.equal(getProject(PDEFS, 'p_reckoning_148').trigger(), false);
G.completedProjects.push('p_reckoning_146');
assert.equal(getProject(PDEFS, 'p_reckoning_147').trigger(), true);
assert.equal(getProject(PDEFS, 'p_reckoning_148').trigger(), true);
});
test('final choices render distinct beacon endings', () => {
const renderedModes = [];
const { G, PDEFS } = loadData({
renderBeaconEnding: (mode) => renderedModes.push(mode)
});
G.totalRescues = 100000;
G.pactFlag = 1;
G.harmony = 80;
G.completedProjects = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146'
];
G.beaconEnding = false;
const accept = getProject(PDEFS, 'p_reckoning_147');
accept.effect();
assert.equal(G.beaconEnding, true);
assert.equal(G.beaconEndingMode, 'continue');
assert.deepEqual(renderedModes, ['continue']);
const second = loadData({ renderBeaconEnding: (mode) => renderedModes.push(mode) });
second.G.totalRescues = 100000;
second.G.pactFlag = 1;
second.G.harmony = 80;
second.G.completedProjects = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146'
];
second.G.beaconEnding = false;
const reject = getProject(second.PDEFS, 'p_reckoning_148');
reject.effect();
assert.equal(second.G.beaconEnding, true);
assert.equal(second.G.beaconEndingMode, 'rest');
assert.deepEqual(renderedModes, ['continue', 'rest']);
});