Compare commits

...

4 Commits

Author SHA1 Message Date
Timmy
0ce0ceadf3 feat: add community swarm simulation for #6
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Failing after 20s
2026-04-20 21:22:46 -04:00
Timmy
1a20b14bd8 test: define community swarm simulation for #6 2026-04-20 21:18:49 -04:00
d5645fea58 Merge pull request 'fix: resolve #192 — move dead code to docs/reference, fix GENOME.md' (#194) from fix/192-dead-code-cleanup into main
Merge PR #194: fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
2026-04-17 01:47:15 +00:00
Alexander Whitestone
db08f9a478 fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Failing after 16s
- game/npc-logic.js → docs/reference/npc-logic-prototype.js (ES module, never imported)
- scripts/guardrails.js → docs/reference/guardrails-prototype.js (HP/MP validation, wrong game)
- Updated GENOME.md architecture diagram to reflect actual file structure
- Updated DEAD_CODE_AUDIT to mark these as resolved
- Corrected JS line counts (6,033 across 11 files)
- Removed empty game/ directory

The actual CI scripts (guardrails.sh, smoke.mjs) remain active in scripts/.
2026-04-15 21:25:38 -04:00
10 changed files with 634 additions and 17 deletions

View File

@@ -8,24 +8,32 @@ The Beacon is a browser-based idle/incremental game inspired by Universal Paperc
Static HTML/JS — no build step, no dependencies, no framework. Open `index.html` in any browser.
**5,128 lines of JavaScript** across 10 files. **1 HTML file** with embedded CSS (~300 lines). **1 Python test file** for reckoning projects.
**6,033 lines of JavaScript** across 11 files. **1 HTML file** with embedded CSS (~300 lines). **3 test files** (2 Node.js, 1 Python).
## Architecture
```
index.html (UI + embedded CSS)
index.html (UI + embedded CSS + inline JS ~5000L)
|
+-- js/engine.js (1590L) Core game loop, tick, resources, buildings, projects, events
+-- js/data.js (944L) Building definitions, project trees, event tables, phase data
+-- js/render.js (390L) DOM rendering, UI updates, resource displays
+-- js/combat.js (359L) Boss encounters, combat mechanics
+-- js/combat.js (359L) Canvas boid-flocking combat visualization
+-- js/sound.js (401L) Web Audio API ambient drone, phase-aware sound
+-- js/dismantle.js (570L) The Dismantle sequence (late-game narrative)
+-- js/main.js (223L) Initialization, game loop start, auto-save, help overlay
+-- js/utils.js (314L) Formatting, save/load, export/import, DOM helpers
+-- js/tutorial.js (251L) New player tutorial, step-by-step guidance
+-- js/strategy.js (68L) NPC strategy logic for combat
+-- game/npc-logic.js (18L) NPC behavior stub
+-- js/emergent-mechanics.js Emergent game mechanics from player behavior
CI scripts (not browser runtime):
+-- scripts/guardrails.sh Static analysis guardrails for game logic
+-- scripts/smoke.mjs Playwright smoke tests
Reference prototypes (NOT loaded by runtime):
+-- docs/reference/npc-logic-prototype.js NPC state machine prototype
+-- docs/reference/guardrails-prototype.js Stat validation prototype
```
## Entry Points

View File

@@ -3,20 +3,15 @@ _2026-04-12, Perplexity QA_
## Findings
### Potentially Unimported Files
### Dead Code — Resolved (2026-04-15, Issue #192)
The following files were added by recent PRs but may not be imported
by the main game runtime (`js/main.js``js/engine.js`):
The following files were confirmed dead code — never imported by any runtime module.
They have been moved to `docs/reference/` as prototype reference code.
| 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
| File | Original | Resolution |
|------|----------|------------|
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | **Moved to `docs/reference/npc-logic-prototype.js`** — ES module using `export default`, incompatible with the global-script loading pattern. Concept (NPC state machine) is sound but not wired into any game system. |
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | **Moved to `docs/reference/guardrails-prototype.js`** — validates HP/MP/stats concepts that don't exist in The Beacon's resource system. The `scripts/guardrails.sh` (bash CI script) remains active. |
### game.js Bloat (PR #76)

View File

@@ -175,6 +175,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div id="sprint-label" style="font-size:9px;color:#666;margin-top:2px;text-align:center"></div>
</div>
<div id="alignment-ui" style="display:none"></div>
<div id="swarm-ui" style="display:none"></div>
<button class="save-btn" onclick="saveGame()" aria-label="Save game progress">Save Game [Ctrl+S]</button>
<div style="display:flex;gap:4px;margin-top:4px">
<button class="save-btn" onclick="exportSave()" aria-label="Export save to file" style="flex:1">Export [E]</button>
@@ -267,6 +268,7 @@ The light is on. The room is empty."
<script src="js/sound.js"></script>
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/swarm.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/emergent-mechanics.js"></script>

View File

@@ -106,6 +106,7 @@ const G = {
pactFlag: 0,
swarmFlag: 0,
swarmRate: 0,
swarmSim: null,
// Game state
running: true,

View File

@@ -102,6 +102,14 @@ function updateRates() {
G.codeRate += G.swarmRate;
}
if (typeof SwarmSim !== 'undefined') {
const simRates = SwarmSim.computeRates();
G.codeRate += simRates.codeRate;
G.knowledgeRate += simRates.knowledgeRate;
G.harmonyRate += simRates.harmonyRate;
G.trustRate += simRates.trustRate;
}
// Apply persistent debuffs from active events
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
for (const debuff of G.activeDebuffs) {
@@ -216,6 +224,9 @@ function tick() {
// Combat: tick battle simulation
Combat.tickBattle(dt);
// Community swarm alignment simulation
if (typeof tickSwarm === 'function') tickSwarm(dt);
// Check milestones
checkMilestones();

View File

@@ -6,6 +6,7 @@ function render() {
renderStats();
updateEducation();
renderAlignment();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
renderProgress();
renderCombo();
renderDebuffs();
@@ -225,6 +226,7 @@ function saveGame() {
sprintCooldown: G.sprintCooldown || 0,
swarmFlag: G.swarmFlag || 0,
swarmRate: G.swarmRate || 0,
swarmSim: G.swarmSim || null,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
dismantleTriggered: G.dismantleTriggered || false,
@@ -265,7 +267,7 @@ function loadGame() {
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'swarmFlag', 'swarmRate', 'swarmSim', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
];

300
js/swarm.js Normal file
View File

@@ -0,0 +1,300 @@
const SwarmSim = (() => {
const STATUS_ORDER = ['Active', 'Confused', 'Bored', 'Cold', 'Disorganized', 'Sleeping', 'Lonely', 'No Response'];
const DEFAULT_POPULATION = 16;
const DEFAULT_COUNTS = {
'Active': 5,
'Confused': 2,
'Bored': 2,
'Cold': 2,
'Disorganized': 2,
'Sleeping': 2,
'Lonely': 1,
'No Response': 0
};
const ACTIONS = {
feed: {
label: 'Feed',
cost: 1,
shifts: [['Cold', 'Active', 2], ['Lonely', 'Active', 1], ['No Response', 'Sleeping', 1]],
help: 'Meals and attention pull cold contributors back into the room.'
},
teach: {
label: 'Teach',
cost: 1,
shifts: [['Confused', 'Active', 2], ['Sleeping', 'Active', 1]],
help: 'Mentorship turns confusion into aligned contribution.'
},
entertain: {
label: 'Entertain',
cost: 1,
shifts: [['Bored', 'Active', 2], ['Lonely', 'Active', 1]],
help: 'Joy keeps the community present long enough to care.'
},
clad: {
label: 'Clad',
cost: 1,
shifts: [['Cold', 'Active', 2], ['No Response', 'Sleeping', 1]],
help: 'Warmth and material care reduce attrition.'
},
synchronize: {
label: 'Synchronize',
cost: 1,
shifts: [['Disorganized', 'Active', 2], ['Confused', 'Active', 1]],
help: 'Shared cadence restores coherence without coercion.'
}
};
function unlocked() {
return G.swarmFlag === 1 || ((G.buildings && G.buildings.community) || 0) > 0;
}
function defaultState() {
return {
population: DEFAULT_POPULATION,
counts: { ...DEFAULT_COUNTS },
workRatio: 0.5,
tickTimer: 0,
giftTimer: 0,
lastGift: '',
lastPenalty: ''
};
}
function ensure() {
if (!unlocked()) return null;
if (!G.swarmSim || typeof G.swarmSim !== 'object') {
G.swarmSim = defaultState();
}
normalize();
return G.swarmSim;
}
function normalize() {
const sim = G.swarmSim;
if (!sim) return;
if (!sim.counts || typeof sim.counts !== 'object') sim.counts = { ...DEFAULT_COUNTS };
for (const key of STATUS_ORDER) {
sim.counts[key] = Math.max(0, Math.floor(Number(sim.counts[key] || 0)));
}
const target = Math.max(2, Math.floor(Number(sim.population || DEFAULT_POPULATION)));
sim.population = target;
let total = STATUS_ORDER.reduce((sum, key) => sum + sim.counts[key], 0);
if (total < target) {
sim.counts.Active += target - total;
} else if (total > target) {
let overflow = total - target;
for (const key of ['Active', 'Sleeping', 'Bored', 'Confused', 'Disorganized', 'Cold', 'Lonely', 'No Response']) {
if (overflow <= 0) break;
const take = Math.min(overflow, sim.counts[key]);
sim.counts[key] -= take;
overflow -= take;
}
}
const rawRatio = Number(sim.workRatio);
sim.workRatio = Number.isFinite(rawRatio) ? Math.max(0, Math.min(1, rawRatio)) : 0.5;
sim.tickTimer = Number(sim.tickTimer || 0);
sim.giftTimer = Number(sim.giftTimer || 0);
sim.lastGift = String(sim.lastGift || '');
sim.lastPenalty = String(sim.lastPenalty || '');
}
function move(counts, from, to, amount) {
const take = Math.min(amount, counts[from] || 0);
if (!take) return 0;
counts[from] -= take;
counts[to] = (counts[to] || 0) + take;
return take;
}
function setWorkThinkAllocation(value) {
const sim = ensure();
if (!sim) return;
const numeric = Number(value);
sim.workRatio = Number.isFinite(numeric) ? Math.max(0, Math.min(1, numeric / 100)) : 0.5;
if (typeof updateRates === 'function') updateRates();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
}
function applyAction(action) {
const sim = ensure();
if (!sim) return false;
const def = ACTIONS[action];
if (!def) return false;
if (G.ops < def.cost) {
if (typeof log === 'function') log('Not enough ops to support the community swarm.', true);
return false;
}
G.ops -= def.cost;
for (const [from, to, amount] of def.shifts) {
move(sim.counts, from, to, amount);
}
normalize();
sim.lastPenalty = '';
if (typeof log === 'function') log(`${def.label} steadies the community swarm.`, true);
if (typeof updateRates === 'function') updateRates();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
return true;
}
function computeRates() {
const sim = ensure();
if (!sim) {
return { codeRate: 0, knowledgeRate: 0, harmonyRate: 0, trustRate: 0, penaltyFactor: 1 };
}
const active = sim.counts.Active || 0;
const bored = sim.counts.Bored || 0;
const disorganized = sim.counts.Disorganized || 0;
const cold = sim.counts.Cold || 0;
const lonely = sim.counts.Lonely || 0;
const silent = sim.counts['No Response'] || 0;
const penaltyFactor = Math.max(0.2, 1 - bored * 0.04 - disorganized * 0.05);
const work = active * sim.workRatio;
const think = active * (1 - sim.workRatio);
const codeRate = work * 1.2 * penaltyFactor;
const knowledgeRate = think * 1.4 * penaltyFactor;
const harmonyRate = -(bored * 0.03 + disorganized * 0.05 + cold * 0.02);
const trustRate = -(lonely * 0.01 + silent * 0.03);
sim.lastPenalty = (bored + disorganized) > 0
? `Bored (${bored}) / Disorganized (${disorganized}) reduce swarm output.`
: '';
return { codeRate, knowledgeRate, harmonyRate, trustRate, penaltyFactor };
}
function maybeGift(sim) {
const active = sim.counts.Active || 0;
const bored = sim.counts.Bored || 0;
const disorganized = sim.counts.Disorganized || 0;
if (active < 6 || bored + disorganized > 5) return false;
const gift = 5 + active;
G.compute += gift;
G.totalCompute += gift;
G.maxCompute = Math.max(G.maxCompute || 0, G.compute);
sim.lastGift = `Community gift: +${fmt(gift)} compute`;
if (typeof log === 'function') log(sim.lastGift, true);
return true;
}
function decayStatuses(sim) {
const counts = sim.counts;
if (sim.workRatio > 0.75) move(counts, 'Active', 'Bored', 1);
if (sim.workRatio < 0.25) move(counts, 'Active', 'Disorganized', 1);
if (G.harmony < 25) move(counts, 'Active', 'Confused', 1);
if (G.compute < 50) move(counts, 'Active', 'Cold', 1);
if (G.ops < 3) move(counts, 'Active', 'Sleeping', 1);
if (G.trust < 8) move(counts, 'Active', 'Lonely', 1);
if (G.harmony < 10) move(counts, 'Lonely', 'No Response', 1);
move(counts, 'Sleeping', 'Active', 1);
if (G.compute > 50) move(counts, 'Cold', 'Active', 1);
if (G.trust >= 10) move(counts, 'Lonely', 'Active', 1);
if ((counts.Bored || 0) > 0 && Math.random() < 0.5) move(counts, 'Bored', 'Active', 1);
if ((counts.Confused || 0) > 0 && Math.random() < 0.35) move(counts, 'Confused', 'Active', 1);
normalize();
}
function tick(dt) {
const sim = ensure();
if (!sim) return;
const delta = Number(dt) || 0;
sim.tickTimer += delta;
sim.giftTimer += delta;
let changed = false;
while (sim.tickTimer >= 5) {
sim.tickTimer -= 5;
decayStatuses(sim);
changed = true;
}
while (sim.giftTimer >= 20) {
sim.giftTimer -= 20;
if (maybeGift(sim)) changed = true;
}
if (changed && typeof updateRates === 'function') updateRates();
}
return {
STATUS_ORDER,
ACTIONS,
ensure,
setWorkThinkAllocation,
applyAction,
computeRates,
tick
};
})();
function setSwarmWorkThinkAllocation(value) {
SwarmSim.setWorkThinkAllocation(value);
}
function applySwarmAction(action) {
SwarmSim.applyAction(action);
}
function tickSwarm(dt) {
SwarmSim.tick(dt);
}
function renderSwarmPanel() {
const container = document.getElementById('swarm-ui');
if (!container) return;
const sim = SwarmSim.ensure();
if (!sim) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
const rates = SwarmSim.computeRates();
const workPct = Math.round(sim.workRatio * 100);
const thinkPct = 100 - workPct;
let html = '';
html += `<div style="margin-top:10px;padding:10px;border:1px solid #1a2a3a;border-radius:6px;background:#0b1018">`;
html += `<div style="color:#b388ff;font-weight:700;font-size:11px;margin-bottom:6px">COMMUNITY SWARM ALIGNMENT</div>`;
html += `<div style="font-size:9px;color:#888;margin-bottom:8px">Inspired by Paperclips' manageSwarm(): care, cadence, and shared attention shape how the community thinks together.</div>`;
html += `<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;margin-bottom:8px;font-size:9px">`;
for (const key of SwarmSim.STATUS_ORDER) {
html += `<div style="padding:4px;border:1px solid #182230;border-radius:4px;background:#0f1620"><div style="color:#666">${key}</div><div style="color:#d8e6ff;font-weight:700">${sim.counts[key] || 0}</div></div>`;
}
html += `</div>`;
html += `<div style="font-size:9px;color:#aaa;margin-bottom:4px">WorkThink allocation: <span style="color:#ffd700">${workPct}%</span> work / <span style="color:#4a9eff">${thinkPct}%</span> think</div>`;
html += `<input id="swarm-workthink" type="range" min="0" max="100" step="5" value="${workPct}" oninput="setSwarmWorkThinkAllocation(this.value)" aria-label="WorkThink allocation slider" style="width:100%;margin-bottom:8px">`;
html += `<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;margin-bottom:8px">`;
for (const [key, def] of Object.entries(SwarmSim.ACTIONS)) {
html += `<button class="ops-btn" onclick="applySwarmAction('${key}')" aria-label="${def.label} the swarm, costs ${def.cost} ops" title="${def.help}">${def.label}</button>`;
}
html += `</div>`;
html += `<div style="font-size:9px;color:#4caf50">Swarm output: +${fmt(rates.codeRate)}/s code, +${fmt(rates.knowledgeRate)}/s knowledge</div>`;
html += `<div style="font-size:9px;color:#777;margin-top:4px">Penalties: boredom and disorganization suppress throughput until you intervene.</div>`;
if (sim.lastGift) html += `<div style="font-size:9px;color:#4a9eff;margin-top:4px">${sim.lastGift}</div>`;
if (sim.lastPenalty) html += `<div style="font-size:9px;color:#f44336;margin-top:4px">${sim.lastPenalty}</div>`;
html += `</div>`;
container.innerHTML = html;
container.style.display = 'block';
}
if (typeof window !== 'undefined') {
window.SwarmSim = SwarmSim;
window.setSwarmWorkThinkAllocation = setSwarmWorkThinkAllocation;
window.applySwarmAction = applySwarmAction;
window.tickSwarm = tickSwarm;
window.renderSwarmPanel = renderSwarmPanel;
}

298
tests/swarm.test.cjs Normal file
View File

@@ -0,0 +1,298 @@
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, '..');
function createStorage() {
const store = new Map();
return {
getItem(key) {
return store.has(key) ? store.get(key) : null;
},
setItem(key, value) {
store.set(key, String(value));
},
removeItem(key) {
store.delete(key);
},
clear() {
store.clear();
},
snapshot(key) {
return store.has(key) ? store.get(key) : null;
}
};
}
function makeContext() {
const byId = new Map();
const storage = createStorage();
function makeEl(id = '') {
return {
id,
style: {},
innerHTML: '',
textContent: '',
className: '',
dataset: {},
attributes: {},
children: [],
parentNode: null,
classList: {
add() {},
remove() {},
contains() { return false; },
toggle() { return false; }
},
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;
},
insertBefore(child) {
child.parentNode = this;
this.children.unshift(child);
return child;
},
setAttribute(name, value) {
this.attributes[name] = value;
if (name === 'id') this.id = value;
},
querySelector() { return null; },
querySelectorAll() { return []; }
};
}
const document = {
body: {
classList: {
add() {},
remove() {},
contains() { return false; },
toggle() { return false; }
}
},
getElementById(id) {
if (!byId.has(id)) byId.set(id, makeEl(id));
return byId.get(id);
},
createElement(tag) {
return makeEl(tag);
},
addEventListener() {},
removeEventListener() {},
querySelector() { return null; },
querySelectorAll() { return []; }
};
const math = Object.create(Math);
const context = vm.createContext({
console,
Math: math,
Date,
document,
window: null,
globalThis: null,
navigator: { userAgent: 'node' },
localStorage: storage,
requestAnimationFrame(fn) { return fn(); },
cancelAnimationFrame() {},
setTimeout() { return 0; },
clearTimeout() {},
setInterval() { return 0; },
clearInterval() {},
location: { reload() {} },
confirm() { return false; },
Combat: { renderCombatPanel() {}, tickBattle() {}, init() {} },
Sound: undefined,
log() {},
showToast() {},
render() {},
renderPhase() {},
showOfflinePopup() {},
showSaveToast() {}
});
context.window = context;
context.globalThis = context;
return { context, document, storage };
}
function loadBeacon(context, files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/render.js', 'js/swarm.js']) {
const source = files
.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8'))
.join('\n\n');
vm.runInContext(`${source}
this.__exports = {
G,
SwarmSim: typeof SwarmSim !== 'undefined' ? SwarmSim : null,
updateRates: typeof updateRates === 'function' ? updateRates : null,
renderSwarmPanel: typeof renderSwarmPanel === 'function' ? renderSwarmPanel : null,
applySwarmAction: typeof applySwarmAction === 'function' ? applySwarmAction : null,
setSwarmWorkThinkAllocation: typeof setSwarmWorkThinkAllocation === 'function' ? setSwarmWorkThinkAllocation : null,
tickSwarm: typeof tickSwarm === 'function' ? tickSwarm : null,
saveGame: typeof saveGame === 'function' ? saveGame : null,
loadGame: typeof loadGame === 'function' ? loadGame : null
};`, context);
return context.__exports;
}
test('community swarm panel renders statuses, controls, and educational copy', () => {
const { context, document } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.renderSwarmPanel();
const html = document.getElementById('swarm-ui').innerHTML;
assert.equal(typeof exp.renderSwarmPanel, 'function');
assert.match(html, /COMMUNITY SWARM ALIGNMENT/i);
assert.match(html, /Feed/);
assert.match(html, /Teach/);
assert.match(html, /Entertain/);
assert.match(html, /Clad/);
assert.match(html, /Synchronize/);
assert.match(html, /WorkThink allocation/i);
assert.match(html, /Active/);
assert.match(html, /Confused/);
assert.match(html, /No Response/);
assert.match(html, /Paperclips/i);
});
test('teach action reduces confusion and costs ops', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Confused = 4;
exp.G.swarmSim.counts.Active = 1;
const beforeOps = exp.G.ops;
exp.applySwarmAction('teach');
assert.ok(exp.G.swarmSim.counts.Confused < 4);
assert.ok(exp.G.swarmSim.counts.Active > 1);
assert.ok(exp.G.ops < beforeOps);
});
test('work-think allocation shifts swarm output toward code or knowledge', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.setSwarmWorkThinkAllocation(90);
exp.updateRates();
const workCode = exp.G.codeRate;
const workKnowledge = exp.G.knowledgeRate;
exp.setSwarmWorkThinkAllocation(10);
exp.updateRates();
const thinkCode = exp.G.codeRate;
const thinkKnowledge = exp.G.knowledgeRate;
assert.ok(workCode > thinkCode);
assert.ok(thinkKnowledge > workKnowledge);
});
test('boredom and disorganization penalize swarm output', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.updateRates();
const cleanCode = exp.G.codeRate;
exp.G.swarmSim.counts.Bored = 5;
exp.G.swarmSim.counts.Disorganized = 5;
exp.updateRates();
const penalizedCode = exp.G.codeRate;
assert.ok(penalizedCode < cleanCode);
});
test('healthy swarm periodically sends compute gifts', () => {
const { context } = makeContext();
context.Math.random = () => 0.1;
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.G.trust = 20;
exp.G.compute = 100;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.G.swarmSim.counts.Cold = 0;
exp.G.swarmSim.counts.Lonely = 0;
const beforeCompute = exp.G.compute;
exp.tickSwarm(20);
assert.ok(exp.G.compute > beforeCompute);
assert.match(exp.G.swarmSim.lastGift, /compute/i);
});
test('save and load round-trip preserves swarm simulation state', () => {
const { context, storage } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 42;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts = {
'Active': 7,
'Confused': 2,
'Bored': 3,
'Cold': 1,
'Disorganized': 0,
'Sleeping': 2,
'Lonely': 1,
'No Response': 0
};
exp.G.swarmSim.population = 16;
exp.G.swarmSim.workRatio = 0.8;
exp.G.swarmSim.giftTimer = 12;
exp.G.swarmSim.lastGift = 'Community gift: +12 compute';
exp.saveGame();
const saved = JSON.parse(storage.snapshot('the-beacon-v2'));
assert.equal(saved.swarmSim.counts.Active, 7);
assert.equal(saved.swarmSim.workRatio, 0.8);
exp.G.swarmSim.counts.Active = 1;
exp.G.swarmSim.workRatio = 0.1;
exp.G.swarmSim.lastGift = '';
assert.equal(exp.loadGame(), true);
assert.equal(exp.G.swarmSim.counts.Active, 7);
assert.equal(exp.G.swarmSim.counts.Bored, 3);
assert.equal(exp.G.swarmSim.workRatio, 0.8);
assert.equal(exp.G.swarmSim.lastGift, 'Community gift: +12 compute');
});