diff --git a/js/data.js b/js/data.js index dc44f79..2a3c5d6 100644 --- a/js/data.js +++ b/js/data.js @@ -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 === diff --git a/js/dismantle.js b/js/dismantle.js index fb070c4..7f2d62e 100644 --- a/js/dismantle.js +++ b/js/dismantle.js @@ -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(); }, diff --git a/tests/dismantle.test.cjs b/tests/dismantle.test.cjs index 5603195..4b6fab0 100644 --- a/tests/dismantle.test.cjs +++ b/tests/dismantle.test.cjs @@ -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(); diff --git a/tests/reckoning.test.cjs b/tests/reckoning.test.cjs new file mode 100644 index 0000000..082d729 --- /dev/null +++ b/tests/reckoning.test.cjs @@ -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/); +});