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';