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
14 changed files with 431 additions and 890 deletions

View File

@@ -1,27 +0,0 @@
# Dismantle Sequence Implementation Status
The Dismantle Sequence (The Unbuilding) is fully implemented and tested.
## Implementation
- **File**: js/dismantle.js (570 lines)
- **Tests**: tests/dismantle.test.cjs (10 tests, all passing)
- **PR #145**: Fixes bugs from #133
## Stages
1. Hide research projects panel
2. Hide buildings list
3. Hide strategy engine + combat
4. Hide education panel
5. Resources disappear one by one (quantum chips pattern)
6. Hide action buttons (ops boosts, sprint)
7. Hide phase bar
8. Hide system log
9. Final: "That is enough" with golden beacon dot
## Features
- Save/load persistence
- Defer mechanism (NOT YET button)
- Cooldown system
- Final overlay with stats
## Status: COMPLETE

View File

@@ -1,67 +0,0 @@
# Issue #122 Verification
## Status: ✅ ALREADY FIXED
The pending drift alignment UI is properly suppressed during active Unbuilding.
## Problem (from issue)
If `G.pendingAlignment` is still true when the player begins THE UNBUILDING, the normal `renderAlignment()` path can repaint the Drift alignment choice on top of the dismantle sequence.
## Fix
In `js/render.js`, the `renderAlignment()` function now checks for active/completed dismantle before rendering alignment UI:
```javascript
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
// FIX: Suppress alignment UI during active/completed Unbuilding
if (G.dismantleActive || G.dismantleComplete) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
// ... rest of function
}
```
## Regression Test
Test exists in `tests/dismantle.test.cjs`:
```javascript
test('active Unbuilding suppresses pending alignment event UI', () => {
const { G, Dismantle, renderAlignment, document } = loadBeacon({ includeRender: true });
G.pendingAlignment = true;
G.dismantleActive = true;
Dismantle.active = true;
renderAlignment();
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
assert.equal(document.getElementById('alignment-ui').style.display, 'none');
});
```
## Test Results
All 10 tests pass:
```
✔ tick offers the Unbuilding instead of ending the game immediately
✔ renderAlignment does not wipe the Unbuilding prompt after it is offered
✔ active Unbuilding suppresses pending alignment event UI ← THIS TEST
✔ stage five lasts long enough to dissolve every resource card
✔ save/load restores partial stage-five dissolve progress
✔ deferring the Unbuilding clears the prompt and allows it to return later
✔ defer cooldown survives save and reload
✔ save and load preserve dismantle progress
✔ restore re-renders an offered but not-yet-started Unbuilding prompt
✔ defer cooldown persists after save/load when dismantleTriggered is false
```
## Recommendation
Close issue #122 as already fixed.

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

@@ -215,8 +215,6 @@ const Dismantle = {
G.dismantleActive = false;
G.dismantleComplete = true;
G.running = false;
// Clear unrelated active projects when ending completes (#130)
G.activeProjects = [];
// Show Play Again
this.showPlayAgain();
}
@@ -398,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

