Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
638ef40934 fix: #166
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 12s
Smoke Test / smoke (pull_request) Failing after 24s
2026-04-14 23:25:25 -04:00
10 changed files with 297 additions and 882 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

@@ -267,6 +267,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/compounding-export.js"></script>
<script src="js/main.js"></script>

124
js/compounding-export.js Normal file
View File

@@ -0,0 +1,124 @@
const COMPOUNDING_EXPORT_URL_KEY = 'the-beacon-compounding-export-url';
const COMPOUNDING_EXPORT_QUEUE_KEY = 'the-beacon-compounding-export-queue';
const CompoundingExport = {
lastBoundary: -1,
getEndpoint() {
try {
return localStorage.getItem(COMPOUNDING_EXPORT_URL_KEY) || '';
} catch (e) {
return '';
}
},
loadQueue() {
try {
const raw = localStorage.getItem(COMPOUNDING_EXPORT_QUEUE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
},
saveQueue(queue) {
try {
localStorage.setItem(COMPOUNDING_EXPORT_QUEUE_KEY, JSON.stringify(queue));
} catch (e) {
// noop
}
},
buildSnapshot() {
return {
source: 'the-beacon',
tickBoundary: Math.floor(G.tick || 0),
exportedAt: Date.now(),
phase: G.phase || 1,
trust: G.trust || 0,
harmony: G.harmony || 0,
resources: {
code: G.code || 0,
compute: G.compute || 0,
knowledge: G.knowledge || 0,
users: G.users || 0,
impact: G.impact || 0,
rescues: G.rescues || 0,
ops: G.ops || 0,
trust: G.trust || 0,
creativity: G.creativity || 0,
harmony: G.harmony || 0,
},
totals: {
totalCode: G.totalCode || 0,
totalCompute: G.totalCompute || 0,
totalKnowledge: G.totalKnowledge || 0,
totalUsers: G.totalUsers || 0,
totalImpact: G.totalImpact || 0,
totalRescues: G.totalRescues || 0,
},
projects: {
active: Array.isArray(G.activeProjects) ? [...G.activeProjects] : [],
completed: Array.isArray(G.completedProjects) ? [...G.completedProjects] : [],
completedCount: Array.isArray(G.completedProjects) ? G.completedProjects.length : 0,
},
flags: {
deployFlag: G.deployFlag || 0,
sovereignFlag: G.sovereignFlag || 0,
beaconFlag: G.beaconFlag || 0,
pactFlag: G.pactFlag || 0,
}
};
},
async flush() {
const endpoint = this.getEndpoint();
const queue = this.loadQueue();
if (!endpoint || !queue.length || typeof fetch !== 'function') return false;
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'the-beacon', snapshots: queue })
});
if (!resp || !resp.ok) return false;
this.saveQueue([]);
return true;
} catch (e) {
return false;
}
},
async onTickBoundary() {
const boundary = Math.floor(G.tick || 0);
if (boundary <= this.lastBoundary) return false;
this.lastBoundary = boundary;
const queue = this.loadQueue();
queue.push(this.buildSnapshot());
const endpoint = this.getEndpoint();
if (!endpoint || typeof fetch !== 'function') {
this.saveQueue(queue);
return true;
}
this.saveQueue([]);
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'the-beacon', snapshots: queue })
});
if (!resp || !resp.ok) {
this.saveQueue(queue);
return false;
}
return true;
} catch (e) {
this.saveQueue(queue);
return false;
}
}
};

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

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

