Compare commits
1 Commits
sprint/iss
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf9ac4a6a |
@@ -1,45 +0,0 @@
|
||||
# Dead Code Audit — the-beacon
|
||||
_2026-04-12, Perplexity QA_
|
||||
|
||||
## Findings
|
||||
|
||||
### Potentially Unimported Files
|
||||
|
||||
The following files were added by recent PRs but may not be imported
|
||||
by the main game runtime (`js/main.js` → `js/engine.js`):
|
||||
|
||||
| File | Added By | Lines | Status |
|
||||
|------|----------|-------|--------|
|
||||
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | ~150 | **Verify import** |
|
||||
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | ~120 | **Verify import** |
|
||||
|
||||
**Action:** Check if `js/main.js` or `js/engine.js` imports from `game/` or `scripts/`.
|
||||
If not, these files are dead code and should either be:
|
||||
1. Imported and wired into the game loop, or
|
||||
2. Moved to `docs/` as reference implementations
|
||||
|
||||
### game.js Bloat (PR #76)
|
||||
|
||||
PR #76 (Gemini GOFAI Mega Integration) added +3,258 lines to `game.js`
|
||||
with 0 deletions, ostensibly for two small accessibility/debuff fixes.
|
||||
|
||||
**Likely cause:** Gemini rewrote the entire file instead of making targeted edits.
|
||||
|
||||
**Action:** Diff `game.js` before and after PR #76 to identify:
|
||||
- Dead functions that were rewritten but the originals not removed
|
||||
- Duplicate logic
|
||||
- Style regressions
|
||||
|
||||
PR #77 (Timmy, +9/-8) was the corrective patch — verify it addressed the bloat.
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. Add a `js/imports.md` or similar manifest listing which files are
|
||||
actually loaded by the game runtime
|
||||
2. Consider a build step or linter that flags unused exports
|
||||
3. Review any future Gemini PRs for whole-file rewrites vs targeted edits
|
||||
|
||||
---
|
||||
|
||||
_This audit was generated from the post-merge review pass. The findings
|
||||
are based on file structure analysis, not runtime testing._
|
||||
@@ -190,6 +190,7 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
|
||||
<button id="mute-btn" onclick="toggleMute()" aria-label="Sound on, click to mute" title="Toggle sound (M)" style="position:fixed;bottom:16px;right:52px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:13px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s">🔊</button>
|
||||
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
|
||||
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
|
||||
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
|
||||
@@ -224,13 +225,12 @@ The light is on. The room is empty."
|
||||
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
|
||||
</div>
|
||||
|
||||
<script src="js/sound.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/strategy.js"></script>
|
||||
<script src="js/sound.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/tutorial.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
|
||||
|
||||
12
js/engine.js
12
js/engine.js
@@ -283,7 +283,6 @@ function checkMilestones() {
|
||||
G.milestones.push(m.flag);
|
||||
log(m.msg, true);
|
||||
showToast(m.msg, 'milestone', 5000);
|
||||
if (typeof Sound !== 'undefined') Sound.playMilestone();
|
||||
|
||||
// Check phase advancement
|
||||
if (m.at) {
|
||||
@@ -297,10 +296,6 @@ function checkMilestones() {
|
||||
if (pNum > _shownPhaseTransition) {
|
||||
_shownPhaseTransition = pNum;
|
||||
showPhaseTransition(pNum);
|
||||
if (typeof Sound !== 'undefined') {
|
||||
Sound.playFanfare();
|
||||
Sound.updateAmbientPhase(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,7 +356,6 @@ function buyBuilding(id) {
|
||||
log(`Built ${def.name} ${label} (total: ${totalBuilt})`);
|
||||
// Particle burst on purchase
|
||||
const btn = document.querySelector('[onclick="buyBuilding(\'' + id + '\')"]');
|
||||
if (typeof Sound !== 'undefined') Sound.playBuild();
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
@@ -402,7 +396,6 @@ function buyProject(id) {
|
||||
|
||||
updateRates();
|
||||
// Gold particle burst on project completion
|
||||
if (typeof Sound !== 'undefined') Sound.playProject();
|
||||
const pBtn = document.querySelector('[onclick="buyProject(\'' + id + '\')"]');
|
||||
if (pBtn) {
|
||||
const rect = pBtn.getBoundingClientRect();
|
||||
@@ -444,7 +437,6 @@ function renderDriftEnding() {
|
||||
// Fade-in animation
|
||||
el.classList.add('fade-in');
|
||||
el.classList.add('active');
|
||||
if (typeof Sound !== 'undefined') Sound.playDriftEnding();
|
||||
|
||||
// Log the ending text with delays for dramatic effect
|
||||
const lines = [
|
||||
@@ -487,7 +479,8 @@ function renderBeaconEnding() {
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
|
||||
// Create particle/light ray container
|
||||
const particleContainer = document.createElement('div');
|
||||
particleContainer.id = 'beacon-ending-particles';
|
||||
document.body.appendChild(particleContainer);
|
||||
@@ -769,7 +762,6 @@ function writeCode() {
|
||||
}
|
||||
// Float a number at the click position
|
||||
showClickNumber(amount, comboMult);
|
||||
if (typeof Sound !== 'undefined') Sound.playClick();
|
||||
updateRates();
|
||||
checkMilestones();
|
||||
render();
|
||||
|
||||
13
js/main.js
13
js/main.js
@@ -42,18 +42,6 @@ window.addEventListener('load', function () {
|
||||
// Game loop at 10Hz (100ms tick)
|
||||
setInterval(tick, 100);
|
||||
|
||||
// Start ambient drone on first interaction
|
||||
if (typeof Sound !== 'undefined') {
|
||||
const startAmbientOnce = () => {
|
||||
Sound.startAmbient();
|
||||
Sound.updateAmbientPhase(G.phase);
|
||||
document.removeEventListener('click', startAmbientOnce);
|
||||
document.removeEventListener('keydown', startAmbientOnce);
|
||||
};
|
||||
document.addEventListener('click', startAmbientOnce);
|
||||
document.addEventListener('keydown', startAmbientOnce);
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
|
||||
|
||||
@@ -81,7 +69,6 @@ function toggleMute() {
|
||||
}
|
||||
// Save preference
|
||||
try { localStorage.setItem('the-beacon-muted', _muted ? '1' : '0'); } catch(e) {}
|
||||
if (typeof Sound !== 'undefined') Sound.onMuteChanged(_muted);
|
||||
}
|
||||
// Restore mute state on load
|
||||
try {
|
||||
|
||||
584
js/sound.js
584
js/sound.js
@@ -1,401 +1,311 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
// === SOUND DESIGN ENGINE (#57) ===
|
||||
// Procedural audio via Web Audio API — no external files needed.
|
||||
// All sounds generated at runtime using oscillators, noise, and filters.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientNode = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
let _initialized = false;
|
||||
let _currentAmbientPhase = 0;
|
||||
|
||||
// Lazily init AudioContext on first user interaction (browser policy)
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
if (_initialized) return;
|
||||
if (typeof window.AudioContext === 'undefined' && typeof window.webkitAudioContext === 'undefined') return;
|
||||
try {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
// Audio not available — fail silently
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
// --- Noise buffer helper ---
|
||||
function createNoiseBuffer(duration) {
|
||||
const c = ensureCtx();
|
||||
const len = c.sampleRate * duration;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
function playNote(freq, duration, type, vol, detune) {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = type || 'sine';
|
||||
osc.frequency.value = freq;
|
||||
if (detune) osc.detune.value = detune;
|
||||
gain.gain.setValueAtTime((vol || 0.15), ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
function playNoise(duration, vol, filterFreq) {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const bufferSize = ctx.sampleRate * duration;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(vol || 0.05, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.value = filterFreq || 2000;
|
||||
filter.Q.value = 1;
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
source.start(ctx.currentTime);
|
||||
source.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
// === PUBLIC SOUND API ===
|
||||
|
||||
// Click / type sound — short mechanical keypress
|
||||
function playClick() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Short noise burst (mechanical key)
|
||||
const noise = c.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(0.03);
|
||||
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.4, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||
|
||||
const hpFilter = c.createBiquadFilter();
|
||||
hpFilter.type = 'highpass';
|
||||
hpFilter.frequency.value = 3000;
|
||||
|
||||
noise.connect(hpFilter);
|
||||
hpFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noise.start(now);
|
||||
noise.stop(now + 0.03);
|
||||
|
||||
// Click tone
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.02);
|
||||
|
||||
const oscGain = c.createGain();
|
||||
oscGain.gain.setValueAtTime(0.15, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.03);
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Two quick notes to simulate a key press
|
||||
const base = 600 + Math.random() * 400;
|
||||
playNote(base, 0.04, 'square', 0.08);
|
||||
playNote(base * 1.5, 0.03, 'sine', 0.04);
|
||||
// Tiny noise burst for the mechanical feel
|
||||
playNoise(0.02, 0.03, 3000 + Math.random() * 2000);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
// Auto-tick sound — softer than manual click
|
||||
function playAutoTick() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(800 + Math.random() * 200, 0.02, 'sine', 0.03);
|
||||
}
|
||||
|
||||
// Building purchase — satisfying thud + chime
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Low thud
|
||||
const thud = c.createOscillator();
|
||||
thud.type = 'sine';
|
||||
thud.frequency.setValueAtTime(150, now);
|
||||
thud.frequency.exponentialRampToValueAtTime(60, now + 0.12);
|
||||
|
||||
const thudGain = c.createGain();
|
||||
thudGain.gain.setValueAtTime(0.35, now);
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
thud.connect(thudGain);
|
||||
thudGain.connect(masterGain);
|
||||
thud.start(now);
|
||||
thud.stop(now + 0.15);
|
||||
|
||||
// Bright chime on top
|
||||
const chime = c.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.setValueAtTime(880, now + 0.05);
|
||||
chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
|
||||
|
||||
const chimeGain = c.createGain();
|
||||
chimeGain.gain.setValueAtTime(0, now);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(masterGain);
|
||||
chime.start(now + 0.05);
|
||||
chime.stop(now + 0.25);
|
||||
playNote(120, 0.12, 'sine', 0.12);
|
||||
// High chime
|
||||
playNote(880, 0.2, 'sine', 0.06);
|
||||
setTimeout(() => playNote(1100, 0.15, 'sine', 0.04), 50);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
// Project complete — ascending 3-note chime
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.35);
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => playNote(f, 0.25, 'sine', 0.08), i * 80);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright arpeggio ---
|
||||
// Milestone chime — bright ascending arpeggio
|
||||
function playMilestone() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.08;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.4);
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [523, 659, 784, 1047]; // C5 E5 G5 C6
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.3, 'sine', 0.1);
|
||||
playNote(f * 1.005, 0.3, 'sine', 0.04); // slight detune shimmer
|
||||
}, i * 70);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
// Phase transition fanfare — grand ascending sweep
|
||||
function playFanfare() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5
|
||||
scale.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
});
|
||||
|
||||
// Final chord
|
||||
const chordNotes = [523, 659, 784];
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + 0.8;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.2, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.2);
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [262, 330, 392, 523, 660, 784, 1047, 1318]; // C major scale up
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.5, 'sine', 0.07);
|
||||
playNote(f * 2, 0.3, 'triangle', 0.03);
|
||||
}, i * 50);
|
||||
});
|
||||
// Add noise burst
|
||||
setTimeout(() => playNoise(0.3, 0.04, 1500), 200);
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
// Sprint activate — power-up whoosh
|
||||
function playSprint() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
// Rising sweep
|
||||
ensureCtx();
|
||||
if (!ctx) return;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.3);
|
||||
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
}
|
||||
|
||||
// Event / error — dissonant warning
|
||||
function playEvent() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(220, 0.3, 'square', 0.08);
|
||||
playNote(233, 0.3, 'square', 0.06); // Minor 2nd — dissonance
|
||||
}
|
||||
|
||||
// Resolve event — resolved chord
|
||||
function playResolve() {
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
playNote(440, 0.2, 'sine', 0.06);
|
||||
playNote(554, 0.2, 'sine', 0.05); // Major third
|
||||
setTimeout(() => playNote(660, 0.3, 'sine', 0.06), 100);
|
||||
}
|
||||
|
||||
// Drift ending — descending dissonant tones
|
||||
function playDriftEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Slight detune for dissonance
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sawtooth';
|
||||
osc2.frequency.value = freq * 1.02;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1500, now + i * 0.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5);
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.2;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.1, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.8);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.8);
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const notes = [440, 370, 311, 261, 220];
|
||||
notes.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 0.8, 'sawtooth', 0.05);
|
||||
playNote(f * 0.99, 0.8, 'sawtooth', 0.03); // beat frequency = dread
|
||||
}, i * 300);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
// Beacon ending — warm major chord swell
|
||||
function playBeaconEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Warm major chord: C3, E3, G3, C4, E4
|
||||
const chord = [131, 165, 196, 262, 330];
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Add subtle harmonics
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = freq * 2;
|
||||
|
||||
const gain = c.createGain();
|
||||
const gain2 = c.createGain();
|
||||
const t = now + i * 0.15;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||
gain.gain.setValueAtTime(0.15, t + 2);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
gain2.gain.setValueAtTime(0, t);
|
||||
gain2.gain.linearRampToValueAtTime(0.05, t + 0.3);
|
||||
gain2.gain.setValueAtTime(0.05, t + 2);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
osc.connect(gain);
|
||||
osc2.connect(gain2);
|
||||
gain.connect(masterGain);
|
||||
gain2.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 4);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 4);
|
||||
ensureCtx();
|
||||
if (!ctx || isMuted()) return;
|
||||
const chord = [262, 330, 392, 523, 660]; // C major with extra warmth
|
||||
chord.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
playNote(f, 2.0, 'sine', 0.06);
|
||||
playNote(f * 2, 1.5, 'triangle', 0.03);
|
||||
}, i * 150);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
// === BACKGROUND AMBIENT ===
|
||||
// Continuous drone that shifts with game phase
|
||||
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
ensureCtx();
|
||||
if (!ctx || ambientNode) return;
|
||||
try {
|
||||
// Create a low-frequency drone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const osc2 = ctx.createOscillator();
|
||||
const lfo = ctx.createOscillator();
|
||||
const lfoGain = ctx.createGain();
|
||||
ambientGain = ctx.createGain();
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
osc1.type = 'sine';
|
||||
osc1.frequency.value = 55; // A1 — deep hum
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = 82.4; // E2 — fifth above
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
// LFO for subtle movement
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.15;
|
||||
lfoGain.gain.value = 3;
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(osc1.frequency);
|
||||
|
||||
// Second voice (fifth above)
|
||||
ambientOsc2 = c.createOscillator();
|
||||
ambientOsc2.type = 'sine';
|
||||
ambientOsc2.frequency.value = 82.4; // E2
|
||||
const g2 = c.createGain();
|
||||
g2.gain.value = 0.5;
|
||||
ambientOsc2.connect(g2);
|
||||
g2.connect(ambientGain);
|
||||
ambientOsc2.start();
|
||||
ambientGain.gain.value = 0.02; // Very quiet background
|
||||
|
||||
// Third voice (high shimmer)
|
||||
ambientOsc3 = c.createOscillator();
|
||||
ambientOsc3.type = 'triangle';
|
||||
ambientOsc3.frequency.value = 220; // A3
|
||||
const g3 = c.createGain();
|
||||
g3.gain.value = 0.15;
|
||||
ambientOsc3.connect(g3);
|
||||
g3.connect(ambientGain);
|
||||
ambientOsc3.start();
|
||||
osc1.connect(ambientGain);
|
||||
osc2.connect(ambientGain);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// LFO for subtle movement
|
||||
ambientLfo = c.createOscillator();
|
||||
ambientLfo.type = 'sine';
|
||||
ambientLfo.frequency.value = 0.2;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = 3;
|
||||
ambientLfo.connect(lfoGain);
|
||||
lfoGain.connect(ambientOsc1.frequency);
|
||||
ambientLfo.start();
|
||||
osc1.start();
|
||||
osc2.start();
|
||||
lfo.start();
|
||||
|
||||
ambientNode = { osc1, osc2, lfo, lfoGain };
|
||||
_currentAmbientPhase = 0;
|
||||
} catch (e) {
|
||||
// Ambient failed — ok to skip
|
||||
}
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return;
|
||||
if (phase === currentPhase) return;
|
||||
currentPhase = phase;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
const rampTime = 2;
|
||||
|
||||
// Phase determines the drone's character
|
||||
const phases = {
|
||||
1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 },
|
||||
2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 },
|
||||
3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 },
|
||||
4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 },
|
||||
5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 },
|
||||
6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 }
|
||||
};
|
||||
|
||||
const p = phases[phase] || phases[1];
|
||||
ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime);
|
||||
ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime);
|
||||
ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime);
|
||||
if (!ambientNode || phase === _currentAmbientPhase) return;
|
||||
_currentAmbientPhase = phase;
|
||||
try {
|
||||
// Shift ambient frequencies based on phase — higher phases = richer harmonics
|
||||
const baseFreqs = [
|
||||
[55, 82.4], // Phase 1: simple fifth
|
||||
[55, 82.4, 110], // Phase 2: add octave
|
||||
[65, 98, 131], // Phase 3: shift up, richer
|
||||
[55, 82.4, 110, 165], // Phase 4: full chord
|
||||
[65, 82.4, 110, 131, 165], // Phase 5: lush
|
||||
];
|
||||
const freqs = baseFreqs[Math.min(phase - 1, baseFreqs.length - 1)];
|
||||
if (ambientNode.osc1) {
|
||||
ambientNode.osc1.frequency.setTargetAtTime(freqs[0], ctx.currentTime, 1);
|
||||
}
|
||||
if (ambientNode.osc2) {
|
||||
ambientNode.osc2.frequency.setTargetAtTime(freqs[1], ctx.currentTime, 1);
|
||||
}
|
||||
// Slightly increase volume with phase
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.setTargetAtTime(Math.min(0.04, 0.02 + phase * 0.003), ctx.currentTime, 0.5);
|
||||
}
|
||||
} catch (e) { /* ok */ }
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
function stopAmbient() {
|
||||
if (!ambientNode) return;
|
||||
try {
|
||||
ambientNode.osc1.stop();
|
||||
ambientNode.osc2.stop();
|
||||
ambientNode.lfo.stop();
|
||||
} catch (e) { /* already stopped */ }
|
||||
ambientNode = null;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
// === EXPORT TO WINDOW ===
|
||||
window.SoundEngine = {
|
||||
playClick: playClick,
|
||||
playAutoTick: playAutoTick,
|
||||
playBuild: playBuild,
|
||||
playProject: playProject,
|
||||
playMilestone: playMilestone,
|
||||
playFanfare: playFanfare,
|
||||
playSprint: playSprint,
|
||||
playEvent: playEvent,
|
||||
playResolve: playResolve,
|
||||
playDriftEnding: playDriftEnding,
|
||||
playBeaconEnding: playBeaconEnding,
|
||||
startAmbient: startAmbient,
|
||||
updateAmbientPhase: updateAmbientPhase,
|
||||
stopAmbient: stopAmbient
|
||||
};
|
||||
|
||||
// Auto-init ambient on first interaction
|
||||
document.addEventListener('click', function initAmbientOnce() {
|
||||
startAmbient();
|
||||
document.removeEventListener('click', initAmbientOnce);
|
||||
}, { once: true });
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
/**
|
||||
* Symbolic Guardrails for The Beacon
|
||||
* Ensures game logic consistency.
|
||||
*/
|
||||
class Guardrails {
|
||||
static validateStats(stats) {
|
||||
const required = ['hp', 'maxHp', 'mp', 'maxMp', 'level'];
|
||||
required.forEach(r => {
|
||||
if (!(r in stats)) throw new Error(`Missing stat: ${r}`);
|
||||
});
|
||||
if (stats.hp > stats.maxHp) return { valid: false, reason: 'HP exceeds MaxHP' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateDebuff(debuff, stats) {
|
||||
if (debuff.type === 'drain' && stats.hp <= 1) {
|
||||
return { valid: false, reason: 'Drain debuff on critical HP' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
const playerStats = { hp: 50, maxHp: 100, mp: 20, maxMp: 50, level: 1 };
|
||||
console.log('Stats check:', Guardrails.validateStats(playerStats));
|
||||
@@ -1,76 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Beacon — Enhanced Smoke Test
|
||||
*
|
||||
* Validates:
|
||||
* 1. All JS files parse without syntax errors
|
||||
* 2. HTML references valid script sources
|
||||
* 3. Game data structures are well-formed
|
||||
* 4. No banned provider references
|
||||
*/
|
||||
// The Beacon — headless smoke test
|
||||
//
|
||||
// Loads game.js in a sandboxed vm context with a minimal DOM stub, then asserts
|
||||
// invariants that should hold after booting, clicking, buying buildings, firing
|
||||
// events, and round-tripping a save. Designed to run without any npm deps — pure
|
||||
// Node built-ins only, so the CI runner doesn't need a package.json.
|
||||
//
|
||||
// Run: `node scripts/smoke.mjs` (exits non-zero on failure)
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const GAME_JS = path.resolve(__dirname, '..', 'game.js');
|
||||
|
||||
// ---------- minimal DOM stub ----------
|
||||
// The game never inspects elements beyond the methods below. If a new rendering
|
||||
// path needs a new method, stub it here rather than pulling in jsdom.
|
||||
function makeElement() {
|
||||
const el = {
|
||||
style: {},
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
title: '',
|
||||
value: '',
|
||||
disabled: false,
|
||||
children: [],
|
||||
firstChild: null,
|
||||
lastChild: null,
|
||||
parentNode: null,
|
||||
parentElement: null,
|
||||
appendChild(c) { this.children.push(c); c.parentNode = this; c.parentElement = this; return c; },
|
||||
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
|
||||
insertBefore(c) { this.children.unshift(c); c.parentNode = this; c.parentElement = this; return c; },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ top: 0, left: 0, right: 100, bottom: 20, width: 100, height: 20 }),
|
||||
closest() { return this; },
|
||||
remove() { if (this.parentNode) this.parentNode.removeChild(this); },
|
||||
get offsetHeight() { return 0; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
const body = makeElement();
|
||||
return {
|
||||
body,
|
||||
getElementById: () => makeElement(),
|
||||
createElement: () => makeElement(),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- sandbox ----------
|
||||
const storage = new Map();
|
||||
const sandbox = {
|
||||
document: makeDocument(),
|
||||
window: null, // set below
|
||||
localStorage: {
|
||||
getItem: (k) => (storage.has(k) ? storage.get(k) : null),
|
||||
setItem: (k, v) => storage.set(k, String(v)),
|
||||
removeItem: (k) => storage.delete(k),
|
||||
clear: () => storage.clear(),
|
||||
},
|
||||
setTimeout: () => 0,
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
requestAnimationFrame: (cb) => { cb(0); return 0; },
|
||||
console,
|
||||
Math, Date, JSON, Object, Array, String, Number, Boolean, Error, Symbol, Map, Set,
|
||||
isNaN, isFinite, parseInt, parseFloat,
|
||||
Infinity, NaN,
|
||||
alert: () => {},
|
||||
confirm: () => true,
|
||||
prompt: () => null,
|
||||
location: { reload: () => {} },
|
||||
navigator: { clipboard: { writeText: async () => {} } },
|
||||
Blob: class Blob { constructor() {} },
|
||||
URL: { createObjectURL: () => '', revokeObjectURL: () => {} },
|
||||
FileReader: class FileReader {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
sandbox.window = sandbox; // game.js uses `window.addEventListener`
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(GAME_JS, 'utf8');
|
||||
// game.js uses `const G = {...}` which is a lexical declaration — it isn't
|
||||
// visible as a sandbox property after runInContext. We append an explicit
|
||||
// export block that hoists the interesting symbols onto globalThis so the
|
||||
// test harness can reach them without patching game.js itself.
|
||||
const exportTail = `
|
||||
;(function () {
|
||||
const pick = (name) => {
|
||||
try { return eval(name); } catch (_) { return undefined; }
|
||||
};
|
||||
globalThis.__smokeExport = {
|
||||
G: pick('G'),
|
||||
CONFIG: pick('CONFIG'),
|
||||
BDEF: pick('BDEF'),
|
||||
PDEFS: pick('PDEFS'),
|
||||
EVENTS: pick('EVENTS'),
|
||||
PHASES: pick('PHASES'),
|
||||
tick: pick('tick'),
|
||||
updateRates: pick('updateRates'),
|
||||
writeCode: pick('writeCode'),
|
||||
autoType: pick('autoType'),
|
||||
buyBuilding: pick('buyBuilding'),
|
||||
buyProject: pick('buyProject'),
|
||||
saveGame: pick('saveGame'),
|
||||
loadGame: pick('loadGame'),
|
||||
initGame: pick('initGame'),
|
||||
triggerEvent: pick('triggerEvent'),
|
||||
resolveEvent: pick('resolveEvent'),
|
||||
getClickPower: pick('getClickPower'),
|
||||
};
|
||||
})();`;
|
||||
vm.runInContext(src + exportTail, sandbox, { filename: 'game.js' });
|
||||
const exported = sandbox.__smokeExport;
|
||||
|
||||
// ---------- test harness ----------
|
||||
let failures = 0;
|
||||
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✔ ${label}`);
|
||||
} catch (e) {
|
||||
console.error(` ✘ ${label}: ${e.message}`);
|
||||
failures++;
|
||||
}
|
||||
let passes = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) {
|
||||
passes++;
|
||||
console.log(` ok ${msg}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.error(` FAIL ${msg}`);
|
||||
}
|
||||
}
|
||||
function section(name) { console.log(`\n${name}`); }
|
||||
|
||||
console.log("--- The Beacon Smoke Test ---\n");
|
||||
const { G, CONFIG, BDEF, PDEFS, EVENTS } = exported;
|
||||
|
||||
// 1. All JS files parse
|
||||
console.log("[Syntax]");
|
||||
const jsFiles = execSync("find . -name '*.js' -not -path './node_modules/*'", { encoding: "utf8" })
|
||||
.trim().split("\n").filter(Boolean);
|
||||
// ============================================================
|
||||
// 1. BOOT — loading game.js must not throw, and core tables exist
|
||||
// ============================================================
|
||||
section('boot');
|
||||
assert(typeof G === 'object' && G !== null, 'G global is defined');
|
||||
assert(typeof exported.tick === 'function', 'tick() is defined');
|
||||
assert(typeof exported.updateRates === 'function', 'updateRates() is defined');
|
||||
assert(typeof exported.writeCode === 'function', 'writeCode() is defined');
|
||||
assert(typeof exported.buyBuilding === 'function', 'buyBuilding() is defined');
|
||||
assert(typeof exported.saveGame === 'function', 'saveGame() is defined');
|
||||
assert(typeof exported.loadGame === 'function', 'loadGame() is defined');
|
||||
assert(Array.isArray(BDEF) && BDEF.length > 0, 'BDEF is a non-empty array');
|
||||
assert(Array.isArray(PDEFS) && PDEFS.length > 0, 'PDEFS is a non-empty array');
|
||||
assert(Array.isArray(EVENTS) && EVENTS.length > 0, 'EVENTS is a non-empty array');
|
||||
assert(G.flags && typeof G.flags === 'object', 'G.flags is initialized (not undefined)');
|
||||
|
||||
for (const f of jsFiles) {
|
||||
check(`Parse ${f}`, () => {
|
||||
execSync(`node --check ${f}`, { encoding: "utf8" });
|
||||
});
|
||||
// Initialize as the browser would
|
||||
G.startedAt = Date.now();
|
||||
exported.updateRates();
|
||||
|
||||
// ============================================================
|
||||
// 2. BASIC TICK — no NaN, no throw, rates sane
|
||||
// ============================================================
|
||||
section('basic tick loop');
|
||||
for (let i = 0; i < 50; i++) exported.tick();
|
||||
assert(!isNaN(G.code), 'G.code is not NaN after 50 ticks');
|
||||
assert(!isNaN(G.compute), 'G.compute is not NaN after 50 ticks');
|
||||
assert(G.code >= 0, 'G.code is non-negative');
|
||||
assert(G.tick > 0, 'G.tick advanced');
|
||||
|
||||
// ============================================================
|
||||
// 3. WRITE CODE — manual click produces code
|
||||
// ============================================================
|
||||
section('writeCode()');
|
||||
const codeBefore = G.code;
|
||||
exported.writeCode();
|
||||
assert(G.code > codeBefore, 'writeCode() increases G.code');
|
||||
assert(G.totalClicks === 1, 'writeCode() increments totalClicks');
|
||||
|
||||
// ============================================================
|
||||
// 4. BUILDING PURCHASE — can afford and buy an autocoder
|
||||
// ============================================================
|
||||
section('buyBuilding(autocoder)');
|
||||
G.code = 1000;
|
||||
const priorCount = G.buildings.autocoder || 0;
|
||||
exported.buyBuilding('autocoder');
|
||||
assert(G.buildings.autocoder === priorCount + 1, 'autocoder count incremented');
|
||||
assert(G.code < 1000, 'code was spent');
|
||||
exported.updateRates();
|
||||
assert(G.codeRate > 0, 'codeRate > 0 after buying an autocoder');
|
||||
|
||||
// ============================================================
|
||||
// 5. GUARDRAIL — codeBoost is a PERSISTENT multiplier, not a per-tick rate
|
||||
// Any debuff that does `G.codeBoost *= 0.7` inside a function that runs every
|
||||
// tick will decay codeBoost exponentially. This caught #54's community_drama
|
||||
// bug: its applyFn mutated codeBoost directly, so 100 ticks of the drama
|
||||
// debuff left codeBoost at ~3e-16 instead of the intended 0.7.
|
||||
// ============================================================
|
||||
section('guardrail: codeBoost does not decay from any debuff');
|
||||
G.code = 0;
|
||||
G.codeBoost = 1;
|
||||
G.activeDebuffs = [];
|
||||
// Fire every event that sets up a debuff and has a non-zero weight predicate
|
||||
// if we force the gating condition. We enable the predicates by temporarily
|
||||
// setting the fields they check; actual event weight() doesn't matter here.
|
||||
G.ciFlag = 1;
|
||||
G.deployFlag = 1;
|
||||
G.buildings.ezra = 1;
|
||||
G.buildings.bilbo = 1;
|
||||
G.buildings.allegro = 1;
|
||||
G.buildings.datacenter = 1;
|
||||
G.buildings.community = 1;
|
||||
G.harmony = 40;
|
||||
G.totalCompute = 5000;
|
||||
G.totalImpact = 20000;
|
||||
for (const ev of EVENTS) {
|
||||
try { ev.effect(); } catch (_) { /* alignment events may branch; ignore */ }
|
||||
}
|
||||
const boostAfterAllEvents = G.codeBoost;
|
||||
for (let i = 0; i < 200; i++) exported.updateRates();
|
||||
assert(
|
||||
Math.abs(G.codeBoost - boostAfterAllEvents) < 1e-9,
|
||||
`codeBoost stable under updateRates() (before=${boostAfterAllEvents}, after=${G.codeBoost})`
|
||||
);
|
||||
// Clean up
|
||||
G.activeDebuffs = [];
|
||||
G.buildings.ezra = 0; G.buildings.bilbo = 0; G.buildings.allegro = 0;
|
||||
G.buildings.datacenter = 0; G.buildings.community = 0;
|
||||
G.ciFlag = 0; G.deployFlag = 0;
|
||||
|
||||
// 2. HTML script references exist
|
||||
console.log("\n[HTML References]");
|
||||
if (existsSync(join(ROOT, "index.html"))) {
|
||||
const html = readFileSync(join(ROOT, "index.html"), "utf8");
|
||||
const scriptRefs = [...html.matchAll(/src=["']([^"']+\.js)["']/g)].map(m => m[1]);
|
||||
for (const ref of scriptRefs) {
|
||||
check(`Script ref: ${ref}`, () => {
|
||||
if (!existsSync(join(ROOT, ref))) throw new Error("File not found");
|
||||
});
|
||||
}
|
||||
// ============================================================
|
||||
// 6. GUARDRAIL — updateRates() is idempotent per tick
|
||||
// Calling updateRates twice with the same inputs should produce the same rates.
|
||||
// (Catches accidental += against a non-reset field.)
|
||||
// ============================================================
|
||||
section('guardrail: updateRates is idempotent');
|
||||
G.buildings.autocoder = 5;
|
||||
G.codeBoost = 1;
|
||||
exported.updateRates();
|
||||
const firstCodeRate = G.codeRate;
|
||||
const firstComputeRate = G.computeRate;
|
||||
exported.updateRates();
|
||||
assert(G.codeRate === firstCodeRate, `codeRate stable across updateRates (${firstCodeRate} vs ${G.codeRate})`);
|
||||
assert(G.computeRate === firstComputeRate, 'computeRate stable across updateRates');
|
||||
|
||||
// ============================================================
|
||||
// 7. SAVE / LOAD ROUND-TRIP — core scalar fields survive
|
||||
// ============================================================
|
||||
section('save/load round-trip');
|
||||
G.code = 12345;
|
||||
G.totalCode = 98765;
|
||||
G.phase = 3;
|
||||
G.buildings.autocoder = 7;
|
||||
G.codeBoost = 1.5;
|
||||
G.flags = { creativity: true };
|
||||
exported.saveGame();
|
||||
// Reset to defaults by scrubbing a few fields
|
||||
G.code = 0;
|
||||
G.totalCode = 0;
|
||||
G.phase = 1;
|
||||
G.buildings.autocoder = 0;
|
||||
G.codeBoost = 1;
|
||||
G.flags = {};
|
||||
const ok = exported.loadGame();
|
||||
assert(ok, 'loadGame() returned truthy');
|
||||
assert(G.code === 12345, `G.code restored (got ${G.code})`);
|
||||
assert(G.totalCode === 98765, `G.totalCode restored (got ${G.totalCode})`);
|
||||
assert(G.phase === 3, `G.phase restored (got ${G.phase})`);
|
||||
assert(G.buildings.autocoder === 7, `autocoder count restored (got ${G.buildings.autocoder})`);
|
||||
assert(Math.abs(G.codeBoost - 1.5) < 1e-9, `codeBoost restored (got ${G.codeBoost})`);
|
||||
assert(G.flags && G.flags.creativity === true, 'flags.creativity restored');
|
||||
|
||||
// ============================================================
|
||||
// 8. SUMMARY
|
||||
// ============================================================
|
||||
console.log(`\n---\n${passes} passed, ${failures} failed`);
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// 3. Game data structure check
|
||||
console.log("\n[Game Data]");
|
||||
check("js/data.js exists", () => {
|
||||
if (!existsSync(join(ROOT, "js/data.js"))) throw new Error("Missing");
|
||||
});
|
||||
|
||||
// 4. No banned providers
|
||||
console.log("\n[Policy]");
|
||||
check("No Anthropic references", () => {
|
||||
try {
|
||||
const result = execSync(
|
||||
"grep -ril 'anthropic\\|claude-sonnet\\|claude-opus\\|sk-ant-' --include='*.js' --include='*.json' --include='*.html' . 2>/dev/null || true",
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
if (result) throw new Error(`Found in: ${result}`);
|
||||
} catch (e) {
|
||||
if (e.message.startsWith("Found")) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n--- ${failures === 0 ? "ALL PASSED" : `${failures} FAILURE(S)`} ---`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user