Compare commits

...

11 Commits

Author SHA1 Message Date
Alexander Whitestone
ffbeb18e2e Fix #137: Unbuilding defer cooldown persists across save/load
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 5s
Smoke Test / smoke (pull_request) Failing after 8s
- Added G.dismantleDeferUntilAt to restore() condition in main.js
- Added defer cooldown restoration in dismantle.js restore() method
- Added new test to verify cooldown persistence
- Fixed issue where defer cooldown was bypassed after reload

The bug occurred because:
1. When player defers, G.dismantleTriggered is set to false
2. On load, Dismantle.restore() was not called because condition only checked G.dismantleTriggered
3. This.deferUntilAt was not restored from G.dismantleDeferUntilAt
4. checkTrigger() would trigger immediately instead of honoring cooldown

Fix:
- Added G.dismantleDeferUntilAt > 0 to restore() condition in main.js
- Added defer cooldown restoration in dismantle.js restore() method
- Now defer cooldown properly survives save/load cycles

All tests pass including new test for this specific scenario.
2026-04-14 14:38:10 -04:00
Timmy
6803ef80af beacon: persist the Unbuilding defer cooldown
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 6s
Smoke Test / smoke (pull_request) Failing after 9s
2026-04-14 11:43:51 -04:00
Timmy
4f0b54a970 beacon: persist stage-five dissolve progress 2026-04-14 11:36:31 -04:00
Timmy
cff3d1715b beacon: suppress drift UI during the Unbuilding 2026-04-14 11:36:31 -04:00
Timmy
f1bed950e6 beacon: preserve the Unbuilding prompt during renders 2026-04-14 11:36:31 -04:00
Timmy
4c162ed123 beacon: implement the Unbuilding endgame 2026-04-14 11:36:31 -04:00
1081b9e6c4 Merge pull request 'ci: re-trigger smoke test (clearing stale run #213)' (#115) from ci/retrigger-smoke into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
ci: re-trigger smoke test
2026-04-13 19:09:36 +00:00
Alexander Whitestone
e74f956bf4 ci: re-trigger smoke test (stale run #213 from before PR #106 merge)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 5s
2026-04-13 15:08:54 -04:00
55f280d056 Merge pull request 'burn: fix null ref in renderResources and add tutorial dialog a11y' (#114) from burn/20260413-0400-qa-remaining-fixes into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
merge reviewed bugfix
2026-04-13 09:43:52 +00:00
Alexander Whitestone
6446ecb43a burn: fix null ref in renderResources and add tutorial dialog a11y
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 4s
BUG-08: Add null check on closest('.res') in renderResources to
prevent TypeError if DOM structure is unexpected.

BUG-11: Add role='dialog', aria-modal='true', aria-label='Tutorial'
to tutorial overlay. Add aria-label to Skip and Next buttons for
screen reader accessibility.

Smoke test: all 19 checks passed.
2026-04-13 04:37:08 -04:00
0a312b111d Merge pull request 'fix: add missing CSS for resource counter pulse/shake animations' (#113) from fix/resource-counter-animations into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-13 08:30:27 +00:00
10 changed files with 1096 additions and 9 deletions

1
.ci-trigger Normal file
View File

@@ -0,0 +1 @@
# Trivial file to re-trigger CI after stale run

View File

@@ -8,6 +8,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
@@ -22,3 +25,7 @@ jobs:
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'guardrails'; then exit 1; fi
echo "PASS: No secrets"
- name: Node tests
run: |
node --test tests/*.cjs
echo "PASS: Node tests"

View File

@@ -266,6 +266,7 @@ The light is on. The room is empty."
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/main.js"></script>

View File

@@ -158,7 +158,17 @@ const G = {
// Time tracking
playTime: 0,
startTime: 0,
flags: {}
flags: {},
// Endgame sequence
beaconEnding: false,
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
dismantleResourceIndex: 0,
dismantleResourceTimer: 0,
dismantleDeferUntilAt: 0,
dismantleComplete: false
};
// === PHASE DEFINITIONS ===

570
js/dismantle.js Normal file
View File

@@ -0,0 +1,570 @@
// ============================================================
// 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

@@ -216,20 +216,31 @@ function tick() {
}
// Check corruption events every ~30 seconds
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) {
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY && !G.dismantleActive) {
triggerEvent();
G.lastEventAt = G.tick;
}
// The Unbuilding: offer or advance the sequence before a positive ending overlay can freeze the game
if (typeof Dismantle !== 'undefined') {
if (!G.dismantleActive && !G.dismantleComplete) {
Dismantle.checkTrigger();
}
if (G.dismantleActive) {
Dismantle.tick(dt);
G.dismantleStage = Dismantle.stage;
}
}
// Drift ending: if drift reaches 100, the game ends
if (G.drift >= 100 && !G.driftEnding) {
if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) {
G.driftEnding = true;
G.running = false;
renderDriftEnding();
}
// True ending: The Beacon Shines — rescues + Pact + harmony
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) {
// Legacy Beacon overlay remains as a fallback for contexts where Dismantle is unavailable.
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') {
G.beaconEnding = true;
G.running = false;
renderBeaconEnding();
@@ -964,7 +975,10 @@ function renderResources() {
// Rescues — only show if player has any beacon/mesh nodes
const rescuesRes = document.getElementById('r-rescues');
if (rescuesRes) {
rescuesRes.closest('.res').style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
const container = rescuesRes.closest('.res');
if (container) {
container.style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
}
set('r-rescues', G.rescues, G.rescuesRate);
}

View File

@@ -6,6 +6,10 @@ function initGame() {
G.deployFlag = 0;
G.sovereignFlag = 0;
G.beaconFlag = 0;
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = false;
updateRates();
render();
renderPhase();
@@ -31,6 +35,8 @@ window.addEventListener('load', function () {
if (G.driftEnding) {
G.running = false;
renderDriftEnding();
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete || G.dismantleDeferUntilAt > 0)) {
Dismantle.restore();
} else if (G.beaconEnding) {
G.running = false;
renderBeaconEnding();

View File

@@ -37,6 +37,18 @@ function renderStrategy() {
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
if (G.dismantleActive || G.dismantleComplete) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
if (G.dismantleTriggered && !G.dismantleActive && !G.dismantleComplete && typeof Dismantle !== 'undefined' && Dismantle.triggered) {
Dismantle.renderChoice();
return;
}
if (G.pendingAlignment) {
container.innerHTML = `
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
@@ -215,6 +227,13 @@ function saveGame() {
swarmRate: G.swarmRate || 0,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
dismantleTriggered: G.dismantleTriggered || false,
dismantleActive: G.dismantleActive || false,
dismantleStage: G.dismantleStage || 0,
dismantleResourceIndex: G.dismantleResourceIndex || 0,
dismantleResourceTimer: G.dismantleResourceTimer || 0,
dismantleDeferUntilAt: G.dismantleDeferUntilAt || 0,
dismantleComplete: G.dismantleComplete || false,
savedAt: Date.now()
};
@@ -246,7 +265,9 @@ function loadGame() {
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
];
G.isLoading = true;

View File

@@ -177,6 +177,9 @@ function renderTutorialStep(index) {
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tutorial-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Tutorial');
document.body.appendChild(overlay);
}
@@ -196,8 +199,8 @@ function renderTutorialStep(index) {
<div class="t-tip">${step.tip}</div>
<div id="tutorial-dots">${dots}</div>
<div id="tutorial-btns">
<button id="tutorial-skip-btn" onclick="closeTutorial()">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}">${isLast ? 'Start Playing' : 'Next →'}</button>
<button id="tutorial-skip-btn" onclick="closeTutorial()" aria-label="Skip tutorial">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}" aria-label="${isLast ? 'Start playing' : 'Next tutorial step'}">${isLast ? 'Start Playing' : 'Next →'}</button>
</div>
</div>
`;

454
tests/dismantle.test.cjs Normal file
View File

@@ -0,0 +1,454 @@
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(' ');
}
};
}
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;
}
getBoundingClientRect() {
return { left: 0, top: 0, width: 12, height: 12 };
}
}
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(element) {
if (element.id) byId.set(element.id, element);
return element;
}
const alignmentUi = register(new Element('div', 'alignment-ui'));
const actionPanel = register(new Element('div', 'action-panel'));
const sprintContainer = register(new Element('div', 'sprint-container'));
const projectPanel = register(new Element('div', 'project-panel'));
const buildingsHeader = new Element('h2');
const buildings = register(new Element('div', 'buildings'));
buildings.previousElementSibling = buildingsHeader;
const strategyPanel = register(new Element('div', 'strategy-panel'));
const combatPanel = register(new Element('div', 'combat-panel'));
const eduPanel = register(new Element('div', 'edu-panel'));
const phaseBar = register(new Element('div', 'phase-bar'));
const logPanel = register(new Element('div', 'log'));
const logEntries = register(new Element('div', 'log-entries'));
const toastContainer = register(new Element('div', 'toast-container'));
body.appendChild(alignmentUi);
body.appendChild(actionPanel);
body.appendChild(sprintContainer);
body.appendChild(projectPanel);
body.appendChild(buildingsHeader);
body.appendChild(buildings);
body.appendChild(strategyPanel);
body.appendChild(combatPanel);
body.appendChild(eduPanel);
body.appendChild(phaseBar);
body.appendChild(logPanel);
logPanel.appendChild(logEntries);
body.appendChild(toastContainer);
const opsBtn = new Element('button');
opsBtn.className = 'ops-btn';
const saveBtn = new Element('button');
saveBtn.className = 'save-btn';
const resetBtn = new Element('button');
resetBtn.className = 'reset-btn';
actionPanel._queryMap.set('.ops-btn', [opsBtn]);
actionPanel._queryMap.set('.save-btn, .reset-btn', [saveBtn, resetBtn]);
const resourceIds = [
'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact',
'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony'
];
for (const id of resourceIds) {
const wrapper = new Element('div');
wrapper.className = 'res';
const value = register(new Element('div', id));
wrapper.appendChild(value);
body.appendChild(wrapper);
}
return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } };
}
function loadBeacon({ includeRender = false } = {}) {
const { document, window } = buildDom();
const storage = new Map();
const timerQueue = [];
const context = {
console,
Math,
Date,
document,
window,
navigator: { userAgent: 'node' },
location: { reload() {} },
confirm: () => false,
requestAnimationFrame: (fn) => fn(),
setTimeout: (fn) => {
timerQueue.push(fn);
return timerQueue.length;
},
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() {}, startBattle() {} },
Sound: undefined,
};
vm.createContext(context);
const files = ['js/data.js', 'js/utils.js', 'js/engine.js'];
if (includeRender) files.push('js/render.js');
files.push('js/dismantle.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,
Dismantle,
tick,
renderAlignment: typeof renderAlignment === 'function' ? renderAlignment : null,
saveGame: typeof saveGame === 'function' ? saveGame : null,
loadGame: typeof loadGame === 'function' ? loadGame : null
};`, context);
return {
context,
document,
...context.__exports,
};
}
test('tick offers the Unbuilding instead of ending the game immediately', () => {
const { G, Dismantle, tick, document } = loadBeacon();
G.totalCode = 1_000_000_000;
G.totalRescues = 100_000;
G.phase = 6;
G.pactFlag = 1;
G.harmony = 60;
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
tick();
assert.equal(typeof Dismantle, 'object');
assert.equal(G.dismantleTriggered, true);
assert.equal(G.beaconEnding, false);
assert.equal(G.running, true);
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
test('renderAlignment does not wipe the Unbuilding prompt after it is offered', () => {
const { G, tick, renderAlignment, document } = loadBeacon({ includeRender: true });
G.totalCode = 1_000_000_000;
G.totalRescues = 100_000;
G.phase = 6;
G.pactFlag = 1;
G.harmony = 60;
G.beaconEnding = false;
G.running = true;
G.activeProjects = [];
G.completedProjects = [];
tick();
renderAlignment();
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
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('stage five lasts long enough to dissolve every resource card', () => {
const { G, Dismantle } = loadBeacon();
Dismantle.begin();
Dismantle.stage = 5;
Dismantle.tickTimer = 0;
Dismantle.resourceSequence = Dismantle.getResourceList();
Dismantle.resourceIndex = 0;
Dismantle.resourceTimer = 0;
G.dismantleActive = true;
G.dismantleStage = 5;
for (let i = 0; i < 63; i++) Dismantle.tick(0.1);
assert.equal(Dismantle.resourceIndex, Dismantle.resourceSequence.length);
});
test('save/load restores partial stage-five dissolve progress', () => {
const { G, Dismantle, saveGame, loadGame, document } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.dismantleTriggered = true;
G.dismantleActive = true;
G.dismantleStage = 5;
G.dismantleComplete = false;
G.dismantleResourceIndex = 4;
G.dismantleResourceTimer = 4.05;
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = false;
G.dismantleResourceIndex = 0;
G.dismantleResourceTimer = 0;
Dismantle.resourceIndex = 0;
Dismantle.resourceTimer = 0;
assert.equal(loadGame(), true);
Dismantle.restore();
assert.equal(Dismantle.resourceIndex, 4);
assert.equal(document.getElementById('r-harmony').closest('.res').style.display, 'none');
assert.equal(document.getElementById('r-ops').closest('.res').style.display, 'none');
assert.notEqual(document.getElementById('r-rescues').closest('.res').style.display, 'none');
});
test('deferring the Unbuilding clears the prompt and allows it to return later', () => {
const { G, Dismantle, document } = loadBeacon();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
Dismantle.defer();
assert.equal(G.dismantleTriggered, false);
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
Dismantle.deferUntilAt = Date.now() + 1000;
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false);
Dismantle.deferUntilAt = Date.now() - 1;
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
});
test('defer cooldown survives save and reload', () => {
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
Dismantle.checkTrigger();
Dismantle.defer();
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
Dismantle.triggered = false;
Dismantle.deferUntilAt = 0;
assert.equal(loadGame(), true);
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false);
});
test('save and load preserve dismantle progress', () => {
const { G, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.dismantleTriggered = true;
G.dismantleActive = true;
G.dismantleStage = 4;
G.dismantleComplete = false;
saveGame();
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleStage = 0;
G.dismantleComplete = true;
assert.equal(loadGame(), true);
assert.equal(G.dismantleTriggered, true);
assert.equal(G.dismantleActive, true);
assert.equal(G.dismantleStage, 4);
assert.equal(G.dismantleComplete, false);
});
test('restore re-renders an offered but not-yet-started Unbuilding prompt', () => {
const { G, Dismantle, document } = loadBeacon();
G.dismantleTriggered = true;
G.dismantleActive = false;
G.dismantleComplete = false;
Dismantle.triggered = true;
Dismantle.restore();
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
});
test('defer cooldown persists after save/load when dismantleTriggered is false', () => {
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
G.startedAt = Date.now();
G.totalCode = 1_000_000_000;
G.phase = 6;
G.pactFlag = 1;
// Trigger the Unbuilding
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
// Defer it
Dismantle.defer();
assert.equal(G.dismantleTriggered, false);
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now());
// Save the game
saveGame();
// Clear state (simulate reload)
G.dismantleTriggered = false;
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleDeferUntilAt = 0;
Dismantle.triggered = false;
Dismantle.deferUntilAt = 0;
// Load the game
assert.equal(loadGame(), true);
Dismantle.restore(); // Call restore to restore defer cooldown
// The cooldown should be restored
assert.ok((Dismantle.deferUntilAt || 0) > Date.now(), 'deferUntilAt should be restored');
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now(), 'G.dismantleDeferUntilAt should be restored');
// checkTrigger should not trigger because cooldown is active
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false, 'dismantleTriggered should remain false during cooldown');
});