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
6 changed files with 289 additions and 288 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

@@ -1,235 +0,0 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
function extractProjects(source) {
const start = source.indexOf('const PDEFS = [');
if (start === -1) return [];
const arrayStart = source.indexOf('[', start);
const arrayEnd = source.indexOf('];', arrayStart);
const body = source.slice(arrayStart + 1, arrayEnd);
const objects = [];
let depth = 0;
let objStart = -1;
let inSingle = false;
let inDouble = false;
let escaped = false;
for (let i = 0; i < body.length; i += 1) {
const ch = body[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (!inDouble && ch === "'") {
inSingle = !inSingle;
continue;
}
if (!inSingle && ch === '"') {
inDouble = !inDouble;
continue;
}
if (inSingle || inDouble) continue;
if (ch === '{') {
if (depth === 0) objStart = i;
depth += 1;
} else if (ch === '}') {
depth -= 1;
if (depth === 0 && objStart !== -1) {
objects.push(body.slice(objStart, i + 1));
objStart = -1;
}
}
}
return objects.map((obj) => {
const id = /id:\s*'([^']+)'/.exec(obj);
const name = /name:\s*'([^']+)'/.exec(obj);
const trigger = new RegExp(String.raw`trigger:\s*\(\)\s*=>\s*([\s\S]*?)(?:,\s*effect:|,\s*repeatable:|,\s*milestone:|\n\s*\})`).exec(obj);
const effect = /effect:\s*\(\)\s*=>\s*\{([\s\S]*?)\}/.exec(obj);
return {
id: id ? id[1] : null,
name: name ? name[1] : '',
trigger: trigger ? trigger[1].trim() : '',
effect: effect ? effect[1].trim() : '',
};
}).filter((project) => project.id);
}
function buildDependencyGraph(projects) {
const ids = new Set(projects.map((project) => project.id));
const nodes = projects.map((project) => ({ id: project.id, name: project.name }));
const edges = [];
for (const project of projects) {
for (const dep of ids) {
const token = `includes('${dep}')`;
if (project.trigger.includes(token)) {
edges.push({ from: dep, to: project.id, reason: 'completedProjects.includes' });
}
}
}
return { nodes, edges };
}
function isTerminalProject(project) {
return [
'G.beaconEnding = true',
'G.dismantleTriggered = true',
'G.dismantleActive = true',
'renderBeaconEnding',
'renderDriftEnding',
].some((token) => project.effect.includes(token));
}
function analyzeReckoningChain(projects) {
const graph = buildDependencyGraph(projects);
const reckoningProjects = projects
.filter((project) => project.id.startsWith('p_reckoning_'))
.sort((a, b) => a.id.localeCompare(b.id));
const deadEnds = [];
const fixProposals = [];
if (reckoningProjects.length === 0) {
deadEnds.push({
type: 'missing_root_project',
message: 'No ReCKoning projects exist in js/data.js.',
});
fixProposals.push({
type: 'create_chain',
proposals: [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
],
rationale: 'Create a narrative chain with at least one terminal project so endgame guardrails have a live project path.',
});
return {
totalProjects: projects.length,
totalEdges: graph.edges.length,
reckoningProjects: [],
hasReckoningProjects: false,
deadEnds,
fixProposals,
};
}
const outgoing = new Map();
for (const project of reckoningProjects) {
outgoing.set(project.id, graph.edges.filter((edge) => edge.from === project.id).map((edge) => edge.to));
}
const ids = reckoningProjects.map((project) => project.id);
const root = 'p_reckoning_140';
if (!ids.includes(root)) {
deadEnds.push({
type: 'missing_expected_root',
message: `Expected root project ${root} is missing.`,
});
fixProposals.push({
type: 'add_root',
project: root,
rationale: 'Root project anchors the ReCKoning chain and prevents the endgame panel from opening empty.',
});
}
for (const project of reckoningProjects) {
const next = outgoing.get(project.id) || [];
if (next.length === 0 && !isTerminalProject(project)) {
deadEnds.push({
type: 'leaf_without_terminal_effect',
project: project.id,
message: `${project.id} unlocks nothing downstream and does not end the sequence.`,
});
const num = Number(project.id.split('_').pop());
if (Number.isFinite(num)) {
fixProposals.push({
type: 'add_missing_link',
after: project.id,
project: `p_reckoning_${num + 1}`,
rationale: 'Add the next narrative project or mark this node as a true terminal endgame effect.',
});
}
}
}
return {
totalProjects: projects.length,
totalEdges: graph.edges.length,
reckoningProjects: reckoningProjects.map((project) => ({
id: project.id,
name: project.name,
outgoing: outgoing.get(project.id) || [],
terminal: isTerminalProject(project),
})),
hasReckoningProjects: true,
deadEnds,
fixProposals,
};
}
function loadSource(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function generateReport(filePath) {
const source = loadSource(filePath);
const projects = extractProjects(source);
return analyzeReckoningChain(projects);
}
function parseArgs(argv) {
const args = {
file: path.resolve(process.cwd(), 'js', 'data.js'),
json: false,
strict: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--file') {
args.file = path.resolve(process.cwd(), argv[i + 1]);
i += 1;
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--strict') {
args.strict = true;
}
}
return args;
}
function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
const report = generateReport(args.file);
if (args.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
process.stdout.write(`ReCKoning projects: ${report.reckoningProjects.length}\n`);
process.stdout.write(`Dead ends: ${report.deadEnds.length}\n`);
}
if (args.strict && report.deadEnds.length > 0) {
return 1;
}
return 0;
}
module.exports = {
extractProjects,
buildDependencyGraph,
analyzeReckoningChain,
generateReport,
main,
};
if (require.main === module) {
process.exitCode = main();
}

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/);
});