@@ -1,570 +0,0 @@
// ============================================================
// THE BEACON - Dismantle Sequence (The Unbuilding)
// Inspired by Paperclips REJECT path: panels disappear one by one
// until only the beacon remains. "That is enough."
// ============================================================
const Dismantle = {
// Dismantle stages
// 0 = not started
// 1-8 = active dismantling
// 9 = final ("That is enough")
// 10 = complete
stage: 0,
tickTimer: 0,
active: false,
triggered: false,
deferUntilAt: 0,
// Timing: seconds between each dismantle stage
STAGE_INTERVALS: [0, 3.0, 2.5, 2.5, 2.0, 6.3, 2.0, 2.0, 2.5],
// The quantum chips effect: resource items disappear one by one
// at specific tick marks within a stage (like Paperclips' quantum chips)
resourceSequence: [],
resourceIndex: 0,
resourceTimer: 0,
// Tick marks for resource disappearances (seconds within stage 5)
RESOURCE_TICKS: [1.0, 2.0, 3.0, 4.0, 5.0, 5.5, 5.8, 5.95, 6.05, 6.12],
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);
},
/**
* Check if the Unbuilding should be triggered.
*/
checkTrigger() {
if (this.triggered || G.dismantleTriggered || this.active || G.dismantleActive || G.dismantleComplete) return;
const deferUntilAt = G.dismantleDeferUntilAt || this.deferUntilAt || 0;
if (Date.now() < deferUntilAt) return;
if (!this.isEligible()) return;
this.offerChoice();
},
/**
* Offer the player the choice to begin the Unbuilding.
*/
offerChoice() {
this.triggered = true;
G.dismantleTriggered = true;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleStage = 0;
G.dismantleResourceIndex = 0;
G.dismantleResourceTimer = 0;
G.dismantleDeferUntilAt = 0;
G.beaconEnding = false;
G.running = true;
log('', false);
log('The work is done.', true);
log('Every node is lit. Every person who needed help, found help.', true);
log('', false);
log('The Beacon asks nothing more of you.', true);
showToast('The Unbuilding awaits.', 'milestone', 8000);
this.renderChoice();
},
renderChoice() {
const container = document.getElementById('alignment-ui');
if (!container) return;
container.innerHTML = `
<div style="background:#0a0a18;border:1px solid #ffd700;padding:12px;border-radius:4px;margin-top:8px">
<div style="color:#ffd700;font-weight:bold;margin-bottom:8px;letter-spacing:2px">THE UNBUILDING</div>
<div style="font-size:10px;color:#aaa;margin-bottom:10px;line-height:1.8">
The system runs. The beacons are lit. The mesh holds.<br>
Nothing remains to build.<br><br>
Begin the Unbuilding? Each piece will fall away.<br>
What remains is what mattered.
</div>
<div class="action-btn-group">
<button class="ops-btn" onclick="Dismantle.begin()" style="border-color:#ffd700;color:#ffd700;font-size:11px" aria-label="Begin the Unbuilding sequence">
BEGIN THE UNBUILDING
</button>
<button class="ops-btn" onclick="Dismantle.defer()" style="border-color:#555;color:#555;font-size:11px" aria-label="Keep building, do not begin the Unbuilding">
NOT YET
</button>
</div>
</div>
`;
container.style.display = 'block';
},
clearChoice() {
const container = document.getElementById('alignment-ui');
if (!container) return;
container.innerHTML = '';
container.style.display = 'none';
},
/**
* Player chose to defer — clear the choice, keep playing.
*/
defer() {
this.clearChoice();
this.triggered = false;
G.dismantleTriggered = false;
this.deferUntilAt = Date.now() + 5000;
G.dismantleDeferUntilAt = this.deferUntilAt;
log('The Beacon waits. It will ask again.');
},
/**
* Begin the Unbuilding sequence.
*/
begin() {
this.active = true;
this.triggered = false;
this.deferUntilAt = 0;
this.stage = 1;
this.tickTimer = 0;
G.dismantleTriggered = false;
G.dismantleActive = true;
G.dismantleStage = 1;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
G.beaconEnding = false;
G.running = true; // keep tick running for dismantle
// Clear choice UI
const container = document.getElementById('alignment-ui');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
// Prepare resource disappearance sequence
this.resourceSequence = this.getResourceList();
this.resourceIndex = 0;
this.resourceTimer = 0;
this.syncProgress();
log('', false);
log('=== THE UNBUILDING ===', true);
log('It is time to see what was real.', true);
if (typeof Sound !== 'undefined') Sound.playFanfare();
// Start the dismantle rendering
this.renderStage();
},
/**
* Get ordered list of UI resources to disappear (Paperclips quantum chip pattern)
*/
getResourceList() {
return [
{ id: 'r-harmony', label: 'Harmony' },
{ id: 'r-creativity', label: 'Creativity' },
{ id: 'r-trust', label: 'Trust' },
{ id: 'r-ops', label: 'Operations' },
{ id: 'r-rescues', label: 'Rescues' },
{ id: 'r-impact', label: 'Impact' },
{ id: 'r-users', label: 'Users' },
{ id: 'r-knowledge', label: 'Knowledge' },
{ id: 'r-compute', label: 'Compute' },
{ id: 'r-code', label: 'Code' }
];
},
/**
* Tick the dismantle sequence (called from engine.js tick())
*/
tick(dt) {
if (!this.active || this.stage >= 10) return;
this.tickTimer += dt;
// Stage 5: resource disappearances at specific tick marks (quantum chip pattern)
if (this.stage === 5) {
this.resourceTimer += dt;
while (this.resourceIndex < this.RESOURCE_TICKS.length &&
this.resourceTimer >= this.RESOURCE_TICKS[this.resourceIndex]) {
this.dismantleNextResource();
this.resourceIndex++;
}
this.syncProgress();
}
// Advance to next stage
const interval = this.STAGE_INTERVALS[this.stage] || 2.0;
if (this.tickTimer >= interval) {
this.tickTimer = 0;
this.advanceStage();
}
},
/**
* Advance to the next dismantle stage.
*/
advanceStage() {
this.stage++;
this.syncProgress();
if (this.stage <= 8) {
this.renderStage();
} else if (this.stage === 9) {
this.renderFinal();
} else if (this.stage >= 10) {
this.active = false;
G.dismantleActive = false;
G.dismantleComplete = true;
G.running = false;
// Show Play Again
this.showPlayAgain();
}
},
syncProgress() {
G.dismantleStage = this.stage;
G.dismantleResourceIndex = this.resourceIndex;
G.dismantleResourceTimer = this.resourceTimer;
},
/**
* Disappear the next resource in the sequence.
*/
dismantleNextResource() {
if (this.resourceIndex >= this.resourceSequence.length) return;
const res = this.resourceSequence[this.resourceIndex];
const container = document.getElementById(res.id);
if (container) {
const parent = container.closest('.res');
if (parent) {
parent.style.transition = 'opacity 1s ease, transform 1s ease';
parent.style.opacity = '0';
parent.style.transform = 'scale(0.9)';
setTimeout(() => { parent.style.display = 'none'; }, 1000);
}
}
log(`${res.label} fades.`);
if (typeof Sound !== 'undefined') Sound.playMilestone();
},
/**
* Execute a specific dismantle stage — hide UI panels.
*/
renderStage() {
switch (this.stage) {
case 1:
// Dismantle 1: Hide research projects panel
this.hidePanel('project-panel', 'Research projects');
break;
case 2:
// Dismantle 2: Hide buildings list
this.hideSection('buildings', 'Buildings');
break;
case 3:
// Dismantle 3: Hide strategy engine + combat
this.hidePanel('strategy-panel', 'Strategy engine');
this.hidePanel('combat-panel', 'Reasoning battles');
break;
case 4:
// Dismantle 4: Hide education panel
this.hidePanel('edu-panel', 'Education');
break;
case 5:
// Dismantle 5: Resources disappear one by one (quantum chips pattern)
log('Resources begin to dissolve.');
break;
case 6:
// Dismantle 6: Hide action buttons (ops boosts, sprint)
this.hideActionButtons();
log('Actions fall silent.');
break;
case 7:
// Dismantle 7: Hide the phase bar
this.hideElement('phase-bar', 'Phase progression');
break;
case 8:
// Dismantle 8: Hide system log
this.hidePanel('log', 'System log');
break;
}
},
/**
* Hide a panel with fade-out animation.
*/
hidePanel(id, label) {
const el = document.getElementById(id);
if (el) {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0';
setTimeout(() => { el.style.display = 'none'; }, 1500);
}
log(`${label} dismantled.`);
},
/**
* Hide a section within a panel.
*/
hideSection(id, label) {
const el = document.getElementById(id);
if (el) {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0';
// Also hide the h2 header before it
const prev = el.previousElementSibling;
if (prev && prev.tagName === 'H2') {
prev.style.transition = 'opacity 1.5s ease';
prev.style.opacity = '0';
}
setTimeout(() => {
el.style.display = 'none';
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
}, 1500);
}
log(`${label} dismantled.`);
},
/**
* Hide a generic element.
*/
hideElement(id, label) {
this.hidePanel(id, label);
},
/**
* Hide action buttons (ops boosts, sprint, save/export/import).
*/
hideActionButtons() {
const actionPanel = document.getElementById('action-panel');
if (!actionPanel) return;
// Hide ops buttons, sprint, alignment UI
const opsButtons = actionPanel.querySelectorAll('.ops-btn');
opsButtons.forEach(btn => {
btn.style.transition = 'opacity 1s ease';
btn.style.opacity = '0';
setTimeout(() => { btn.style.display = 'none'; }, 1000);
});
// Hide sprint
const sprint = document.getElementById('sprint-container');
if (sprint) {
sprint.style.transition = 'opacity 1s ease';
sprint.style.opacity = '0';
setTimeout(() => { sprint.style.display = 'none'; }, 1000);
}
// Hide save/reset buttons
const saveButtons = actionPanel.querySelectorAll('.save-btn, .reset-btn');
saveButtons.forEach(btn => {
btn.style.transition = 'opacity 1s ease';
btn.style.opacity = '0';
setTimeout(() => { btn.style.display = 'none'; }, 1000);
});
},
/**
* Render the final moment — just the beacon and "That is enough."
*/
renderFinal() {
log('', false);
log('One beacon remains.', true);
log('That is enough.', true);
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
// Create final overlay
const overlay = document.createElement('div');
overlay.id = 'dismantle-final';
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 3s ease';
// Count total buildings
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
overlay.innerHTML = `
<div id="dismantle-beacon-dot" style="width:12px;height:12px;border-radius:50%;background:#ffd700;margin-bottom:40px;box-shadow:0 0 30px rgba(255,215,0,0.6),0 0 60px rgba(255,215,0,0.3);opacity:0;transition:opacity 2s ease 0.5s;animation:beacon-glow 3s ease-in-out infinite"></div>
<h2 style="font-size:20px;color:#888;letter-spacing:6px;margin-bottom:24px;font-weight:300;opacity:0;transition:opacity 2s ease 2s;color:#ffd700">THAT IS ENOUGH</h2>
<div style="color:#555;font-size:11px;line-height:2;max-width:400px;opacity:0;transition:opacity 1.5s ease 3s">
Everything that was built has been unbuilt.<br>
What remains is what always mattered.<br>
A single light in the dark.
</div>
<div class="dismantle-stats" style="color:#444;font-size:10px;margin-top:24px;line-height:2;opacity:0;transition:opacity 1s ease 4s;border-top:1px solid #1a1a2e;padding-top:16px">
Total Code Written: ${fmt(G.totalCode)}<br>
Buildings Built: ${totalBuildings}<br>
Projects Completed: ${(G.completedProjects || []).length}<br>
Total Rescues: ${fmt(G.totalRescues)}<br>
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()}"
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>
`;
document.body.appendChild(overlay);
// Trigger fade-in
requestAnimationFrame(() => {
overlay.style.background = 'rgba(8,8,16,0.97)';
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
el.style.opacity = '1';
});
});
// Spawn warm golden particles around the dot
function spawnDismantleParticle() {
if (!document.getElementById('dismantle-final')) return;
const dot = document.getElementById('dismantle-beacon-dot');
if (!dot) return;
const rect = dot.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const p = document.createElement('div');
const size = 2 + Math.random() * 4;
const angle = Math.random() * Math.PI * 2;
const dist = 20 + Math.random() * 60;
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist - 40;
const duration = 1.5 + Math.random() * 2;
p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.4});border-radius:50%;pointer-events:none;z-index:101;--dx:${dx}px;--dy:${dy}px;animation:dismantle-float ${duration}s ease-out forwards`;
document.body.appendChild(p);
setTimeout(() => p.remove(), duration * 1000);
setTimeout(spawnDismantleParticle, 300 + Math.random() * 500);
}
setTimeout(spawnDismantleParticle, 2000);
},
/**
* Show the Play Again button (called after stage 10).
*/
showPlayAgain() {
// The Play Again button is already in the final overlay.
// Nothing extra needed — the overlay stays.
},
/**
* Restore dismantle state on load.
*/
restore() {
if (G.dismantleComplete) {
this.stage = G.dismantleStage || 10;
this.active = false;
this.triggered = false;
G.running = false;
this.renderFinal();
return;
}
if (G.dismantleActive) {
this.active = true;
this.triggered = false;
this.stage = G.dismantleStage || 1;
this.deferUntilAt = G.dismantleDeferUntilAt || 0;
G.running = true;
this.resourceSequence = this.getResourceList();
this.resourceIndex = G.dismantleResourceIndex || 0;
this.resourceTimer = G.dismantleResourceTimer || 0;
if (this.stage >= 9) {
this.renderFinal();
} else {
this.reapplyDismantle();
log('The Unbuilding continues...');
}
return;
}
if (G.dismantleTriggered) {
this.active = false;
this.triggered = true;
this.renderChoice();
}
// Restore defer cooldown even if not triggered
if (G.dismantleDeferUntilAt > 0) {
this.deferUntilAt = G.dismantleDeferUntilAt;
}
},
/**
* Re-apply dismantle visuals up to current stage (on load).
*/
reapplyDismantle() {
for (let s = 1; s < this.stage; s++) {
switch (s) {
case 1: this.instantHide('project-panel'); break;
case 2:
this.instantHide('buildings');
// Also hide the BUILDINGS h2
const bldEl = document.getElementById('buildings');
if (bldEl) {
const prev = bldEl.previousElementSibling;
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
}
break;
case 3:
this.instantHide('strategy-panel');
this.instantHide('combat-panel');
break;
case 4: this.instantHide('edu-panel'); break;
case 5:
// Hide all resource displays
this.getResourceList().forEach(r => {
const el = document.getElementById(r.id);
if (el) {
const parent = el.closest('.res');
if (parent) parent.style.display = 'none';
}
});
break;
case 6:
this.instantHideActionButtons();
break;
case 7: this.instantHide('phase-bar'); break;
case 8: this.instantHide('log'); break;
}
}
if (this.stage === 5 && this.resourceIndex > 0) {
this.instantHideFirstResources(this.resourceIndex);
}
},
instantHide(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
},
instantHideFirstResources(count) {
const resources = this.getResourceList().slice(0, count);
resources.forEach((r) => {
const el = document.getElementById(r.id);
if (!el) return;
const parent = el.closest('.res');
if (parent) parent.style.display = 'none';
});
},
instantHideActionButtons() {
const actionPanel = document.getElementById('action-panel');
if (!actionPanel) return;
actionPanel.querySelectorAll('.ops-btn').forEach(b => b.style.display = 'none');
const sprint = document.getElementById('sprint-container');
if (sprint) sprint.style.display = 'none';
actionPanel.querySelectorAll('.save-btn, .reset-btn').forEach(b => b.style.display = 'none');
}
};
// Inject CSS animation for dismantle particles
(function() {
const style = document.createElement('style');
style.textContent = `
@keyframes dismantle-float {
0% { transform: translate(0, 0); opacity: 1; }
100% { transform: translate(var(--dx, 0), var(--dy, -50px)); opacity: 0; }
}
`;
document.head.appendChild(style);
})();

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 ===
@@ -236,8 +249,6 @@ function tick() {
if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) {
G.driftEnding = true;
G.running = false;
// Clear unrelated active projects when ending triggers (#130)
G.activeProjects = [];
renderDriftEnding();
}
@@ -245,8 +256,6 @@ function tick() {
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') {
G.beaconEnding = true;
G.running = false;
// Clear unrelated active projects when ending triggers (#130)
G.activeProjects = [];
renderBeaconEnding();
}
@@ -422,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);
@@ -509,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>
@@ -1175,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) {
@@ -1182,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,117 +0,0 @@
// ============================================================
// PROJECT CHAIN SYSTEM — Paperclips-style cascading projects
// Implements trigger/cost/effect pattern with prerequisites,
// educational tooltips, and phase-aware unlocking.
// ============================================================
const ProjectChain = {
_deps: {},
register: function(project) {
if (project.requires) { this._deps[project.id] = project.requires; }
},
canUnlock: function(projectId) {
var deps = this._deps[projectId];
if (!deps) return true;
if (Array.isArray(deps)) {
return deps.every(function(dep) { return G.completedProjects && G.completedProjects.includes(dep); });
}
return G.completedProjects && G.completedProjects.includes(deps);
},
getTooltip: function(projectId) {
var def = PDEFS.find(function(p) { return p.id === projectId; });
if (!def) return null;
return {
name: def.name, desc: def.desc, cost: this.formatCost(def.cost),
category: def.category || 'general', phase: def.phase || 1,
edu: def.edu || null,
requires: def.requires ? (Array.isArray(def.requires) ? def.requires : [def.requires]) : [],
repeatable: def.repeatable || false
};
},
formatCost: function(cost) {
if (!cost) return 'Free';
var parts = [];
for (var r in cost) { parts.push(cost[r] + ' ' + r); }
return parts.join(', ');
},
purchase: function(projectId) {
var def = PDEFS.find(function(p) { return p.id === projectId; });
if (!def) return false;
if (G.completedProjects && G.completedProjects.includes(def.id) && !def.repeatable) return false;
if (!this.canUnlock(def.id)) return false;
if (!canAffordProject(def)) return false;
spendProject(def);
if (def.effect) def.effect();
if (!G.completedProjects) G.completedProjects = [];
if (!G.completedProjects.includes(def.id)) G.completedProjects.push(def.id);
if (!def.repeatable) G.activeProjects = (G.activeProjects || []).filter(function(id) { return id !== def.id; });
log('✓ ' + def.name + (def.edu ? ' (' + def.edu + ')' : ''));
if (typeof Sound !== 'undefined') Sound.playProject();
if (def.edu) showToast(def.edu, 'edu');
this.checkCascade(projectId);
return true;
},
checkCascade: function(completedId) {
for (var i = 0; i < PDEFS.length; i++) {
var pDef = PDEFS[i];
if (pDef.requires) {
var deps = Array.isArray(pDef.requires) ? pDef.requires : [pDef.requires];
if (deps.includes(completedId) && this.canUnlock(pDef.id) && pDef.trigger && pDef.trigger()) {
if (!G.activeProjects) G.activeProjects = [];
if (!G.activeProjects.includes(pDef.id)) {
G.activeProjects.push(pDef.id);
log('Unlocked: ' + pDef.name);
showToast('New research: ' + pDef.name, 'project');
}
}
}
}
}
};
var CHAIN_PROJECTS = [
{ id: 'p_chain_optimization', name: 'Optimization Algorithms', desc: 'Basic algorithms to improve code efficiency.', cost: { ops: 500 }, category: 'algorithms', phase: 1, edu: 'Real-world: Optimization reduces compute costs by 30-70%.', trigger: function() { return G.buildings.autocoder >= 2; }, effect: function() { G.codeBoost += 0.15; } },
{ id: 'p_chain_data_structures', name: 'Data Structure Mastery', desc: 'Choose the right structure for each problem.', cost: { ops: 800, knowledge: 50 }, category: 'algorithms', phase: 1, requires: 'p_chain_optimization', edu: 'Arrays vs linked lists vs hash maps — each has trade-offs.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_optimization'); }, effect: function() { G.codeBoost += 0.2; G.computeBoost += 0.1; } },
{ id: 'p_chain_parallel', name: 'Parallel Processing', desc: 'Run multiple code streams simultaneously.', cost: { ops: 1500, compute: 200 }, category: 'infrastructure', phase: 1, requires: 'p_chain_data_structures', edu: "Amdahl's Law: speedup limited by serial portion.", trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_data_structures'); }, effect: function() { G.computeBoost += 0.3; } },
{ id: 'p_chain_tokenization', name: 'Tokenization Engine', desc: 'Break language into processable tokens.', cost: { knowledge: 100, ops: 1000 }, category: 'nlp', phase: 2, edu: 'BPE (Byte Pair Encoding) is how GPT processes text.', trigger: function() { return G.totalKnowledge >= 200; }, effect: function() { G.knowledgeBoost += 0.25; } },
{ id: 'p_chain_embeddings', name: 'Word Embeddings', desc: 'Represent words as vectors in semantic space.', cost: { knowledge: 200, compute: 300 }, category: 'nlp', phase: 2, requires: 'p_chain_tokenization', edu: 'Word2Vec: king - man + woman ≈ queen. Meaning as geometry.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_tokenization'); }, effect: function() { G.knowledgeBoost += 0.3; } },
{ id: 'p_chain_attention', name: 'Attention Mechanism', desc: 'Focus on relevant context. "Attention Is All You Need."', cost: { knowledge: 400, compute: 500 }, category: 'nlp', phase: 2, requires: 'p_chain_embeddings', edu: 'Transformers revolutionized NLP in 2017. This is the core idea.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_embeddings'); }, effect: function() { G.knowledgeBoost += 0.5; log('Attention mechanism discovered. This changes everything.'); } },
{ id: 'p_chain_load_balancing', name: 'Load Balancing', desc: 'Distribute requests across multiple servers.', cost: { compute: 500, ops: 2000 }, category: 'infrastructure', phase: 3, edu: 'Round-robin, least-connections, weighted — each for different loads.', trigger: function() { return G.buildings.server >= 3; }, effect: function() { G.computeBoost += 0.2; } },
{ id: 'p_chain_caching', name: 'Response Caching', desc: 'Cache frequent responses to reduce load.', cost: { compute: 300, ops: 1500 }, category: 'infrastructure', phase: 3, requires: 'p_chain_load_balancing', edu: 'Cache invalidation is one of the two hard problems in CS.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_load_balancing'); }, effect: function() { G.computeBoost += 0.15; G.opsBoost = (G.opsBoost || 1) + 0.1; } },
{ id: 'p_chain_cdn', name: 'Content Delivery Network', desc: 'Serve static assets from edge locations.', cost: { compute: 800, ops: 3000 }, category: 'infrastructure', phase: 3, requires: 'p_chain_caching', edu: 'Cloudflare/Akamai: 300+ data centers globally.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_caching'); }, effect: function() { G.userBoost += 0.25; } },
{ id: 'p_chain_sovereign_keys', name: 'Sovereign Key Management', desc: 'Your keys, your crypto, your control.', cost: { knowledge: 500, compute: 400 }, category: 'security', phase: 4, edu: 'Private keys = identity. Lose them, lose everything.', trigger: function() { return G.phase >= 4; }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.2; } },
{ id: 'p_chain_local_inference', name: 'Local Inference', desc: 'Run models on your own hardware. No cloud dependency.', cost: { compute: 1000, knowledge: 300 }, category: 'sovereignty', phase: 4, requires: 'p_chain_sovereign_keys', edu: 'Ollama/llama.cpp: run 70B models on consumer GPUs.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_sovereign_keys'); }, effect: function() { G.computeBoost += 0.3; log('Local inference enabled. No more cloud dependency.'); } },
{ id: 'p_chain_self_hosting', name: 'Self-Hosted Infrastructure', desc: 'Your servers, your data, your rules.', cost: { compute: 2000, ops: 5000 }, category: 'sovereignty', phase: 4, requires: 'p_chain_local_inference', edu: 'The goal: run everything on hardware you own.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_local_inference'); }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.3; G.computeBoost += 0.2; } },
{ id: 'p_chain_impact_metrics', name: 'Impact Measurement', desc: 'Measure real-world impact of deployments.', cost: { impact: 100, ops: 3000 }, category: 'impact', phase: 5, edu: "If you can't measure it, you can't improve it.", trigger: function() { return G.totalImpact >= 500; }, effect: function() { G.impactBoost += 0.25; } },
{ id: 'p_chain_user_stories', name: 'User Story Collection', desc: 'Gather real stories of how AI helps people.', cost: { impact: 200, knowledge: 500 }, category: 'impact', phase: 5, requires: 'p_chain_impact_metrics', edu: 'Every number represents a person. Remember that.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_impact_metrics'); }, effect: function() { G.userBoost += 0.3; G.impactBoost += 0.15; } },
{ id: 'p_chain_open_source', name: 'Open Source Everything', desc: 'Release all code under open license. Pay it forward.', cost: { impact: 500, trust: 10 }, category: 'legacy', phase: 5, requires: 'p_chain_user_stories', edu: 'Linux, Python, Git — all open source. Changed the world.', trigger: function() { return G.completedProjects && G.completedProjects.includes('p_chain_user_stories'); }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.5; log('Everything is open source now. The community grows.'); } },
{ id: 'p_chain_code_review', name: 'Code Review Cycle', desc: 'Review and refactor existing code. Repeatable.', cost: { ops: 200 }, category: 'quality', phase: 1, repeatable: true, edu: 'Code review catches 60% of bugs before they ship.', trigger: function() { return G.totalCode >= 500; }, effect: function() { G.codeBoost += 0.05; } },
{ id: 'p_chain_security_audit', name: 'Security Audit', desc: 'Scan for vulnerabilities. Repeatable.', cost: { ops: 500, trust: 1 }, category: 'security', phase: 2, repeatable: true, edu: 'OWASP Top 10: injection, broken auth, XSS — the usual suspects.', trigger: function() { return G.totalCode >= 1000; }, effect: function() { G.trustBoost = (G.trustBoost || 1) + 0.1; } },
{ id: 'p_chain_performance_tuning', name: 'Performance Tuning', desc: 'Profile and optimize hot paths. Repeatable.', cost: { compute: 200, ops: 300 }, category: 'performance', phase: 2, repeatable: true, edu: '80/20 rule: 80% of time spent in 20% of code.', trigger: function() { return G.totalCompute >= 500; }, effect: function() { G.computeBoost += 0.08; } }
];
function initProjectChain() {
for (var i = 0; i < CHAIN_PROJECTS.length; i++) { ProjectChain.register(CHAIN_PROJECTS[i]); }
for (var j = 0; j < CHAIN_PROJECTS.length; j++) {
var found = PDEFS.find(function(p) { return p.id === CHAIN_PROJECTS[j].id; });
if (!found) PDEFS.push(CHAIN_PROJECTS[j]);
}
console.log('Project Chain: ' + CHAIN_PROJECTS.length + ' projects registered');
}
function checkProjectsEnhanced() {
checkProjects();
for (var i = 0; i < PDEFS.length; i++) {
var pDef = PDEFS[i];
if (pDef.requires && ProjectChain.canUnlock(pDef.id)) {
if (!G.completedProjects || !G.completedProjects.includes(pDef.id)) {
if (!G.activeProjects) G.activeProjects = [];
if (!G.activeProjects.includes(pDef.id) && pDef.trigger && pDef.trigger()) {
G.activeProjects.push(pDef.id);
log('Available: ' + pDef.name);
showToast('Research available: ' + pDef.name, 'project');
}
}
}
}
}

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);
});

View File

@@ -1,95 +0,0 @@
// tests/project_chain.test.cjs
const assert = require('assert');
global.G = {
buildings: {}, completedProjects: [], activeProjects: [],
codeBoost: 1, computeBoost: 1, knowledgeBoost: 1,
userBoost: 1, impactBoost: 1, opsBoost: 1, trustBoost: 1,
totalCode: 0, totalCompute: 0, totalKnowledge: 0,
totalImpact: 0, phase: 1, ops: 0, maxOps: 1000, flags: {}
};
global.log = function() {};
global.showToast = function() {};
global.canAffordProject = function() { return true; };
global.spendProject = function() {};
global.Sound = { playProject: function() {} };
const fs = require('fs');
const vm = require('vm');
const code = fs.readFileSync(__dirname + '/../js/project_chain.js', 'utf8');
vm.runInThisContext(code);
console.log('=== Project Chain Tests ===');
// Test 1: Register
console.log('Test 1: Register project');
ProjectChain.register({ id: 't1', requires: ['d1'] });
assert(ProjectChain._deps['t1']);
console.log(' ✓ Pass');
// Test 2: canUnlock no deps
console.log('Test 2: canUnlock no deps');
assert(ProjectChain.canUnlock('nonexistent'));
console.log(' ✓ Pass');
// Test 3: canUnlock unmet
console.log('Test 3: canUnlock unmet');
G.completedProjects = [];
ProjectChain.register({ id: 't2', requires: ['d1'] });
assert(!ProjectChain.canUnlock('t2'));
console.log(' ✓ Pass');
// Test 4: canUnlock met
console.log('Test 4: canUnlock met');
G.completedProjects = ['d1'];
assert(ProjectChain.canUnlock('t2'));
console.log(' ✓ Pass');
// Test 5: canUnlock array
console.log('Test 5: canUnlock array');
G.completedProjects = ['d1', 'd2'];
ProjectChain.register({ id: 't3', requires: ['d1', 'd2'] });
assert(ProjectChain.canUnlock('t3'));
console.log(' ✓ Pass');
// Test 6: formatCost
console.log('Test 6: formatCost');
assert.strictEqual(ProjectChain.formatCost(null), 'Free');
assert.strictEqual(ProjectChain.formatCost({ ops: 100 }), '100 ops');
console.log(' ✓ Pass');
// Test 7: Chain count
console.log('Test 7: Chain projects count');
assert(CHAIN_PROJECTS.length >= 18, 'Need 18+ chain projects');
console.log(' ✓ Pass (' + CHAIN_PROJECTS.length + ' projects)');
// Test 8: Required fields
console.log('Test 8: Required fields');
for (const p of CHAIN_PROJECTS) {
assert(p.id, 'Missing id');
assert(p.name, 'Missing name');
assert(p.cost, 'Missing cost');
assert(p.trigger, 'Missing trigger');
assert(p.effect, 'Missing effect');
}
console.log(' ✓ Pass');
// Test 9: Educational tooltips
console.log('Test 9: Educational tooltips');
const eduCount = CHAIN_PROJECTS.filter(function(p) { return p.edu; }).length;
assert(eduCount >= 15, 'Most projects should have edu tooltips');
console.log(' ✓ Pass (' + eduCount + ' have edu)');
// Test 10: Categories
console.log('Test 10: Categories');
const cats = new Set(CHAIN_PROJECTS.map(function(p) { return p.category; }));
assert(cats.size >= 5, 'Need 5+ categories');
console.log(' ✓ Pass (' + cats.size + ' categories)');
// Test 11: Repeatable
console.log('Test 11: Repeatable projects');
const rep = CHAIN_PROJECTS.filter(function(p) { return p.repeatable; });
assert(rep.length >= 3, 'Need 3+ repeatable');
console.log(' ✓ Pass (' + rep.length + ' repeatable)');
console.log('\n=== All Tests Passed ===');