diff --git a/index.html b/index.html
index dc8594d..2ace41b 100644
--- a/index.html
+++ b/index.html
@@ -175,6 +175,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
@@ -267,6 +268,7 @@ The light is on. The room is empty."
+
diff --git a/js/data.js b/js/data.js
index 21992ea..c400b25 100644
--- a/js/data.js
+++ b/js/data.js
@@ -106,6 +106,7 @@ const G = {
pactFlag: 0,
swarmFlag: 0,
swarmRate: 0,
+ swarmSim: null,
// Game state
running: true,
diff --git a/js/engine.js b/js/engine.js
index 8a4bd40..f347d06 100644
--- a/js/engine.js
+++ b/js/engine.js
@@ -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();
diff --git a/js/render.js b/js/render.js
index 9fa900a..a415355 100644
--- a/js/render.js
+++ b/js/render.js
@@ -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'
];
diff --git a/js/swarm.js b/js/swarm.js
new file mode 100644
index 0000000..f3d08d4
--- /dev/null
+++ b/js/swarm.js
@@ -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 += `
`;
+ html += `
COMMUNITY SWARM ALIGNMENT
`;
+ html += `
Inspired by Paperclips' manageSwarm(): care, cadence, and shared attention shape how the community thinks together.
`;
+ html += `
`;
+ for (const key of SwarmSim.STATUS_ORDER) {
+ html += `
${key}
${sim.counts[key] || 0}
`;
+ }
+ html += `
`;
+ html += `
WorkThink allocation: ${workPct}% work / ${thinkPct}% think
`;
+ html += `
`;
+ html += `
`;
+ for (const [key, def] of Object.entries(SwarmSim.ACTIONS)) {
+ html += ``;
+ }
+ html += `
`;
+ html += `
Swarm output: +${fmt(rates.codeRate)}/s code, +${fmt(rates.knowledgeRate)}/s knowledge
`;
+ html += `
Penalties: boredom and disorganization suppress throughput until you intervene.
`;
+ if (sim.lastGift) html += `
${sim.lastGift}
`;
+ if (sim.lastPenalty) html += `
${sim.lastPenalty}
`;
+ html += `
`;
+
+ 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;
+}
diff --git a/tests/swarm.test.cjs b/tests/swarm.test.cjs
index d8fb5b8..386e5a6 100644
--- a/tests/swarm.test.cjs
+++ b/tests/swarm.test.cjs
@@ -243,10 +243,14 @@ test('healthy swarm periodically sends compute gifts', () => {
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);
@@ -262,8 +266,17 @@ test('save and load round-trip preserves swarm simulation state', () => {
exp.G.swarmFlag = 1;
exp.G.ops = 42;
exp.SwarmSim.ensure();
- exp.G.swarmSim.counts.Active = 7;
- exp.G.swarmSim.counts.Bored = 3;
+ 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';