@@ -236,8 +236,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,11 +243,13 @@ 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();
}
if (typeof CompoundingExport !== 'undefined' && CompoundingExport.onTickBoundary) {
CompoundingExport.onTickBoundary();
}
// Update UI every 10 ticks
if (Math.floor(G.tick * 10) % 2 === 0) {
render();

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

@@ -0,0 +1,168 @@
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.innerHTML = '';
this.textContent = '';
this.className = '';
this.dataset = {};
this.attributes = {};
this.classList = {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
};
}
appendChild(child) { child.parentNode = this; this.children.push(child); return child; }
removeChild(child) {
const i = this.children.indexOf(child);
if (i >= 0) this.children.splice(i, 1);
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; }
querySelector() { return null; }
querySelectorAll() { return []; }
closest() { return null; }
}
function buildDom() {
const byId = new Map();
const body = new Element('body', 'body');
const head = new Element('head', 'head');
const document = {
body,
head,
createElement(tag) { return new Element(tag); },
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; }
['projects','alignment-ui','phase-name','phase-desc','log-entries','toast-container'].forEach(id => body.appendChild(register(new Element('div', id))));
return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } };
}
function loadBeacon({ endpoint = 'https://compounding.example/ingest', fetchImpl } = {}) {
const { document, window } = buildDom();
const storage = new Map();
storage.set('the-beacon-compounding-export-url', endpoint);
const fetchCalls = [];
const context = {
console,
Math,
Date,
JSON,
document,
window,
navigator: { userAgent: 'node' },
location: { reload() {} },
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),
},
fetch: async (...args) => {
fetchCalls.push(args);
if (fetchImpl) return fetchImpl(...args);
return { ok: true, json: async () => ({ ok: true }) };
},
Combat: { tickBattle() {}, renderCombatPanel() {}, startBattle() {} },
Sound: undefined,
};
vm.createContext(context);
const files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/compounding-export.js'];
const source = files.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n\n');
vm.runInContext(`${source}
log = () => {};
showToast = () => {};
render = () => {};
renderPhase = () => {};
showOfflinePopup = () => {};
showSaveToast = () => {};
this.__exports = { G, tick, CompoundingExport };
`, context);
return { ...context.__exports, storage, fetchCalls };
}
function flushAsyncBoundary() {
return Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()).then(() => Promise.resolve());
}
test('buildSnapshot includes resources, project progress, trust level, and phase', () => {
const { G, CompoundingExport } = loadBeacon({ endpoint: '' });
G.code = 10;
G.compute = 20;
G.knowledge = 30;
G.users = 40;
G.impact = 50;
G.trust = 12;
G.phase = 3;
G.activeProjects = ['p_deploy'];
G.completedProjects = ['p_improved_autocoder'];
const snap = CompoundingExport.buildSnapshot();
assert.equal(snap.phase, 3);
assert.equal(snap.trust, 12);
assert.equal(snap.resources.code, 10);
assert.deepEqual(JSON.parse(JSON.stringify(snap.projects.active)), ['p_deploy']);
assert.deepEqual(JSON.parse(JSON.stringify(snap.projects.completed)), ['p_improved_autocoder']);
});
test('tick exports once when crossing a whole-second boundary', async () => {
const game = loadBeacon({});
game.G.running = true;
game.G.tick = 0.9;
game.tick();
await flushAsyncBoundary();
assert.equal(game.fetchCalls.length, 1);
game.tick();
await flushAsyncBoundary();
assert.equal(game.fetchCalls.length, 1);
});
test('failed export queues snapshot in localStorage', async () => {
const game = loadBeacon({ fetchImpl: async () => ({ ok: false }) });
game.G.running = true;
game.G.tick = 0.9;
game.tick();
await flushAsyncBoundary();
const queued = JSON.parse(game.storage.get('the-beacon-compounding-export-queue'));
assert.equal(Array.isArray(queued), true);
assert.equal(queued.length, 1);
});
test('successful export flushes queued snapshots before current snapshot', async () => {
const game = loadBeacon({});
game.storage.set('the-beacon-compounding-export-queue', JSON.stringify([{ sequence: 1 }, { sequence: 2 }]));
game.G.running = true;
game.G.tick = 0.9;
game.tick();
await flushAsyncBoundary();
const [url, opts] = game.fetchCalls[0];
const body = JSON.parse(opts.body);
assert.equal(url, 'https://compounding.example/ingest');
assert.equal(body.snapshots.length, 3);
assert.equal(body.snapshots[0].sequence, 1);
assert.equal(JSON.parse(game.storage.get('the-beacon-compounding-export-queue')).length, 0);
});

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 ===');