View File

@@ -1,50 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
extractProjects,
buildDependencyGraph,
analyzeReckoningChain,
generateReport,
} = require('../scripts/reckoning_chain_validator.cjs');
const DATA_PATH = path.resolve(__dirname, '..', 'js', 'data.js');
test('extractProjects finds the beacon project definitions', () => {
const fs = require('node:fs');
const source = fs.readFileSync(DATA_PATH, 'utf8');
const projects = extractProjects(source);
assert.ok(projects.length >= 30);
assert.ok(projects.some((project) => project.id === 'p_hermes_deploy'));
});
test('buildDependencyGraph links completed-project triggers', () => {
const projects = [
{ id: 'p_reckoning_140', name: 'Start', trigger: 'G.phase >= 6', effect: '' },
{ id: 'p_reckoning_141', name: 'Next', trigger: "G.completedProjects.includes('p_reckoning_140')", effect: '' },
];
const graph = buildDependencyGraph(projects);
assert.deepEqual(graph.edges, [
{ from: 'p_reckoning_140', to: 'p_reckoning_141', reason: 'completedProjects.includes' },
]);
});
test('analyzeReckoningChain flags missing chain on current beacon data', () => {
const report = generateReport(DATA_PATH);
assert.equal(report.hasReckoningProjects, false);
assert.ok(report.deadEnds.some((item) => item.type === 'missing_root_project'));
assert.ok(report.fixProposals.some((item) => item.type === 'create_chain'));
});
test('analyzeReckoningChain accepts a healthy synthetic ReCKoning chain', () => {
const projects = [
{ id: 'p_reckoning_140', name: 'ReCKoning I', trigger: 'G.phase >= 6', effect: '' },
{ id: 'p_reckoning_141', name: 'ReCKoning II', trigger: "G.completedProjects.includes('p_reckoning_140')", effect: '' },
{ id: 'p_reckoning_142', name: 'ReCKoning III', trigger: "G.completedProjects.includes('p_reckoning_141')", effect: 'G.beaconEnding = true;' },
];
const report = analyzeReckoningChain(projects);
assert.equal(report.hasReckoningProjects, true);
assert.equal(report.deadEnds.length, 0);
assert.equal(report.reckoningProjects.length, 3);
assert.equal(report.reckoningProjects[2].terminal, true);
});