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 = `
-
Someone found the light tonight.
-That is enough.
+${endingCopy.line1}
+${endingCopy.line2}