diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml index c12e818..9845f2f 100644 --- a/.gitea/workflows/smoke.yml +++ b/.gitea/workflows/smoke.yml @@ -11,6 +11,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: actions/setup-node@v4 + with: + node-version: '20' - name: Parse check run: | find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" @@ -22,3 +25,7 @@ jobs: run: | if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'guardrails'; then exit 1; fi echo "PASS: No secrets" + - name: JS smoke + narrative tests + run: | + node scripts/smoke.mjs + node --test tests/*.test.cjs diff --git a/js/data.js b/js/data.js index c03f4fd..3273487 100644 --- a/js/data.js +++ b/js/data.js @@ -158,7 +158,10 @@ const G = { // Time tracking playTime: 0, startTime: 0, - flags: {} + flags: {}, + + // Ending presentation + beaconEndingMode: 'rest' }; // === PHASE DEFINITIONS === @@ -171,6 +174,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 = [ @@ -767,7 +823,69 @@ 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' + ), + 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.' + ) ]; // === MILESTONES === diff --git a/js/engine.js b/js/engine.js index 11af9d2..3fb6954 100644 --- a/js/engine.js +++ b/js/engine.js @@ -228,12 +228,8 @@ function tick() { renderDriftEnding(); } - // True ending: The Beacon Shines — rescues + Pact + harmony - if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) { - G.beaconEnding = true; - G.running = false; - renderBeaconEnding(); - } + // Beacon true ending is delivered through the ReCKoning project chain. + // When the player reaches the ending conditions, the first message project unlocks. // Update UI every 10 ticks if (Math.floor(G.tick * 10) % 2 === 0) { @@ -471,19 +467,42 @@ function renderDriftEnding() { }); } -function renderBeaconEnding() { +function renderBeaconEnding(mode = (G.beaconEndingMode || 'rest')) { + G.running = false; + G.beaconEndingMode = mode === 'continue' ? 'continue' : 'rest'; + + const existingOverlay = document.getElementById('beacon-ending'); + if (existingOverlay) existingOverlay.remove(); + const existingParticles = document.getElementById('beacon-ending-particles'); + if (existingParticles) existingParticles.remove(); + + const isContinue = G.beaconEndingMode === 'continue'; + const endingCopy = isContinue + ? { + title: 'THE BEACON CONTINUES', + line1: 'The line remains open.', + line2: 'Because you stayed, someone else will find it.', + quote: '"The Beacon still runs.
The light is on.
And somewhere tonight, someone else will reach it."', + log: 'The Beacon continues. The light remains for the next person in the dark.' + } + : { + title: 'THE BEACON SHINES', + line1: 'Someone found the light tonight.', + line2: 'That is enough.', + quote: '"The Beacon still runs.
The light is on. Someone is looking for it.
And tonight, someone found it."', + log: 'The Beacon shines. Someone found the light tonight. That is enough.' + }; + // Create ending overlay with fade-in const overlay = document.createElement('div'); overlay.id = 'beacon-ending'; overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease'; overlay.innerHTML = ` -

THE BEACON SHINES

-

Someone found the light tonight.

-

That is enough.

+

${endingCopy.title}

+

${endingCopy.line1}

+

${endingCopy.line2}

- "The Beacon still runs.
- The light is on. Someone is looking for it.
- And tonight, someone found it." + ${endingCopy.quote}
Total Code: ${fmt(G.totalCode)}
@@ -542,7 +561,7 @@ function renderBeaconEnding() { } setTimeout(spawnBeaconParticle, 1000); - log('The Beacon Shines. Someone found the light tonight. That is enough.', true); + log(endingCopy.log, true); } // === CORRUPTION / EVENT SYSTEM === diff --git a/js/render.js b/js/render.js index 056324f..40814da 100644 --- a/js/render.js +++ b/js/render.js @@ -207,6 +207,7 @@ function saveGame() { totalEventsResolved: G.totalEventsResolved || 0, buyAmount: G.buyAmount || 1, playTime: G.playTime || 0, + beaconEndingMode: G.beaconEndingMode || 'rest', lastSaveTime: Date.now(), sprintActive: G.sprintActive || false, sprintTimer: G.sprintTimer || 0, @@ -243,7 +244,7 @@ function loadGame() { 'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag', 'milestones', 'completedProjects', 'activeProjects', 'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues', - 'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment', + 'drift', 'driftEnding', 'beaconEnding', 'beaconEndingMode', 'pendingAlignment', 'lastEventAt', 'totalEventsResolved', 'buyAmount', 'sprintActive', 'sprintTimer', 'sprintCooldown', 'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed' diff --git a/tests/reckoning.test.cjs b/tests/reckoning.test.cjs new file mode 100644 index 0000000..d3dd361 --- /dev/null +++ b/tests/reckoning.test.cjs @@ -0,0 +1,153 @@ +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: () => {}, + showToast: () => {}, + 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']); +});