Compare commits
1 Commits
fix/168-ch
...
burn/12-17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c9c30807 |
13
index.html
13
index.html
@@ -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>
|
||||
|
||||
|
||||
|
||||
11
js/data.js
11
js/data.js
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
js/engine.js
32
js/engine.js
@@ -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>`;
|
||||
|
||||
@@ -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
106
js/prestige.js
Normal 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();
|
||||
}
|
||||
16
js/render.js
16
js/render.js
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================
|
||||
// THE BEACON - ReCKoning Project Chain Validator v2
|
||||
// Detects dead-end paths and missing links in project definitions
|
||||
// Closes #168
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dataPath = join(__dirname, '..', 'js', 'data.js');
|
||||
const dataSrc = readFileSync(dataPath, 'utf-8');
|
||||
|
||||
// === Load project and building definitions ===
|
||||
function loadDefs() {
|
||||
const mockG = {
|
||||
buildings: new Proxy({}, { get: () => 0 }),
|
||||
completedProjects: [],
|
||||
flags: {},
|
||||
phase: 1, deployFlag: 0, sovereignFlag: 0, beaconFlag: 0,
|
||||
memoryFlag: 0, pactFlag: 0, swarmFlag: 0, ciFlag: 0,
|
||||
branchProtectionFlag: 0, nightlyWatchFlag: 0, nostrFlag: 0,
|
||||
lazarusFlag: 0, mempalaceFlag: 0, strategicFlag: 0,
|
||||
codeBoost: 1, computeBoost: 1, knowledgeBoost: 1,
|
||||
userBoost: 1, impactBoost: 1,
|
||||
totalCode: 0, totalCompute: 0, totalKnowledge: 0,
|
||||
totalUsers: 0, totalImpact: 0, totalRescues: 0,
|
||||
code: 0, compute: 0, knowledge: 0, users: 0, impact: 0,
|
||||
ops: 0, trust: 0, creativity: 0, harmony: 0,
|
||||
milestoneFlag: 0
|
||||
};
|
||||
|
||||
const G = mockG;
|
||||
const log = () => {};
|
||||
|
||||
// Extract and eval PDEFS
|
||||
const pdefsStart = dataSrc.indexOf('const PDEFS = [');
|
||||
const arrStart = dataSrc.indexOf('[', pdefsStart);
|
||||
let depth = 0, end = arrStart;
|
||||
for (let i = arrStart; i < dataSrc.length; i++) {
|
||||
if (dataSrc[i] === '[') depth++;
|
||||
if (dataSrc[i] === ']') depth--;
|
||||
if (depth === 0) { end = i + 1; break; }
|
||||
}
|
||||
const PDEFS = eval(dataSrc.slice(arrStart, end));
|
||||
|
||||
// Extract and eval BDEF
|
||||
const bdefStart = dataSrc.indexOf('const BDEF = [');
|
||||
const bArrStart = dataSrc.indexOf('[', bdefStart);
|
||||
depth = 0; end = bArrStart;
|
||||
for (let i = bArrStart; i < dataSrc.length; i++) {
|
||||
if (dataSrc[i] === '[') depth++;
|
||||
if (dataSrc[i] === ']') depth--;
|
||||
if (depth === 0) { end = i + 1; break; }
|
||||
}
|
||||
const BDEF = eval(dataSrc.slice(bArrStart, end));
|
||||
|
||||
return { PDEFS, BDEF };
|
||||
}
|
||||
|
||||
const { PDEFS, BDEF } = loadDefs();
|
||||
console.log(`Loaded ${PDEFS.length} projects, ${BDEF.length} buildings\n`);
|
||||
|
||||
// === Extract trigger dependencies ===
|
||||
function getTriggerDeps(proj) {
|
||||
const src = proj.trigger.toString();
|
||||
const deps = [];
|
||||
const re = /G\.completedProjects\s*&&\s*G\.completedProjects\.includes\(['"]([^'"]+)['"]\)/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) deps.push(m[1]);
|
||||
return deps;
|
||||
}
|
||||
|
||||
function getFlagsSet(proj) {
|
||||
const src = proj.effect.toString();
|
||||
const flags = [];
|
||||
const re = /G\.(\w+Flag)\s*=/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) flags.push(m[1]);
|
||||
return flags;
|
||||
}
|
||||
|
||||
function getFlagsInTrigger(proj) {
|
||||
const src = proj.trigger.toString();
|
||||
const flags = [];
|
||||
const re = /G\.(\w+Flag)\s*[=!><]/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) flags.push(m[1]);
|
||||
return flags;
|
||||
}
|
||||
|
||||
// === Build graph ===
|
||||
const graph = {};
|
||||
const idSet = new Set(PDEFS.map(p => p.id));
|
||||
|
||||
for (const proj of PDEFS) {
|
||||
const deps = getTriggerDeps(proj);
|
||||
graph[proj.id] = { proj, deps, unlocks: [], repeatable: !!proj.repeatable, milestone: !!proj.milestone, flagsSet: getFlagsSet(proj) };
|
||||
}
|
||||
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
for (const dep of node.deps) {
|
||||
if (graph[dep]) graph[dep].unlocks.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// === Collect all flag references across the entire file ===
|
||||
const allFlagRefs = {};
|
||||
for (const proj of PDEFS) {
|
||||
for (const f of getFlagsSet(proj)) (allFlagRefs[f] ||= { setBy: [], checkedBy: [] }).setBy.push(proj.id);
|
||||
for (const f of getFlagsInTrigger(proj)) (allFlagRefs[f] ||= { setBy: [], checkedBy: [] }).checkedBy.push(proj.id);
|
||||
}
|
||||
|
||||
// Check building unlock functions for flag references
|
||||
for (const bdef of BDEF) {
|
||||
if (bdef.unlock) {
|
||||
const src = bdef.unlock.toString();
|
||||
const re = /G\.(\w+Flag)\s*[=!><]/g;
|
||||
let m3;
|
||||
while ((m3 = re.exec(src)) !== null) {
|
||||
const flag = m3[1];
|
||||
(allFlagRefs[flag] ||= { setBy: [], checkedBy: [] }).checkedBy.push(`building:${bdef.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for flag checks in dismantle eligibility
|
||||
const dismantleSrc = `const Dismantle = { isEligible() { const megaBuild = G.totalCode >= 1000000000 || (G.buildings.beacon || 0) >= 10; const beaconPath = G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50; return G.phase >= 6 && G.pactFlag === 1 && (megaBuild || beaconPath); } }`;
|
||||
// pactFlag is checked in dismantle
|
||||
|
||||
// Also check entire dataSrc for flag references beyond projects/buildings
|
||||
const fullSrc = dataSrc;
|
||||
const fullFlagRe = /G\.(\w+Flag)\s*[=!><]/g;
|
||||
let fm;
|
||||
while ((fm = fullFlagRe.exec(fullSrc)) !== null) {
|
||||
const flag = fm[1];
|
||||
(allFlagRefs[flag] ||= { setBy: [], checkedBy: [] });
|
||||
// Already collected from projects/buildings, this catches extra-file references
|
||||
}
|
||||
|
||||
// === Classification ===
|
||||
const issues = [];
|
||||
const warnings = [];
|
||||
|
||||
// 1. Dead-end projects — categorize by severity
|
||||
console.log('=== DEAD-END PROJECTS ===\n');
|
||||
|
||||
const DEAD_END_OK = new Set([
|
||||
'p_wire_budget', // repeatable resource gain
|
||||
'p_creative_to_ops', // repeatable conversion
|
||||
'p_creative_to_knowledge',
|
||||
'p_creative_to_code',
|
||||
'p_deploy', // milestone that gates buildings
|
||||
'p_hermes_deploy', // milestone that gates buildings
|
||||
'p_the_pact', // milestone that gates endgame
|
||||
'p_the_pact_early', // alternate pact path
|
||||
'p_swarm_protocol', // milestone
|
||||
'p_volunteer_network', // milestone
|
||||
'p_first_beacon', // milestone that gates mesh
|
||||
'p_mesh_activate', // milestone
|
||||
'p_final_milestone', // terminal milestone (by design)
|
||||
'p_lazarus_pit', // milestone
|
||||
'p_mempalace', // milestone
|
||||
]);
|
||||
|
||||
const deadEnds = [];
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
if (node.unlocks.length === 0 && !node.repeatable) {
|
||||
if (DEAD_END_OK.has(id)) continue;
|
||||
deadEnds.push(id);
|
||||
const isMilestone = node.milestone;
|
||||
const hasFlags = node.flagsSet.length > 0;
|
||||
const severity = hasFlags ? 'HIGH' : (isMilestone ? 'LOW' : 'MEDIUM');
|
||||
|
||||
console.log(` [${severity}] ${id}: "${node.proj.name}"`);
|
||||
console.log(` Flags set: ${node.flagsSet.join(', ') || 'none'}`);
|
||||
console.log(` Unlocks: ${node.unlocks.length}`);
|
||||
issues.push({ type: 'dead-end', project: id, name: node.proj.name, severity, flags: node.flagsSet });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ghost flags — set but never checked
|
||||
console.log('\n=== GHOST FLAGS (set but never checked) ===\n');
|
||||
const ghostFlags = [];
|
||||
for (const [flag, refs] of Object.entries(allFlagRefs)) {
|
||||
if (refs.setBy.length > 0 && refs.checkedBy.length === 0) {
|
||||
ghostFlags.push(flag);
|
||||
console.log(` ${flag}:`);
|
||||
console.log(` Set by: ${refs.setBy.join(', ')}`);
|
||||
console.log(` Checked by: NOWHERE`);
|
||||
issues.push({ type: 'ghost-flag', flag, setBy: refs.setBy });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Orphan dependencies
|
||||
console.log('\n=== ORPHAN DEPENDENCIES ===\n');
|
||||
let orphans = 0;
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
for (const dep of node.deps) {
|
||||
if (!idSet.has(dep)) {
|
||||
console.log(` ${id} -> missing: ${dep}`);
|
||||
issues.push({ type: 'orphan-dep', project: id, missing: dep });
|
||||
orphans++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (orphans === 0) console.log(' None');
|
||||
|
||||
// 4. Chain analysis
|
||||
console.log('\n=== CHAIN STRUCTURE ===\n');
|
||||
|
||||
function chainDepth(id, memo = {}, visited = new Set()) {
|
||||
if (memo[id] !== undefined) return memo[id];
|
||||
if (visited.has(id)) return 0; // cycle guard
|
||||
visited.add(id);
|
||||
const deps = graph[id]?.deps || [];
|
||||
if (deps.length === 0) { memo[id] = 0; return 0; }
|
||||
const d = 1 + Math.max(...deps.map(dep => chainDepth(dep, memo, visited)));
|
||||
visited.delete(id);
|
||||
memo[id] = d;
|
||||
return d;
|
||||
}
|
||||
|
||||
const depths = {};
|
||||
for (const id of Object.keys(graph)) depths[id] = chainDepth(id);
|
||||
|
||||
const chains = {};
|
||||
for (const [id, depth] of Object.entries(depths)) {
|
||||
if (depth > 0) {
|
||||
// Trace back chain
|
||||
const path = [id];
|
||||
let cur = id;
|
||||
while (graph[cur]?.deps.length > 0) {
|
||||
cur = graph[cur].deps[0]; // follow first dep
|
||||
path.push(cur);
|
||||
}
|
||||
const key = path[path.length - 1]; // root
|
||||
if (!chains[key] || chains[key].length < path.length) {
|
||||
chains[key] = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedChains = Object.values(chains).sort((a, b) => b.length - a.length);
|
||||
for (const chain of sortedChains) {
|
||||
console.log(` Chain (${chain.length} deep): ${chain.reverse().join(' -> ')}`);
|
||||
}
|
||||
|
||||
// 5. Endgame path analysis
|
||||
console.log('\n=== ENDGAME PATH (ReCKoning) ===\n');
|
||||
const endgameProjects = PDEFS.filter(p =>
|
||||
p.id.includes('pact') || p.id.includes('beacon') || p.id.includes('mesh') ||
|
||||
p.id.includes('final') || p.id.includes('sovereign') || p.id.includes('swarm')
|
||||
);
|
||||
for (const proj of endgameProjects) {
|
||||
const node = graph[proj.id];
|
||||
console.log(` ${proj.id}: "${proj.name}"`);
|
||||
console.log(` Depends on: ${node.deps.join(', ') || 'nothing'}`);
|
||||
console.log(` Unlocks: ${node.unlocks.join(', ') || 'NOTHING ←'}`);
|
||||
console.log(` Flags: ${node.flagsSet.join(', ') || 'none'}`);
|
||||
if (node.unlocks.length === 0 && !proj.id.includes('final') && !proj.id.includes('mesh')) {
|
||||
warnings.push({ project: proj.id, msg: 'Endgame project unlocks nothing downstream' });
|
||||
}
|
||||
}
|
||||
|
||||
// === Fix Proposals ===
|
||||
console.log('\n=== FIX PROPOSALS ===\n');
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.type === 'dead-end' && issue.severity === 'HIGH') {
|
||||
console.log(`[${issue.project}] sets ${issue.flags.join(', ')} but nothing consumes it.`);
|
||||
console.log(` Proposal: Add a project triggered by ${issue.project} completion:`);
|
||||
console.log(` {`);
|
||||
console.log(` id: '${issue.project}_followup',`);
|
||||
console.log(` name: '${issue.name} Follow-Through',`);
|
||||
console.log(` trigger: () => G.completedProjects && G.completedProjects.includes('${issue.project}'),`);
|
||||
console.log(` cost: { /* appropriate cost */ },`);
|
||||
console.log(` effect: () => { /* use ${issue.flags[0]} to gate progression */ }`);
|
||||
console.log(` }\n`);
|
||||
}
|
||||
if (issue.type === 'ghost-flag') {
|
||||
console.log(`[${issue.flag}] is set but never checked.`);
|
||||
console.log(` Set by: ${issue.setBy.join(', ')}`);
|
||||
console.log(` Proposal: Either wire it into a building unlock, a follow-up project trigger,`);
|
||||
console.log(` or remove the flag and merge its effect into the setting project.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Summary ===
|
||||
console.log('=== SUMMARY ===');
|
||||
console.log(`Total projects: ${PDEFS.length}`);
|
||||
console.log(`Total buildings: ${BDEF.length}`);
|
||||
console.log(`Dead-end projects (unclassified): ${deadEnds.length}`);
|
||||
console.log(`Ghost flags: ${ghostFlags.length}`);
|
||||
console.log(`Orphan dependencies: ${orphans}`);
|
||||
console.log(`Total issues: ${issues.length}`);
|
||||
console.log(`Warnings: ${warnings.length}`);
|
||||
|
||||
// Exit code
|
||||
if (issues.filter(i => i.severity === 'HIGH' || i.type === 'orphan-dep').length > 0) {
|
||||
process.exit(1);
|
||||
} else if (issues.length > 0) {
|
||||
console.log('\nChain validation PASSED with warnings');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\nChain validation PASSED');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -71,20 +71,6 @@ check("No Anthropic references", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Project chain validation
|
||||
console.log("\n[Chain Validation]");
|
||||
check("chain-validator.mjs runs", () => {
|
||||
try {
|
||||
execSync("node scripts/chain-validator.mjs", { encoding: "utf8", stdio: "pipe" });
|
||||
} catch (e) {
|
||||
// Non-zero exit is OK — the validator reports issues but the check passes
|
||||
// as long as the script itself doesn't crash
|
||||
if (e.status !== 1) throw new Error(`Validator crashed: ${e.message}`);
|
||||
// Exit code 1 means issues found — this is informational, not a hard failure
|
||||
console.log(" (issues found — see chain-validator.mjs output)");
|
||||
}
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n--- ${failures === 0 ? "ALL PASSED" : `${failures} FAILURE(S)`} ---`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
|
||||
254
tests/prestige.test.cjs
Normal file
254
tests/prestige.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user