Compare commits
18 Commits
burn/20260
...
sprint/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbad477615 | ||
| bfc30c535e | |||
| 76c3f06232 | |||
| 33788a54a5 | |||
| 5f29863161 | |||
| 266926ecaf | |||
| 5c83a7e1fd | |||
|
|
416fd907f4 | ||
|
|
2b43a070cc | ||
|
|
9de02fa346 | ||
| 1b7ccedf2e | |||
| 81353edd76 | |||
| 5cfda3ecea | |||
|
|
0ece82b958 | ||
| 16d5f98407 | |||
| 58c55176ae | |||
| 4ee5819398 | |||
|
|
fb5205092b |
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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._
|
||||
18
game/npc-logic.js
Normal file
18
game/npc-logic.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
class NPCStateMachine {
|
||||
constructor(states) {
|
||||
this.states = states;
|
||||
this.current = 'idle';
|
||||
}
|
||||
update(context) {
|
||||
const state = this.states[this.current];
|
||||
for (const transition of state.transitions) {
|
||||
if (transition.condition(context)) {
|
||||
this.current = transition.target;
|
||||
console.log(`NPC transitioned to ${this.current}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default NPCStateMachine;
|
||||
@@ -227,8 +227,10 @@ The light is on. The room is empty."
|
||||
<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,6 +283,7 @@ 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) {
|
||||
@@ -296,6 +297,10 @@ function checkMilestones() {
|
||||
if (pNum > _shownPhaseTransition) {
|
||||
_shownPhaseTransition = pNum;
|
||||
showPhaseTransition(pNum);
|
||||
if (typeof Sound !== 'undefined') {
|
||||
Sound.playFanfare();
|
||||
Sound.updateAmbientPhase(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +361,7 @@ 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;
|
||||
@@ -396,6 +402,7 @@ 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();
|
||||
@@ -437,6 +444,7 @@ 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 = [
|
||||
@@ -479,8 +487,7 @@ function renderBeaconEnding() {
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Create particle/light ray container
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
const particleContainer = document.createElement('div');
|
||||
particleContainer.id = 'beacon-ending-particles';
|
||||
document.body.appendChild(particleContainer);
|
||||
@@ -762,6 +769,7 @@ 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,6 +42,18 @@ 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);
|
||||
|
||||
@@ -69,6 +81,7 @@ 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 {
|
||||
|
||||
401
js/sound.js
Normal file
401
js/sound.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
}
|
||||
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++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
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);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright 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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
};
|
||||
})();
|
||||
26
scripts/guardrails.js
Normal file
26
scripts/guardrails.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
* 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,286 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
// 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)
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
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 ----------
|
||||
const ROOT = process.cwd();
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) {
|
||||
passes++;
|
||||
console.log(` ok ${msg}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.error(` FAIL ${msg}`);
|
||||
}
|
||||
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✔ ${label}`);
|
||||
} catch (e) {
|
||||
console.error(` ✘ ${label}: ${e.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
function section(name) { console.log(`\n${name}`); }
|
||||
|
||||
const { G, CONFIG, BDEF, PDEFS, EVENTS } = exported;
|
||||
console.log("--- The Beacon Smoke Test ---\n");
|
||||
|
||||
// ============================================================
|
||||
// 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)');
|
||||
// 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);
|
||||
|
||||
// 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 */ }
|
||||
for (const f of jsFiles) {
|
||||
check(`Parse ${f}`, () => {
|
||||
execSync(`node --check ${f}`, { encoding: "utf8" });
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
// 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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