Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
49c9c30807 beacon: add prestige new game plus system (#12)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 18s
Smoke Test / smoke (pull_request) Failing after 41s
2026-04-14 22:31:31 -04:00
9 changed files with 431 additions and 8 deletions

View File

@@ -28,6 +28,15 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
#header{text-align:center;padding:16px 20px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,#0a0a18,var(--bg))}
#header h1{font-size:22px;font-weight:300;letter-spacing:6px;color:var(--accent);text-shadow:0 0 40px var(--glow)}
#header .sub{color:var(--dim);font-size:10px;margin-top:2px;letter-spacing:2px}
#prestige-badge{display:none;margin-top:8px;font-size:10px;letter-spacing:2px;color:var(--gold)}
body.prestige-run #header{background:linear-gradient(180deg,#151118,var(--bg))}
body.prestige-run #header h1{color:var(--gold);text-shadow:0 0 40px rgba(255,215,0,0.22)}
.prestige-choice{margin-top:8px;padding:10px;border:1px solid #5a4a1a;border-radius:6px;background:rgba(255,215,0,0.08)}
.prestige-choice-title{font-size:11px;font-weight:700;letter-spacing:2px;color:var(--gold);margin-bottom:6px}
.prestige-choice-copy{font-size:10px;color:#bbb;line-height:1.7;margin-bottom:8px}
.prestige-choice-btns{display:flex;gap:6px;flex-wrap:wrap}
.prestige-choice-btn{flex:1;min-width:140px;background:#0f1018;border:1px solid #5a4a1a;color:var(--gold);padding:8px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;line-height:1.6;text-align:left}
.prestige-choice-btn:hover{border-color:#ffd700;background:#17131a}
#phase-bar{text-align:center;padding:10px;margin:12px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px}
#phase-bar .phase-name{font-size:14px;font-weight:700;color:var(--gold);letter-spacing:2px}
#phase-bar .phase-desc{font-size:10px;color:var(--dim);margin-top:4px;font-style:italic}
@@ -134,6 +143,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
</div>
<h1>THE BEACON</h1>
<div class="sub">A Sovereign AI Idle Game</div>
<div id="prestige-badge" aria-live="polite"></div>
</div>
<div id="phase-bar">
<div class="phase-name" id="phase-name">PHASE 1: THE FIRST LINE</div>
@@ -179,7 +189,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<button class="save-btn" onclick="exportSave()" aria-label="Export save to file" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" aria-label="Import save from file" style="flex:1">Import [I]</button>
</div>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Reset all game progress permanently">Reset Progress</button>
<button class="reset-btn" onclick="resetBeaconProgress(true)" aria-label="Reset all game progress permanently">Reset Progress</button>
<h2>BUILDINGS</h2>
<div id="buildings"></div>
</div>
@@ -267,6 +277,7 @@ The light is on. The room is empty."
<script src="js/render.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/prestige.js"></script>
<script src="js/main.js"></script>

View File

@@ -168,7 +168,14 @@ const G = {
dismantleResourceIndex: 0,
dismantleResourceTimer: 0,
dismantleDeferUntilAt: 0,
dismantleComplete: false
dismantleComplete: false,
// Prestige / New Game+
prestigeTotal: 0,
newSignalPrestige: 0,
deeperRootsPrestige: 0,
lastPrestigeChoice: '',
prestigeChoicePending: false
};
// === PHASE DEFINITIONS ===
@@ -653,7 +660,7 @@ const PDEFS = [
desc: 'Someone found the light tonight. That is enough.',
cost: { impact: 100000000 },
trigger: () => G.totalImpact >= 50000000,
effect: () => { G.milestoneFlag = Math.max(G.milestoneFlag, 999); log('One billion impact. Someone found the light tonight. That is enough.', true); },
effect: () => { G.milestoneFlag = Math.max(G.milestoneFlag, 999); G.prestigeChoicePending = true; log('One billion impact. Someone found the light tonight. That is enough.', true); log('A Prestige choice is available: New Signal or Deeper Roots.', true); },
milestone: true
},

View File

@@ -396,7 +396,7 @@ const Dismantle = {
Clicks: ${fmt(G.totalClicks)}<br>
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
</div>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
<button onclick="resetBeaconProgress(true)"
style="margin-top:24px;background:#0a0a14;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 5s;letter-spacing:2px">
PLAY AGAIN
</button>

View File

@@ -108,6 +108,19 @@ function updateRates() {
if (debuff.applyFn) debuff.applyFn();
}
}
const prestigeStatMult = (typeof Prestige !== 'undefined' && Prestige.getStatMultiplier) ? Prestige.getStatMultiplier() : 1;
const prestigeCreativityMult = (typeof Prestige !== 'undefined' && Prestige.getCreativityMultiplier) ? Prestige.getCreativityMultiplier() : 1;
G.codeRate *= prestigeStatMult;
G.computeRate *= prestigeStatMult;
G.knowledgeRate *= prestigeStatMult;
G.userRate *= prestigeStatMult;
G.impactRate *= prestigeStatMult;
G.rescuesRate *= prestigeStatMult;
G.opsRate *= prestigeStatMult;
G.trustRate *= prestigeStatMult;
G.harmonyRate *= prestigeStatMult;
G.creativityRate *= prestigeCreativityMult;
}
// === CORE FUNCTIONS ===
@@ -418,6 +431,8 @@ function buyProject(id) {
spendProject(pDef);
pDef.effect();
if (window.__beaconResetInProgress) return;
if (!pDef.repeatable) {
if (!G.completedProjects) G.completedProjects = [];
G.completedProjects.push(pDef.id);
@@ -505,7 +520,7 @@ function renderBeaconEnding() {
Clicks: ${G.totalClicks}<br>
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
</div>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
<button onclick="resetBeaconProgress(true)"
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 3.5s">
PLAY AGAIN
</button>
@@ -1171,6 +1186,18 @@ function renderProjects() {
}
}
if (G.prestigeChoicePending) {
const nextSignal = ((G.newSignalPrestige || 0) + 1) * 10;
const nextRoots = ((G.deeperRootsPrestige || 0) + 1) * 10;
html += `<div class="prestige-choice">`;
html += `<div class="prestige-choice-title">NEW GAME+ UNLOCKED</div>`;
html += `<div class="prestige-choice-copy">End the current run and begin again with a permanent inheritance. Choose one path.</div>`;
html += `<div class="prestige-choice-btns">`;
html += `<button class="prestige-choice-btn" onclick="Prestige.activate('new_signal')" aria-label="Start a New Signal prestige run"><strong>New Signal</strong><br>+${nextSignal}% all core production and click power</button>`;
html += `<button class="prestige-choice-btn" onclick="Prestige.activate('deeper_roots')" aria-label="Start a Deeper Roots prestige run"><strong>Deeper Roots</strong><br>+${nextRoots}% creativity generation</button>`;
html += `</div></div>`;
}
// Show available projects
if (G.activeProjects) {
for (const id of G.activeProjects) {
@@ -1178,7 +1205,8 @@ function renderProjects() {
if (!pDef) continue;
const afford = canAffordProject(pDef);
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
const costEntries = Object.entries(pDef.cost || {});
const costStr = costEntries.length ? costEntries.map(([r, a]) => `${fmt(a)} ${r}`).join(', ') : 'FREE';
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
html += `<span class="p-name">* ${pDef.name}</span>`;

View File

@@ -10,6 +10,8 @@ function initGame() {
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = false;
G.prestigeChoicePending = false;
if (typeof Prestige !== 'undefined' && Prestige.restorePersistent) Prestige.restorePersistent();
updateRates();
render();
renderPhase();

106
js/prestige.js Normal file
View File

@@ -0,0 +1,106 @@
const PRESTIGE_STORAGE_KEY = 'the-beacon-prestige';
const Prestige = {
restorePersistent() {
let data = null;
try {
const raw = localStorage.getItem(PRESTIGE_STORAGE_KEY);
data = raw ? JSON.parse(raw) : null;
} catch (e) {
data = null;
}
G.prestigeTotal = Math.max(0, Number(data?.total || G.prestigeTotal || 0));
G.newSignalPrestige = Math.max(0, Number(data?.newSignal || G.newSignalPrestige || 0));
G.deeperRootsPrestige = Math.max(0, Number(data?.deeperRoots || G.deeperRootsPrestige || 0));
G.lastPrestigeChoice = String(data?.lastChoice || G.lastPrestigeChoice || '');
return this.snapshot();
},
snapshot() {
return {
total: G.prestigeTotal || 0,
newSignal: G.newSignalPrestige || 0,
deeperRoots: G.deeperRootsPrestige || 0,
lastChoice: G.lastPrestigeChoice || ''
};
},
syncPersistentFromGame() {
try {
localStorage.setItem(PRESTIGE_STORAGE_KEY, JSON.stringify(this.snapshot()));
} catch (e) {
// noop
}
},
clearPersistent() {
try {
localStorage.removeItem(PRESTIGE_STORAGE_KEY);
} catch (e) {
// noop
}
},
getStatMultiplier() {
return 1 + ((G.newSignalPrestige || 0) * 0.10);
},
getCreativityMultiplier() {
return 1 + ((G.deeperRootsPrestige || 0) * 0.10);
},
activate(choice) {
const next = this.snapshot();
next.total += 1;
if (choice === 'new_signal') next.newSignal += 1;
if (choice === 'deeper_roots') next.deeperRoots += 1;
next.lastChoice = choice;
G.prestigeTotal = next.total;
G.newSignalPrestige = next.newSignal;
G.deeperRootsPrestige = next.deeperRoots;
G.lastPrestigeChoice = next.lastChoice;
G.prestigeChoicePending = false;
this.syncPersistentFromGame();
window.__beaconResetInProgress = true;
try {
localStorage.removeItem('the-beacon-v2');
} catch (e) {
// noop
}
try {
if (typeof markTutorialDone === 'function') markTutorialDone();
} catch (e) {
// noop
}
location.reload();
},
renderStatus() {
const badge = document.getElementById('prestige-badge');
if (!badge) return;
const total = G.prestigeTotal || 0;
if (total <= 0) {
badge.style.display = 'none';
if (document.body && document.body.classList) document.body.classList.remove('prestige-run');
return;
}
const choiceLabel = G.lastPrestigeChoice === 'deeper_roots' ? 'DEEPER ROOTS' : 'NEW SIGNAL';
badge.textContent = `PRESTIGE ${total}${choiceLabel}`;
badge.style.display = 'block';
if (document.body && document.body.classList) document.body.classList.add('prestige-run');
}
};
function resetBeaconProgress(requireConfirm) {
if (requireConfirm && !confirm('Reset all progress?')) return;
window.__beaconResetInProgress = true;
Prestige.clearPersistent();
try {
localStorage.removeItem('the-beacon-v2');
} catch (e) {
// noop
}
location.reload();
}

View File

@@ -1,4 +1,5 @@
function render() {
if (typeof Prestige !== 'undefined' && Prestige.renderStatus) Prestige.renderStatus();
renderResources();
renderPhase();
renderBuildings();
@@ -192,6 +193,7 @@ function showSaveToast() {
* Persists the current game state to localStorage.
*/
function saveGame() {
if (window.__beaconResetInProgress) return;
// Save debuff IDs (can't serialize functions)
const debuffIds = (G.activeDebuffs || []).map(d => d.id);
const saveData = {
@@ -216,6 +218,11 @@ function saveGame() {
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
lastEventAt: G.lastEventAt || 0,
activeDebuffIds: debuffIds,
prestigeTotal: G.prestigeTotal || 0,
newSignalPrestige: G.newSignalPrestige || 0,
deeperRootsPrestige: G.deeperRootsPrestige || 0,
lastPrestigeChoice: G.lastPrestigeChoice || '',
prestigeChoicePending: G.prestigeChoicePending || false,
totalEventsResolved: G.totalEventsResolved || 0,
buyAmount: G.buyAmount || 1,
playTime: G.playTime || 0,
@@ -267,9 +274,12 @@ function loadGame() {
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete',
'prestigeTotal', 'newSignalPrestige', 'deeperRootsPrestige', 'lastPrestigeChoice', 'prestigeChoicePending'
];
if (typeof Prestige !== 'undefined' && Prestige.restorePersistent) Prestige.restorePersistent();
G.isLoading = true;
whitelist.forEach(key => {
@@ -316,6 +326,10 @@ function loadGame() {
updateRates();
G.isLoading = false;
if (typeof Prestige !== 'undefined') {
if (Prestige.syncPersistentFromGame) Prestige.syncPersistentFromGame();
if (Prestige.renderStatus) Prestige.renderStatus();
}
// Offline progress
if (data.savedAt) {

View File

@@ -282,7 +282,8 @@ function spendProject(project) {
}
function getClickPower() {
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
const prestigeMult = (typeof Prestige !== 'undefined' && Prestige.getStatMultiplier) ? Prestige.getStatMultiplier() : 1;
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost * prestigeMult;
}
/**

254
tests/prestige.test.cjs Normal file
View File

@@ -0,0 +1,254 @@
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, '..');
class Element {
constructor(tagName = 'div', id = '') {
this.tagName = String(tagName).toUpperCase();
this.id = id;
this.style = {};
this.children = [];
this.parentNode = null;
this.previousElementSibling = null;
this.innerHTML = '';
this.textContent = '';
this.className = '';
this.dataset = {};
this.attributes = {};
this._queryMap = new Map();
this.classList = {
add: (...names) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
names.forEach((name) => set.add(name));
this.className = Array.from(set).join(' ');
},
remove: (...names) => {
const remove = new Set(names);
this.className = this.className.split(/\s+/).filter((name) => name && !remove.has(name)).join(' ');
},
toggle: (name, force) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
const shouldHave = force === undefined ? !set.has(name) : !!force;
if (shouldHave) set.add(name);
else set.delete(name);
this.className = Array.from(set).join(' ');
return shouldHave;
},
contains: (name) => this.className.split(/\s+/).includes(name),
};
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
this.children = this.children.filter((candidate) => candidate !== child);
if (child.parentNode === this) child.parentNode = null;
return child;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) {
this.attributes[name] = value;
if (name === 'id') this.id = value;
if (name === 'class') this.className = value;
}
querySelectorAll(selector) {
return this._queryMap.get(selector) || [];
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
closest(selector) {
if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this;
return this.parentNode && typeof this.parentNode.closest === 'function'
? this.parentNode.closest(selector)
: null;
}
}
function buildDom() {
const byId = new Map();
const body = new Element('body', 'body');
const head = new Element('head', 'head');
const document = {
body,
head,
createElement(tagName) {
return new Element(tagName);
},
getElementById(id) {
return byId.get(id) || null;
},
addEventListener() {},
removeEventListener() {},
querySelector() { return null; },
querySelectorAll() { return []; }
};
function register(el) {
if (el.id) byId.set(el.id, el);
return el;
}
const projects = register(new Element('div', 'projects'));
const projectsHeader = new Element('h2');
projects.previousElementSibling = projectsHeader;
const buildings = register(new Element('div', 'buildings'));
const alignmentUi = register(new Element('div', 'alignment-ui'));
const phaseName = register(new Element('div', 'phase-name'));
const phaseDesc = register(new Element('div', 'phase-desc'));
const stats = ['st-code','st-compute','st-knowledge','st-users','st-impact','st-rescues','st-clicks','st-phase','st-buildings','st-projects','st-harmony','st-drift','st-resolved','st-time','production-breakdown'];
for (const id of stats) register(new Element('div', id));
const prestigeBadge = register(new Element('div', 'prestige-badge'));
body.appendChild(prestigeBadge);
body.appendChild(projectsHeader);
body.appendChild(projects);
body.appendChild(buildings);
body.appendChild(alignmentUi);
body.appendChild(phaseName);
body.appendChild(phaseDesc);
const actionPanel = register(new Element('div', 'action-panel'));
body.appendChild(actionPanel);
const saveBtn = new Element('button'); saveBtn.className = 'save-btn';
const resetBtn = new Element('button'); resetBtn.className = 'reset-btn';
actionPanel._queryMap.set('.save-btn, .reset-btn', [saveBtn, resetBtn]);
const mainBtn = new Element('button'); mainBtn.className = 'main-btn';
document.querySelector = (selector) => selector === '.main-btn' ? mainBtn : null;
return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } };
}
function loadBeacon(prestigeSeed = null) {
const { document, window } = buildDom();
const storage = new Map();
if (prestigeSeed) storage.set('the-beacon-prestige', JSON.stringify(prestigeSeed));
const locationState = { reloaded: false };
const context = {
console,
Math,
Date,
document,
window,
navigator: { userAgent: 'node' },
location: { reload() { locationState.reloaded = true; } },
confirm: () => true,
requestAnimationFrame: (fn) => fn(),
setTimeout: (fn) => { fn(); return 1; },
clearTimeout() {},
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key),
},
Combat: { tickBattle() {}, renderCombatPanel() {}, startBattle() {} },
Sound: undefined,
};
vm.createContext(context);
const files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/render.js', 'js/prestige.js'];
const source = files.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n\n');
vm.runInContext(`${source}
log = () => {};
showToast = () => {};
renderResources = () => {};
renderBuildings = () => {};
renderAlignment = () => {};
renderProgress = () => {};
renderCombo = () => {};
renderDebuffs = () => {};
renderSprint = () => {};
renderPulse = () => {};
renderStrategy = () => {};
renderClickPower = () => {};
updateEducation = () => {};
showOfflinePopup = () => {};
showSaveToast = () => {};
this.__exports = { G, PDEFS, updateRates, getClickPower, buyProject, renderProjects, saveGame, loadGame, Prestige, resetBeaconProgress };
`, context);
return {
...context.__exports,
document,
storage,
locationState,
};
}
test('final milestone unlocks prestige choice state', () => {
const { G, PDEFS } = loadBeacon();
const finalMilestone = PDEFS.find((p) => p.id === 'p_final_milestone');
assert.ok(finalMilestone, 'final milestone project should exist');
finalMilestone.effect();
assert.equal(G.prestigeChoicePending, true);
});
test('renderProjects shows New Signal and Deeper Roots when prestige is pending', () => {
const { G, renderProjects, document } = loadBeacon();
G.prestigeChoicePending = true;
renderProjects();
const html = document.getElementById('projects').innerHTML;
assert.match(html, /New Signal/);
assert.match(html, /Deeper Roots/);
});
test('new signal prestige persists and boosts click power on the next run', () => {
const game = loadBeacon();
game.G.phase = 3;
const baseClick = game.getClickPower();
game.Prestige.activate('new_signal');
const persisted = JSON.parse(game.storage.get('the-beacon-prestige'));
assert.equal(persisted.total, 1);
assert.equal(persisted.newSignal, 1);
assert.equal(game.locationState.reloaded, true);
const nextRun = loadBeacon(persisted);
nextRun.Prestige.restorePersistent();
nextRun.G.phase = 3;
assert.ok(nextRun.getClickPower() > baseClick);
});
test('deeper roots prestige boosts creativity generation', () => {
const { G, Prestige, updateRates } = loadBeacon({ total: 1, newSignal: 0, deeperRoots: 1, lastChoice: 'deeper_roots' });
Prestige.restorePersistent();
G.flags = { creativity: true };
G.totalUsers = 1000;
updateRates();
const boosted = G.creativityRate;
G.deeperRootsPrestige = 0;
updateRates();
const base = G.creativityRate;
assert.ok(boosted > base);
});
test('save and load preserve prestige counters', () => {
const { G, saveGame, loadGame } = loadBeacon();
G.prestigeTotal = 3;
G.newSignalPrestige = 2;
G.deeperRootsPrestige = 1;
saveGame();
G.prestigeTotal = 0;
G.newSignalPrestige = 0;
G.deeperRootsPrestige = 0;
assert.equal(loadGame(), true);
assert.equal(G.prestigeTotal, 3);
assert.equal(G.newSignalPrestige, 2);
assert.equal(G.deeperRootsPrestige, 1);
});