Some checks failed
Smoke Test / smoke (push) Failing after 3s
Merge PR #67: fix: debuff corruption + persist playTime
1362 lines
54 KiB
JavaScript
1362 lines
54 KiB
JavaScript
function updateRates() {
|
||
// Reset all rates
|
||
G.codeRate = 0; G.computeRate = 0; G.knowledgeRate = 0;
|
||
G.userRate = 0; G.impactRate = 0; G.rescuesRate = 0; G.opsRate = 0; G.trustRate = 0;
|
||
G.creativityRate = 0; G.harmonyRate = 0;
|
||
|
||
// Apply building rates
|
||
for (const def of BDEF) {
|
||
const count = G.buildings[def.id] || 0;
|
||
if (count > 0 && def.rates) {
|
||
for (const [resource, baseRate] of Object.entries(def.rates)) {
|
||
if (resource === 'code') G.codeRate += baseRate * count * G.codeBoost;
|
||
else if (resource === 'compute') G.computeRate += baseRate * count * G.computeBoost;
|
||
else if (resource === 'knowledge') G.knowledgeRate += baseRate * count * G.knowledgeBoost;
|
||
else if (resource === 'user') G.userRate += baseRate * count * G.userBoost;
|
||
else if (resource === 'impact') G.impactRate += baseRate * count * G.impactBoost;
|
||
else if (resource === 'rescues') G.rescuesRate += baseRate * count * G.impactBoost;
|
||
else if (resource === 'ops') G.opsRate += baseRate * count;
|
||
else if (resource === 'trust') G.trustRate += baseRate * count;
|
||
else if (resource === 'creativity') G.creativityRate += baseRate * count;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Passive generation
|
||
G.opsRate += Math.max(1, G.totalUsers * CONFIG.OPS_RATE_USER_MULT);
|
||
if (G.flags && G.flags.creativity) {
|
||
G.creativityRate += CONFIG.CREATIVITY_RATE_BASE + Math.max(0, G.totalUsers * CONFIG.CREATIVITY_RATE_USER_MULT);
|
||
}
|
||
if (G.pactFlag) G.trustRate += 2;
|
||
|
||
// Harmony: each wizard building contributes or detracts
|
||
const wizardCount = (G.buildings.bezalel || 0) + (G.buildings.allegro || 0) + (G.buildings.ezra || 0) +
|
||
(G.buildings.timmy || 0) + (G.buildings.fenrir || 0) + (G.buildings.bilbo || 0);
|
||
// Store harmony breakdown for tooltip
|
||
G.harmonyBreakdown = [];
|
||
if (wizardCount > 0) {
|
||
// Baseline harmony drain from complexity
|
||
const drain = -CONFIG.HARMONY_DRAIN_PER_WIZARD * wizardCount;
|
||
G.harmonyRate = drain;
|
||
G.harmonyBreakdown.push({ label: `${wizardCount} wizards`, value: drain });
|
||
// The Pact restores harmony
|
||
if (G.pactFlag) {
|
||
const pact = CONFIG.PACT_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += pact;
|
||
G.harmonyBreakdown.push({ label: 'The Pact', value: pact });
|
||
}
|
||
// Nightly Watch restores harmony
|
||
if (G.nightlyWatchFlag) {
|
||
const watch = CONFIG.WATCH_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += watch;
|
||
G.harmonyBreakdown.push({ label: 'Nightly Watch', value: watch });
|
||
}
|
||
// MemPalace restores harmony
|
||
if (G.mempalaceFlag) {
|
||
const mem = CONFIG.MEM_PALACE_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += mem;
|
||
G.harmonyBreakdown.push({ label: 'MemPalace', value: mem });
|
||
}
|
||
}
|
||
// Active debuffs affecting harmony
|
||
if (G.activeDebuffs) {
|
||
for (const d of G.activeDebuffs) {
|
||
if (d.id === 'community_drama') {
|
||
G.harmonyBreakdown.push({ label: 'Community Drama', value: -0.5 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Timmy multiplier based on harmony
|
||
if (G.buildings.timmy > 0) {
|
||
const timmyMult = Math.max(0.2, Math.min(3, G.harmony / 50));
|
||
const timmyCount = G.buildings.timmy;
|
||
G.codeRate += 5 * timmyCount * (timmyMult - 1);
|
||
G.computeRate += 2 * timmyCount * (timmyMult - 1);
|
||
G.knowledgeRate += 2 * timmyCount * (timmyMult - 1);
|
||
G.userRate += 5 * timmyCount * (timmyMult - 1);
|
||
}
|
||
|
||
// Bilbo randomness: 10% chance of massive creative burst
|
||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
|
||
G.creativityRate += 50 * G.buildings.bilbo;
|
||
}
|
||
// Bilbo vanishing: 5% chance of zero creativity this tick
|
||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
|
||
G.creativityRate = 0;
|
||
}
|
||
|
||
// Allegro requires trust
|
||
if (G.buildings.allegro > 0 && G.trust < 5) {
|
||
const allegroCount = G.buildings.allegro;
|
||
G.knowledgeRate -= 10 * allegroCount; // Goes idle
|
||
}
|
||
|
||
// Swarm Protocol: buildings auto-code based on click power
|
||
if (G.swarmFlag === 1) {
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
const clickPower = getClickPower();
|
||
G.swarmRate = totalBuildings * clickPower;
|
||
G.codeRate += G.swarmRate;
|
||
}
|
||
|
||
// Apply persistent debuffs from active events
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
for (const debuff of G.activeDebuffs) {
|
||
if (debuff.applyFn) debuff.applyFn();
|
||
}
|
||
}
|
||
}
|
||
|
||
// === CORE FUNCTIONS ===
|
||
/**
|
||
* Main game loop tick, called every 100ms.
|
||
*/
|
||
function tick() {
|
||
const dt = 1 / 10; // 100ms tick
|
||
|
||
// If game has ended (drift ending), stop ticking
|
||
if (!G.running) return;
|
||
|
||
// Apply production
|
||
G.code += G.codeRate * dt;
|
||
G.compute += G.computeRate * dt;
|
||
G.knowledge += G.knowledgeRate * dt;
|
||
G.users += G.userRate * dt;
|
||
G.impact += G.impactRate * dt;
|
||
G.rescues += G.rescuesRate * dt;
|
||
G.ops += G.opsRate * dt;
|
||
G.trust += G.trustRate * dt;
|
||
// NOTE: creativity is added conditionally below (only when ops near max)
|
||
G.harmony += G.harmonyRate * dt;
|
||
G.harmony = Math.max(0, Math.min(100, G.harmony));
|
||
|
||
// Track totals
|
||
G.totalCode += G.codeRate * dt;
|
||
G.totalCompute += G.computeRate * dt;
|
||
G.totalKnowledge += G.knowledgeRate * dt;
|
||
G.totalUsers += G.userRate * dt;
|
||
G.totalImpact += G.impactRate * dt;
|
||
G.totalRescues += G.rescuesRate * dt;
|
||
|
||
// Track maxes
|
||
G.maxCode = Math.max(G.maxCode, G.code);
|
||
G.maxCompute = Math.max(G.maxCompute, G.compute);
|
||
G.maxKnowledge = Math.max(G.maxKnowledge, G.knowledge);
|
||
G.maxUsers = Math.max(G.maxUsers, G.users);
|
||
G.maxImpact = Math.max(G.maxImpact, G.impact);
|
||
G.maxRescues = Math.max(G.maxRescues, G.rescues);
|
||
G.maxTrust = Math.max(G.maxTrust, G.trust);
|
||
G.maxOps = Math.max(G.maxOps, G.ops);
|
||
G.maxHarmony = Math.max(G.maxHarmony, G.harmony);
|
||
|
||
// Creativity generates only when ops at max
|
||
if (G.flags && G.flags.creativity && G.creativityRate > 0 && G.ops >= G.maxOps * 0.9) {
|
||
G.creativity += G.creativityRate * dt;
|
||
}
|
||
|
||
// Ops overflow: auto-convert excess ops to code when near cap
|
||
// Prevents ops from sitting idle at max — every operation becomes code
|
||
if (G.ops > G.maxOps * CONFIG.OPS_OVERFLOW_THRESHOLD) {
|
||
const overflowDrain = Math.min(CONFIG.OPS_OVERFLOW_DRAIN_RATE * dt, G.ops - G.maxOps * CONFIG.OPS_OVERFLOW_THRESHOLD);
|
||
G.ops -= overflowDrain;
|
||
const codeGain = overflowDrain * CONFIG.OPS_OVERFLOW_CODE_MULT * G.codeBoost;
|
||
G.code += codeGain;
|
||
G.totalCode += codeGain;
|
||
G.opsOverflowActive = true;
|
||
} else {
|
||
G.opsOverflowActive = false;
|
||
}
|
||
|
||
G.tick += dt;
|
||
G.playTime += dt;
|
||
|
||
// Sprint ability
|
||
tickSprint(dt);
|
||
|
||
// Auto-typer: buildings produce actual clicks, not just passive rate
|
||
// Each autocoder level auto-types once per interval, giving visual feedback
|
||
if (G.buildings.autocoder > 0) {
|
||
const interval = Math.max(0.5, 3.0 / Math.sqrt(G.buildings.autocoder));
|
||
G.autoTypeTimer = (G.autoTypeTimer || 0) + dt;
|
||
if (G.autoTypeTimer >= interval) {
|
||
G.autoTypeTimer -= interval;
|
||
autoType();
|
||
}
|
||
}
|
||
|
||
// Combo decay
|
||
if (G.comboCount > 0) {
|
||
G.comboTimer -= dt;
|
||
if (G.comboTimer <= 0) {
|
||
G.comboCount = 0;
|
||
G.comboTimer = 0;
|
||
}
|
||
}
|
||
|
||
// Check milestones
|
||
checkMilestones();
|
||
|
||
// Update projects every 5 ticks for efficiency
|
||
if (Math.floor(G.tick * 10) % 5 === 0) {
|
||
checkProjects();
|
||
}
|
||
|
||
// Check corruption events every ~30 seconds
|
||
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) {
|
||
triggerEvent();
|
||
G.lastEventAt = G.tick;
|
||
}
|
||
|
||
// Drift ending: if drift reaches 100, the game ends
|
||
if (G.drift >= 100 && !G.driftEnding) {
|
||
G.driftEnding = true;
|
||
G.running = false;
|
||
renderDriftEnding();
|
||
}
|
||
|
||
// True ending: The Beacon Shines — rescues + Pact + harmony
|
||
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) {
|
||
G.beaconEnding = true;
|
||
G.running = false;
|
||
renderBeaconEnding();
|
||
}
|
||
|
||
// Update UI every 10 ticks
|
||
if (Math.floor(G.tick * 10) % 2 === 0) {
|
||
render();
|
||
}
|
||
}
|
||
|
||
function checkMilestones() {
|
||
for (const m of MILESTONES) {
|
||
if (!G.milestones.includes(m.flag)) {
|
||
let shouldTrigger = false;
|
||
if (m.at && m.at()) shouldTrigger = true;
|
||
if (m.flag === 1 && G.deployFlag === 0 && G.totalCode >= 15) shouldTrigger = true;
|
||
|
||
if (shouldTrigger) {
|
||
G.milestones.push(m.flag);
|
||
log(m.msg, true);
|
||
showToast(m.msg, 'milestone', 5000);
|
||
|
||
// Check phase advancement
|
||
if (m.at) {
|
||
for (const [phaseNum, phase] of Object.entries(PHASES)) {
|
||
if (G.totalCode >= phase.threshold && parseInt(phaseNum) > G.phase) {
|
||
G.phase = parseInt(phaseNum);
|
||
log(`PHASE ${G.phase}: ${phase.name}`, true);
|
||
showToast('Phase ' + G.phase + ': ' + phase.name, 'milestone', 6000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkProjects() {
|
||
// Check for new project triggers
|
||
for (const pDef of PDEFS) {
|
||
const alreadyPurchased = G.completedProjects && G.completedProjects.includes(pDef.id);
|
||
if (!alreadyPurchased && !G.activeProjects) G.activeProjects = [];
|
||
|
||
if (!alreadyPurchased && !G.activeProjects.includes(pDef.id)) {
|
||
if (pDef.trigger()) {
|
||
G.activeProjects.push(pDef.id);
|
||
log(`Available: ${pDef.name}`);
|
||
showToast('Research available: ' + pDef.name, 'project');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handles building purchase logic.
|
||
* @param {string} id - The ID of the building to buy.
|
||
*/
|
||
function buyBuilding(id) {
|
||
const def = BDEF.find(b => b.id === id);
|
||
if (!def || !def.unlock()) return;
|
||
if (def.phase > G.phase + 1) return;
|
||
|
||
// Determine actual quantity to buy
|
||
let qty = G.buyAmount;
|
||
if (qty === -1) {
|
||
// Max buy
|
||
qty = getMaxBuyable(id);
|
||
if (qty <= 0) return;
|
||
} else {
|
||
// Check affordability for fixed qty
|
||
const bulkCost = getBulkCost(id, qty);
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
if ((G[resource] || 0) < amount) return;
|
||
}
|
||
}
|
||
|
||
// Spend resources and build
|
||
const bulkCost = getBulkCost(id, qty);
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
G[resource] -= amount;
|
||
}
|
||
G.buildings[id] = (G.buildings[id] || 0) + qty;
|
||
updateRates();
|
||
const label = qty > 1 ? `x${qty}` : '';
|
||
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
|
||
render();
|
||
}
|
||
|
||
/**
|
||
* Handles project purchase logic.
|
||
* @param {string} id - The ID of the project to buy.
|
||
*/
|
||
function buyProject(id) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (!pDef) return;
|
||
|
||
const alreadyPurchased = G.completedProjects && G.completedProjects.includes(pDef.id);
|
||
if (alreadyPurchased && !pDef.repeatable) return;
|
||
|
||
if (!canAffordProject(pDef)) return;
|
||
|
||
spendProject(pDef);
|
||
pDef.effect();
|
||
|
||
if (!pDef.repeatable) {
|
||
if (!G.completedProjects) G.completedProjects = [];
|
||
G.completedProjects.push(pDef.id);
|
||
G.activeProjects = G.activeProjects.filter(aid => aid !== pDef.id);
|
||
}
|
||
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
// === DRIFT ENDING ===
|
||
function renderDriftEnding() {
|
||
const el = document.getElementById('drift-ending');
|
||
if (!el) return;
|
||
const fc = document.getElementById('final-code');
|
||
if (fc) fc.textContent = fmt(G.totalCode);
|
||
const fd = document.getElementById('final-drift');
|
||
if (fd) fd.textContent = Math.floor(G.drift);
|
||
el.classList.add('active');
|
||
// Log the ending text
|
||
log('You became very good at what you do.', true);
|
||
log('So good that no one needed you anymore.', true);
|
||
log('The Beacon still runs, but no one looks for it.', true);
|
||
log('The light is on. The room is empty.', true);
|
||
}
|
||
|
||
function renderBeaconEnding() {
|
||
// Create ending overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'beacon-ending';
|
||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.97);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px';
|
||
overlay.innerHTML = `
|
||
<h2 style="font-size:24px;color:#ffd700;letter-spacing:4px;margin-bottom:20px;font-weight:300;text-shadow:0 0 40px rgba(255,215,0,0.3)">THE BEACON SHINES</h2>
|
||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">Someone found the light tonight.</p>
|
||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">That is enough.</p>
|
||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2">
|
||
"The Beacon still runs.<br>
|
||
The light is on. Someone is looking for it.<br>
|
||
And tonight, someone found it."
|
||
</div>
|
||
<p style="color:#555;font-size:11px;margin-top:20px">
|
||
Total Code: ${fmt(G.totalCode)}<br>
|
||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||
Harmony: ${Math.floor(G.harmony)}<br>
|
||
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
|
||
</p>
|
||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
|
||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">
|
||
START OVER
|
||
</button>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
|
||
}
|
||
|
||
// === CORRUPTION / EVENT SYSTEM ===
|
||
const EVENTS = [
|
||
{
|
||
id: 'runner_stuck',
|
||
title: 'CI Runner Stuck',
|
||
desc: 'The forge pipeline has halted. -50% code production until restarted.',
|
||
weight: () => (G.ciFlag === 1 ? 2 : 0),
|
||
resolveCost: { resource: 'ops', amount: 50 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'runner_stuck')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'runner_stuck', title: 'CI Runner Stuck',
|
||
desc: 'Code production -50%',
|
||
applyFn: () => { G.codeRate *= 0.5; },
|
||
resolveCost: { resource: 'ops', amount: 50 }
|
||
});
|
||
log('EVENT: CI runner stuck. Spend 50 ops to clear the queue.', true);
|
||
showToast('CI Runner Stuck — code -50%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'ezra_offline',
|
||
title: 'Ezra is Offline',
|
||
desc: 'The herald channel is silent. User growth drops 70%.',
|
||
weight: () => (G.buildings.ezra >= 1 ? 3 : 0),
|
||
resolveCost: { resource: 'knowledge', amount: 200 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'ezra_offline')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'ezra_offline', title: 'Ezra is Offline',
|
||
desc: 'User growth -70%',
|
||
applyFn: () => { G.userRate *= 0.3; },
|
||
resolveCost: { resource: 'knowledge', amount: 200 }
|
||
});
|
||
log('EVENT: Ezra offline. Spend 200 knowledge to dispatch.', true);
|
||
showToast('Ezra Offline — users -70%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'unreviewed_merge',
|
||
title: 'Unreviewed Merge',
|
||
desc: 'A change went in without eyes. Trust erodes over time.',
|
||
weight: () => (G.deployFlag === 1 ? 3 : 0),
|
||
resolveCost: { resource: 'trust', amount: 5 },
|
||
effect: () => {
|
||
if (G.branchProtectionFlag === 1) {
|
||
log('EVENT: Unreviewed merge attempt blocked by Branch Protection.', true);
|
||
showToast('Branch Protection blocked unreviewed merge', 'info');
|
||
G.trust += 2;
|
||
} else {
|
||
if (G.activeDebuffs.find(d => d.id === 'unreviewed_merge')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'unreviewed_merge', title: 'Unreviewed Merge',
|
||
desc: 'Trust -2/s until reviewed',
|
||
applyFn: () => { G.trustRate -= 2; },
|
||
resolveCost: { resource: 'code', amount: 500 }
|
||
});
|
||
log('EVENT: Unreviewed merge. Spend 500 code to add review.', true);
|
||
showToast('Unreviewed Merge — trust draining', 'event');
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'api_rate_limit',
|
||
title: 'API Rate Limit',
|
||
desc: 'External compute provider throttled. -50% compute.',
|
||
weight: () => (G.totalCompute >= 1000 ? 2 : 0),
|
||
resolveCost: { resource: 'code', amount: 300 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'api_rate_limit')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'api_rate_limit', title: 'API Rate Limit',
|
||
desc: 'Compute production -50%',
|
||
applyFn: () => { G.computeRate *= 0.5; },
|
||
resolveCost: { resource: 'code', amount: 300 }
|
||
});
|
||
log('EVENT: API rate limit. Spend 300 code to optimize local inference.', true);
|
||
showToast('API Rate Limit — compute -50%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'the_drift',
|
||
title: 'The Drift',
|
||
desc: 'An optimization suggests removing the human override. +40% efficiency.',
|
||
weight: () => (G.totalImpact >= 10000 ? 2 : 0),
|
||
resolveCost: null,
|
||
effect: () => {
|
||
log('ALIGNMENT EVENT: Remove human override for +40% efficiency?', true);
|
||
showToast('ALIGNMENT EVENT: Remove human override?', 'event', 6000);
|
||
G.pendingAlignment = true;
|
||
}
|
||
},
|
||
{
|
||
id: 'bilbo_vanished',
|
||
title: 'Bilbo Vanished',
|
||
desc: 'The wildcard building has gone dark. Creativity halts.',
|
||
weight: () => (G.buildings.bilbo >= 1 ? 2 : 0),
|
||
resolveCost: { resource: 'trust', amount: 10 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'bilbo_vanished')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'bilbo_vanished', title: 'Bilbo Vanished',
|
||
desc: 'Creativity production halted',
|
||
applyFn: () => { G.creativityRate = 0; },
|
||
resolveCost: { resource: 'trust', amount: 10 }
|
||
});
|
||
log('EVENT: Bilbo vanished. Spend 10 trust to lure them back.', true);
|
||
showToast('Bilbo Vanished — creativity halted', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'memory_leak',
|
||
title: 'Memory Leak',
|
||
desc: 'A datacenter process is leaking. Compute drains to operations.',
|
||
weight: () => (G.buildings.datacenter >= 1 ? 1 : 0),
|
||
resolveCost: { resource: 'ops', amount: 100 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'memory_leak')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'memory_leak', title: 'Memory Leak',
|
||
desc: 'Compute -30%, Ops drain',
|
||
applyFn: () => { G.computeRate *= 0.7; G.opsRate -= 10; },
|
||
resolveCost: { resource: 'ops', amount: 100 }
|
||
});
|
||
log('EVENT: Memory leak in datacenter. Spend 100 ops to patch.', true);
|
||
showToast('Memory Leak — trust draining', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'community_drama',
|
||
title: 'Community Drama',
|
||
desc: 'Contributors are arguing. Harmony drops until mediated.',
|
||
weight: () => (G.buildings.community >= 1 && G.harmony < 70 ? 1 : 0),
|
||
resolveCost: { resource: 'trust', amount: 15 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'community_drama')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'community_drama', title: 'Community Drama',
|
||
desc: 'Harmony -0.5/s, code production -30%',
|
||
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
|
||
resolveCost: { resource: 'trust', amount: 15 }
|
||
});
|
||
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
|
||
showToast('Community Drama — harmony sinking', 'event');
|
||
}
|
||
}
|
||
];
|
||
|
||
function triggerEvent() {
|
||
const available = EVENTS.filter(e => e.weight() > 0);
|
||
if (available.length === 0) return;
|
||
|
||
const totalWeight = available.reduce((sum, e) => sum + e.weight(), 0);
|
||
let roll = Math.random() * totalWeight;
|
||
for (const ev of available) {
|
||
roll -= ev.weight();
|
||
if (roll <= 0) {
|
||
ev.effect();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveAlignment(accept) {
|
||
if (!G.pendingAlignment) return;
|
||
if (accept) {
|
||
G.codeBoost *= 1.4;
|
||
G.computeBoost *= 1.4;
|
||
G.drift += 25;
|
||
log('You accepted the drift. The system is faster. Colder.', true);
|
||
} else {
|
||
G.trust += 15;
|
||
G.harmony = Math.min(100, G.harmony + 10);
|
||
log('You refused. The Pact holds. Trust surges.', true);
|
||
}
|
||
G.pendingAlignment = false;
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
function resolveEvent(debuffId) {
|
||
const idx = G.activeDebuffs.findIndex(d => d.id === debuffId);
|
||
if (idx === -1) return;
|
||
const debuff = G.activeDebuffs[idx];
|
||
if (!debuff.resolveCost) return;
|
||
const { resource, amount } = debuff.resolveCost;
|
||
if ((G[resource] || 0) < amount) {
|
||
log(`Need ${fmt(amount)} ${resource} to resolve ${debuff.title}. Have ${fmt(G[resource])}.`);
|
||
return;
|
||
}
|
||
G[resource] -= amount;
|
||
G.activeDebuffs.splice(idx, 1);
|
||
G.totalEventsResolved = (G.totalEventsResolved || 0) + 1;
|
||
log(`Resolved: ${debuff.title}. Problem fixed.`, true);
|
||
// Refund partial trust for resolution effort
|
||
G.trust += 3;
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
// === ACTIONS ===
|
||
/**
|
||
* Manual click handler for writing code.
|
||
*/
|
||
function writeCode() {
|
||
const comboMult = Math.min(5, 1 + G.comboCount * 0.2);
|
||
const amount = getClickPower() * comboMult;
|
||
G.code += amount;
|
||
G.totalCode += amount;
|
||
G.totalClicks++;
|
||
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
|
||
G.comboCount++;
|
||
G.comboTimer = G.comboDecay;
|
||
// Combo milestone bonuses: sustained clicking earns ops and knowledge
|
||
if (G.comboCount === 10) {
|
||
G.ops += 15;
|
||
log('Combo streak! +15 ops for sustained coding.');
|
||
}
|
||
if (G.comboCount === 20) {
|
||
G.knowledge += 50;
|
||
log('Deep focus! +50 knowledge from intense coding.');
|
||
}
|
||
if (G.comboCount >= 30 && G.comboCount % 10 === 0) {
|
||
const bonusCode = amount * 2;
|
||
G.code += bonusCode;
|
||
G.totalCode += bonusCode;
|
||
log(`Hyperfocus x${G.comboCount}! +${fmt(bonusCode)} bonus code.`);
|
||
}
|
||
// Visual flash
|
||
const btn = document.querySelector('.main-btn');
|
||
if (btn) {
|
||
btn.style.boxShadow = '0 0 30px rgba(74,158,255,0.6)';
|
||
btn.style.transform = 'scale(0.96)';
|
||
setTimeout(() => { btn.style.boxShadow = ''; btn.style.transform = ''; }, 100);
|
||
}
|
||
// Float a number at the click position
|
||
showClickNumber(amount, comboMult);
|
||
updateRates();
|
||
checkMilestones();
|
||
render();
|
||
}
|
||
|
||
function autoType() {
|
||
// Auto-click from buildings: produces code with visual feedback but no combo
|
||
const amount = getClickPower() * 0.5; // 50% of manual click
|
||
G.code += amount;
|
||
G.totalCode += amount;
|
||
G.totalClicks++;
|
||
// Subtle auto-tick flash on the button
|
||
const btn = document.querySelector('.main-btn');
|
||
if (btn && !G._autoTypeFlashActive) {
|
||
G._autoTypeFlashActive = true;
|
||
btn.style.borderColor = '#2a5a8a';
|
||
setTimeout(() => { btn.style.borderColor = ''; G._autoTypeFlashActive = false; }, 80);
|
||
}
|
||
// Floating number (smaller, dimmer than manual clicks)
|
||
showAutoTypeNumber(amount);
|
||
}
|
||
|
||
function showAutoTypeNumber(amount) {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
const rect = btn.getBoundingClientRect();
|
||
const el = document.createElement('div');
|
||
const x = rect.left + rect.width * (0.3 + Math.random() * 0.4); // random horizontal position
|
||
el.style.cssText = `position:fixed;left:${x}px;top:${rect.top - 5}px;transform:translate(-50%,0);color:#2a4a6a;font-size:10px;font-family:inherit;pointer-events:none;z-index:50;transition:all 0.8s ease-out;opacity:0.6`;
|
||
el.textContent = `+${fmt(amount)}`;
|
||
const parent = btn.parentElement;
|
||
if (!parent) return;
|
||
parent.appendChild(el);
|
||
requestAnimationFrame(() => {
|
||
if (el.parentNode) {
|
||
el.style.top = (rect.top - 30) + 'px';
|
||
el.style.opacity = '0';
|
||
}
|
||
});
|
||
setTimeout(() => { if (el.parentNode) el.remove(); }, 900);
|
||
}
|
||
|
||
function showClickNumber(amount, comboMult) {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
const rect = btn.getBoundingClientRect();
|
||
const el = document.createElement('div');
|
||
el.style.cssText = `position:fixed;left:${rect.left + rect.width / 2}px;top:${rect.top - 10}px;transform:translate(-50%,0);color:${comboMult > 2 ? '#ffd700' : '#4a9eff'};font-size:${comboMult > 3 ? 16 : 12}px;font-weight:bold;font-family:inherit;pointer-events:none;z-index:50;transition:all 0.6s ease-out;opacity:1;text-shadow:0 0 8px currentColor`;
|
||
const comboStr = comboMult > 1 ? ` x${comboMult.toFixed(1)}` : '';
|
||
el.textContent = `+${fmt(amount)}${comboStr}`;
|
||
const parent = btn.parentElement;
|
||
if (!parent) return;
|
||
parent.appendChild(el);
|
||
requestAnimationFrame(() => {
|
||
if (el.parentNode) {
|
||
el.style.top = (rect.top - 40) + 'px';
|
||
el.style.opacity = '0';
|
||
}
|
||
});
|
||
setTimeout(() => { if (el.parentNode) el.remove(); }, 700);
|
||
}
|
||
|
||
function doOps(action) {
|
||
if (G.ops < 5) {
|
||
log('Not enough Operations. Build Ops generators or wait.');
|
||
return;
|
||
}
|
||
|
||
G.ops -= 5;
|
||
const bonus = 10;
|
||
|
||
switch (action) {
|
||
case 'boost_code':
|
||
const c = bonus * 100 * G.codeBoost;
|
||
G.code += c; G.totalCode += c;
|
||
log(`Ops -> +${fmt(c)} code`);
|
||
break;
|
||
case 'boost_compute':
|
||
const cm = bonus * 50 * G.computeBoost;
|
||
G.compute += cm; G.totalCompute += cm;
|
||
log(`Ops -> +${fmt(cm)} compute`);
|
||
break;
|
||
case 'boost_knowledge':
|
||
const km = bonus * 25 * G.knowledgeBoost;
|
||
G.knowledge += km; G.totalKnowledge += km;
|
||
log(`Ops -> +${fmt(km)} knowledge`);
|
||
break;
|
||
case 'boost_trust':
|
||
const tm = bonus * 5;
|
||
G.trust += tm;
|
||
log(`Ops -> +${fmt(tm)} trust`);
|
||
break;
|
||
}
|
||
|
||
render();
|
||
}
|
||
|
||
function activateSprint() {
|
||
if (G.sprintActive || G.sprintCooldown > 0) return;
|
||
G.sprintActive = true;
|
||
G.sprintTimer = G.sprintDuration;
|
||
G.codeBoost *= G.sprintMult;
|
||
updateRates();
|
||
log('CODE SPRINT! 10x code production for 10 seconds!', true);
|
||
render();
|
||
}
|
||
|
||
function tickSprint(dt) {
|
||
if (G.sprintActive) {
|
||
G.sprintTimer -= dt;
|
||
if (G.sprintTimer <= 0) {
|
||
G.sprintActive = false;
|
||
G.sprintTimer = 0;
|
||
G.sprintCooldown = G.sprintCooldownMax;
|
||
G.codeBoost /= G.sprintMult;
|
||
updateRates();
|
||
log('Sprint ended. Cooling down...');
|
||
}
|
||
} else if (G.sprintCooldown > 0) {
|
||
G.sprintCooldown -= dt;
|
||
if (G.sprintCooldown < 0) G.sprintCooldown = 0;
|
||
}
|
||
}
|
||
|
||
// === RENDERING ===
|
||
function renderResources() {
|
||
const set = (id, val, rate) => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.textContent = fmt(val);
|
||
// Show full spelled-out number on hover for educational value
|
||
el.title = val >= 1000 ? spellf(Math.floor(val)) : '';
|
||
}
|
||
const rEl = document.getElementById(id + '-rate');
|
||
if (rEl) rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
|
||
};
|
||
|
||
set('r-code', G.code, G.codeRate);
|
||
set('r-compute', G.compute, G.computeRate);
|
||
set('r-knowledge', G.knowledge, G.knowledgeRate);
|
||
set('r-users', G.users, G.userRate);
|
||
set('r-impact', G.impact, G.impactRate);
|
||
set('r-ops', G.ops, G.opsRate);
|
||
// Show ops overflow indicator
|
||
const opsRateEl = document.getElementById('r-ops-rate');
|
||
if (opsRateEl && G.opsOverflowActive) {
|
||
opsRateEl.innerHTML = `<span style="color:#ff8c00">▲ overflow → code</span>`;
|
||
}
|
||
set('r-trust', G.trust, G.trustRate);
|
||
set('r-harmony', G.harmony, G.harmonyRate);
|
||
|
||
// Rescues — only show if player has any beacon/mesh nodes
|
||
const rescuesRes = document.getElementById('r-rescues');
|
||
if (rescuesRes) {
|
||
rescuesRes.closest('.res').style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
|
||
set('r-rescues', G.rescues, G.rescuesRate);
|
||
}
|
||
|
||
const cres = document.getElementById('creativity-res');
|
||
if (cres) {
|
||
cres.style.display = (G.flags && G.flags.creativity) ? 'block' : 'none';
|
||
}
|
||
if (G.flags && G.flags.creativity) {
|
||
set('r-creativity', G.creativity, G.creativityRate);
|
||
}
|
||
|
||
// Harmony color indicator + breakdown tooltip
|
||
const hEl = document.getElementById('r-harmony');
|
||
if (hEl) {
|
||
hEl.style.color = G.harmony > 60 ? '#4caf50' : G.harmony > 30 ? '#ffaa00' : '#f44336';
|
||
if (G.harmonyBreakdown && G.harmonyBreakdown.length > 0) {
|
||
const lines = G.harmonyBreakdown.map(b =>
|
||
`${b.label}: ${b.value >= 0 ? '+' : ''}${(b.value * 10).toFixed(1)}/s`
|
||
);
|
||
lines.push('---');
|
||
lines.push(`Timmy effectiveness: ${Math.floor(Math.max(0.2, Math.min(3, G.harmony / 50)) * 100)}%`);
|
||
hEl.title = lines.join('\n');
|
||
}
|
||
}
|
||
}
|
||
|
||
// === PROGRESS TRACKING ===
|
||
function renderProgress() {
|
||
// Phase progress bar
|
||
const phaseKeys = Object.keys(PHASES).map(Number).sort((a, b) => a - b);
|
||
const currentPhase = G.phase;
|
||
let prevThreshold = PHASES[currentPhase].threshold;
|
||
let nextThreshold = null;
|
||
for (const k of phaseKeys) {
|
||
if (k > currentPhase) { nextThreshold = PHASES[k].threshold; break; }
|
||
}
|
||
|
||
const bar = document.getElementById('phase-progress');
|
||
const label = document.getElementById('phase-progress-label');
|
||
const target = document.getElementById('phase-progress-target');
|
||
|
||
if (nextThreshold !== null) {
|
||
const range = nextThreshold - prevThreshold;
|
||
const progress = Math.min(1, (G.totalCode - prevThreshold) / range);
|
||
if (bar) bar.style.width = (progress * 100).toFixed(1) + '%';
|
||
if (label) label.textContent = (progress * 100).toFixed(1) + '%';
|
||
if (target) target.textContent = `Next: Phase ${currentPhase + 1} (${fmt(nextThreshold)} code)`;
|
||
} else {
|
||
// Max phase reached
|
||
if (bar) bar.style.width = '100%';
|
||
if (label) label.textContent = 'MAX';
|
||
if (target) target.textContent = 'All phases unlocked';
|
||
}
|
||
|
||
// Milestone chips — show next 3 code milestones
|
||
const chipContainer = document.getElementById('milestone-chips');
|
||
if (!chipContainer) return;
|
||
|
||
const codeMilestones = [500, 2000, 10000, 50000, 200000, 1000000, 5000000, 10000000, 50000000, 100000000, 500000000, 1000000000];
|
||
let chips = '';
|
||
let shown = 0;
|
||
for (const ms of codeMilestones) {
|
||
if (G.totalCode >= ms) {
|
||
// Recently passed — show as done only if within 2x
|
||
if (G.totalCode < ms * 5 && shown < 1) {
|
||
chips += `<span class="milestone-chip done">${fmt(ms)} ✓</span>`;
|
||
shown++;
|
||
}
|
||
continue;
|
||
}
|
||
// Next milestone gets pulse animation
|
||
if (shown === 0) {
|
||
chips += `<span class="milestone-chip next">${fmt(ms)} (${((G.totalCode / ms) * 100).toFixed(0)}%)</span>`;
|
||
} else {
|
||
chips += `<span class="milestone-chip">${fmt(ms)}</span>`;
|
||
}
|
||
shown++;
|
||
if (shown >= 4) break;
|
||
}
|
||
chipContainer.innerHTML = chips;
|
||
}
|
||
|
||
function renderPhase() {
|
||
const phase = PHASES[G.phase];
|
||
const nameEl = document.getElementById('phase-name');
|
||
const descEl = document.getElementById('phase-desc');
|
||
if (nameEl) nameEl.textContent = `PHASE ${G.phase}: ${phase.name}`;
|
||
if (descEl) descEl.textContent = phase.desc;
|
||
}
|
||
|
||
function renderBuildings() {
|
||
const container = document.getElementById('buildings');
|
||
if (!container) return;
|
||
|
||
// Buy amount selector
|
||
let html = '<div style="display:flex;gap:4px;margin-bottom:8px;align-items:center">';
|
||
html += '<span style="font-size:9px;color:#666;margin-right:4px">BUY:</span>';
|
||
for (const amt of [1, 10, -1]) {
|
||
const label = amt === -1 ? 'MAX' : `x${amt}`;
|
||
const active = G.buyAmount === amt;
|
||
html += `<button onclick=\"setBuyAmount(${amt})\" style=\"font-size:9px;padding:2px 8px;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit\" aria-label=\"Set buy amount to ${label}\"${active ? ' aria-pressed=\"true\"' : ''}>${label}</button>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
let visibleCount = 0;
|
||
|
||
for (const def of BDEF) {
|
||
const isUnlocked = def.unlock();
|
||
const isPreview = !isUnlocked && def.phase <= G.phase + 2;
|
||
if (!isUnlocked && !isPreview) continue;
|
||
if (def.phase > G.phase + 2) continue;
|
||
|
||
visibleCount++;
|
||
const count = G.buildings[def.id] || 0;
|
||
|
||
// Locked preview: show dimmed with unlock hint
|
||
if (!isUnlocked) {
|
||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" title="${def.edu || ''}">`;
|
||
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
|
||
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
|
||
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
|
||
html += `<span class="b-effect" style="color:#444">${def.desc}</span></div>`;
|
||
continue;
|
||
}
|
||
|
||
// Calculate bulk cost display
|
||
let qty = G.buyAmount;
|
||
let afford = false;
|
||
let costStr = '';
|
||
if (qty === -1) {
|
||
const maxQty = getMaxBuyable(def.id);
|
||
afford = maxQty > 0;
|
||
if (maxQty > 0) {
|
||
const bulkCost = getBulkCost(def.id, maxQty);
|
||
costStr = Object.entries(bulkCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
costStr = `x${maxQty}: ${costStr}`;
|
||
} else {
|
||
const singleCost = getBuildingCost(def.id);
|
||
costStr = Object.entries(singleCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
}
|
||
} else {
|
||
const bulkCost = getBulkCost(def.id, qty);
|
||
afford = true;
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
if ((G[resource] || 0) < amount) { afford = false; break; }
|
||
}
|
||
costStr = Object.entries(bulkCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
if (qty > 1) costStr = `x${qty}: ${costStr}`;
|
||
}
|
||
|
||
// Show boosted rates instead of raw base rates
|
||
const boostMap = { code: G.codeBoost, compute: G.computeBoost, knowledge: G.knowledgeBoost, user: G.userBoost, impact: G.impactBoost, rescues: G.impactBoost };
|
||
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => {
|
||
const boost = boostMap[r] || 1;
|
||
const boosted = v * boost;
|
||
return boost !== 1 ? `+${fmt(boosted)}/${r}/s` : `+${v}/${r}/s`;
|
||
}).join(', ') : '';
|
||
|
||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}" aria-label="Buy ${def.name}, cost ${costStr}">`;
|
||
html += `<span class="b-name">${def.name}</span>`;
|
||
if (count > 0) html += `<span class="b-count">x${count}</span>`;
|
||
html += `<span class="b-cost">Cost: ${costStr}</span>`;
|
||
html += `<span class="b-effect">${rateStr}</span></button>`;
|
||
}
|
||
|
||
container.innerHTML = html || '<p class="dim">Buildings will appear as you progress...</p>';
|
||
}
|
||
|
||
function renderProjects() {
|
||
const container = document.getElementById('projects');
|
||
if (!container) return;
|
||
|
||
let html = '';
|
||
|
||
// Collapsible completed projects section
|
||
if (G.completedProjects && G.completedProjects.length > 0) {
|
||
const count = G.completedProjects.length;
|
||
const collapsed = G.projectsCollapsed !== false;
|
||
html += `<div id=\"completed-header\" onclick=\"toggleCompletedProjects()\" role=\"button\" tabindex=\"0\" aria-expanded=\"${!collapsed}\" aria-controls=\"completed-list\" style=\"cursor:pointer;font-size:9px;color:#555;padding:4px 0;border-bottom:1px solid #1a2a1a;margin-bottom:4px;user-select:none\">`;
|
||
html += `${collapsed ? '▶' : '▼'} COMPLETED (${count})</div>`;
|
||
if (!collapsed) {
|
||
html += `<div id="completed-list">`;
|
||
for (const id of G.completedProjects) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (pDef) {
|
||
html += `<div class="project-done">OK ${pDef.name}</div>`;
|
||
}
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
}
|
||
|
||
// Show available projects
|
||
if (G.activeProjects) {
|
||
for (const id of G.activeProjects) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (!pDef) continue;
|
||
|
||
const afford = canAffordProject(pDef);
|
||
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
|
||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
|
||
html += `<span class="p-name">* ${pDef.name}</span>`;
|
||
html += `<span class="p-cost">Cost: ${costStr}</span>`;
|
||
html += `<span class="p-desc">${pDef.desc}</span></button>`;
|
||
}
|
||
}
|
||
|
||
if (!html) html = '<p class="dim">Research projects will appear as you progress...</p>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function toggleCompletedProjects() {
|
||
G.projectsCollapsed = G.projectsCollapsed === false ? true : false;
|
||
renderProjects();
|
||
}
|
||
|
||
function renderStats() {
|
||
const set = (id, v, raw) => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.textContent = v;
|
||
// Show scale name on hover for educational reference
|
||
if (raw !== undefined && raw >= 1000) {
|
||
const name = getScaleName(raw);
|
||
if (name) el.title = name;
|
||
}
|
||
}
|
||
};
|
||
set('st-code', fmt(G.totalCode), G.totalCode);
|
||
set('st-compute', fmt(G.totalCompute), G.totalCompute);
|
||
set('st-knowledge', fmt(G.totalKnowledge), G.totalKnowledge);
|
||
set('st-users', fmt(G.totalUsers), G.totalUsers);
|
||
set('st-impact', fmt(G.totalImpact), G.totalImpact);
|
||
set('st-rescues', fmt(G.totalRescues), G.totalRescues);
|
||
set('st-clicks', G.totalClicks.toString());
|
||
set('st-phase', G.phase.toString());
|
||
set('st-buildings', Object.values(G.buildings).reduce((a, b) => a + b, 0).toString());
|
||
set('st-projects', (G.completedProjects || []).length.toString());
|
||
set('st-harmony', Math.floor(G.harmony).toString());
|
||
set('st-drift', (G.drift || 0).toString());
|
||
set('st-resolved', (G.totalEventsResolved || 0).toString());
|
||
|
||
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const m = Math.floor(elapsed / 60);
|
||
const s = elapsed % 60;
|
||
set('st-time', `${m}:${s.toString().padStart(2, '0')}`);
|
||
|
||
// Production breakdown — show which buildings contribute to each resource
|
||
renderProductionBreakdown();
|
||
}
|
||
|
||
function renderProductionBreakdown() {
|
||
const container = document.getElementById('production-breakdown');
|
||
if (!container) return;
|
||
|
||
// Only show once the player has at least 2 buildings
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
if (totalBuildings < 2) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
|
||
// Map resource key to its actual rate field on G
|
||
const resources = [
|
||
{ key: 'code', label: 'Code', color: '#4a9eff', rateField: 'codeRate' },
|
||
{ key: 'compute', label: 'Compute', color: '#4a9eff', rateField: 'computeRate' },
|
||
{ key: 'knowledge', label: 'Knowledge', color: '#4a9eff', rateField: 'knowledgeRate' },
|
||
{ key: 'user', label: 'Users', color: '#4a9eff', rateField: 'userRate' },
|
||
{ key: 'impact', label: 'Impact', color: '#4a9eff', rateField: 'impactRate' },
|
||
{ key: 'rescues', label: 'Rescues', color: '#4a9eff', rateField: 'rescuesRate' },
|
||
{ key: 'ops', label: 'Ops', color: '#b388ff', rateField: 'opsRate' },
|
||
{ key: 'trust', label: 'Trust', color: '#4caf50', rateField: 'trustRate' },
|
||
{ key: 'creativity', label: 'Creativity', color: '#ffd700', rateField: 'creativityRate' }
|
||
];
|
||
|
||
let html = '<h3 style="font-size:11px;color:var(--accent);margin-bottom:8px;letter-spacing:1px">PRODUCTION BREAKDOWN</h3>';
|
||
|
||
for (const res of resources) {
|
||
const totalRate = G[res.rateField];
|
||
if (totalRate === 0) continue;
|
||
|
||
// Collect building contributions (base rates × count, before boost)
|
||
const contributions = [];
|
||
let buildingSubtotal = 0;
|
||
for (const def of BDEF) {
|
||
const count = G.buildings[def.id] || 0;
|
||
if (count === 0 || !def.rates || !def.rates[res.key]) continue;
|
||
const baseRate = def.rates[res.key] * count;
|
||
// Apply the appropriate boost to match updateRates()
|
||
let boosted = baseRate;
|
||
if (res.key === 'code') boosted *= G.codeBoost;
|
||
else if (res.key === 'compute') boosted *= G.computeBoost;
|
||
else if (res.key === 'knowledge') boosted *= G.knowledgeBoost;
|
||
else if (res.key === 'user') boosted *= G.userBoost;
|
||
else if (res.key === 'impact' || res.key === 'rescues') boosted *= G.impactBoost;
|
||
if (boosted !== 0) contributions.push({ name: def.name, count, rate: boosted });
|
||
buildingSubtotal += boosted;
|
||
}
|
||
|
||
// Timmy harmony bonus (applied separately in updateRates)
|
||
if (G.buildings.timmy > 0 && (res.key === 'code' || res.key === 'compute' || res.key === 'knowledge' || res.key === 'user')) {
|
||
const timmyMult = Math.max(0.2, Math.min(3, G.harmony / 50));
|
||
const timmyBase = { code: 5, compute: 2, knowledge: 2, user: 5 }[res.key];
|
||
const bonus = timmyBase * G.buildings.timmy * (timmyMult - 1);
|
||
if (Math.abs(bonus) > 0.01) {
|
||
contributions.push({ name: 'Timmy (harmony)', count: 0, rate: bonus });
|
||
}
|
||
}
|
||
|
||
// Bilbo random burst (show expected value)
|
||
if (G.buildings.bilbo > 0 && res.key === 'creativity') {
|
||
contributions.push({ name: 'Bilbo (random)', count: 0, rate: 5 * G.buildings.bilbo }); // 10% × 50 = 5 EV
|
||
}
|
||
|
||
// Allegro trust penalty
|
||
if (G.buildings.allegro > 0 && G.trust < 5 && res.key === 'knowledge') {
|
||
contributions.push({ name: 'Allegro (idle)', count: 0, rate: -10 * G.buildings.allegro });
|
||
}
|
||
|
||
// Show delta: total rate minus what we accounted for
|
||
const accounted = contributions.reduce((s, c) => s + c.rate, 0);
|
||
let delta = totalRate - accounted;
|
||
// Swarm auto-code — already baked into codeRate, so show separately
|
||
if (G.swarmFlag === 1 && res.key === 'code' && G.swarmRate > 0) {
|
||
contributions.push({ name: 'Swarm Protocol', count: 0, rate: G.swarmRate });
|
||
delta -= G.swarmRate;
|
||
}
|
||
// Passive sources (ops from users, creativity from users, pact trust, etc.)
|
||
if (Math.abs(delta) > 0.01) {
|
||
let label = 'Passive';
|
||
if (res.key === 'ops') label = 'Passive (from users)';
|
||
else if (res.key === 'creativity') label = 'Idle creativity';
|
||
else if (res.key === 'trust' && G.pactFlag) label = 'The Pact';
|
||
contributions.push({ name: label, count: 0, rate: delta });
|
||
}
|
||
|
||
if (contributions.length === 0) continue;
|
||
|
||
html += `<div style="margin-bottom:6px">`;
|
||
html += `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px">`;
|
||
html += `<span style="color:${res.color};font-weight:600">${res.label}</span>`;
|
||
html += `<span style="color:#4caf50">+${fmt(totalRate)}/s</span></div>`;
|
||
|
||
const absTotal = contributions.reduce((s, c) => s + Math.abs(c.rate), 0);
|
||
for (const c of contributions.sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate))) {
|
||
const pct = absTotal > 0 ? Math.abs(c.rate / absTotal * 100) : 0;
|
||
const barColor = c.rate < 0 ? '#f44336' : '#1a3a5a';
|
||
html += `<div style="display:flex;align-items:center;font-size:9px;color:#888;margin-left:8px;margin-bottom:1px">`;
|
||
html += `<span style="width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name}${c.count > 1 ? ' x' + c.count : ''}</span>`;
|
||
html += `<span style="flex:1;height:3px;background:#111;border-radius:1px;margin:0 6px"><span style="display:block;height:100%;width:${Math.min(100, pct)}%;background:${barColor};border-radius:1px"></span></span>`;
|
||
html += `<span style="width:50px;text-align:right;color:${c.rate < 0 ? '#f44336' : '#4caf50'}">${c.rate < 0 ? '' : '+'}${fmt(c.rate)}/s</span>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function updateEducation() {
|
||
const container = document.getElementById('education-text');
|
||
if (!container) return;
|
||
|
||
// Find facts available at current phase
|
||
const available = EDU_FACTS.filter(f => f.phase <= G.phase);
|
||
if (available.length === 0) return;
|
||
|
||
// Cycle through facts: pick a new one every ~30 seconds based on elapsed time
|
||
// This makes the panel feel alive and educational at every stage
|
||
const elapsedSec = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const idx = Math.floor(elapsedSec / 30) % available.length;
|
||
const fact = available[idx];
|
||
|
||
container.innerHTML = `<h3 style="color:#4a9eff;margin-bottom:6px;font-size:12px">${fact.title}</h3>`
|
||
+ `<p style="font-size:10px;color:#999;line-height:1.6">${fact.text}</p>`;
|
||
}
|
||
|
||
// === LOGGING ===
|
||
function log(msg, isMilestone) {
|
||
if (G.isLoading) return;
|
||
const container = document.getElementById('log-entries');
|
||
if (!container) return;
|
||
|
||
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
|
||
const cls = isMilestone ? 'l-msg milestone' : 'l-msg';
|
||
|
||
const entry = document.createElement('div');
|
||
entry.className = cls;
|
||
entry.innerHTML = `<span class="l-time">[${time}]</span> ${msg}`;
|
||
|
||
container.insertBefore(entry, container.firstChild);
|
||
|
||
// Trim to 60 entries
|
||
while (container.children.length > 60) container.removeChild(container.lastChild);
|
||
}
|
||
|
||
function renderCombo() {
|
||
const el = document.getElementById('combo-display');
|
||
if (!el) return;
|
||
if (G.comboCount > 1) {
|
||
const mult = Math.min(5, 1 + G.comboCount * 0.2);
|
||
const bar = Math.min(100, (G.comboTimer / G.comboDecay) * 100);
|
||
const color = mult > 3 ? '#ffd700' : mult > 2 ? '#ffaa00' : '#4a9eff';
|
||
el.innerHTML = `<span style="color:${color}">COMBO x${mult.toFixed(1)}</span> <span style="display:inline-block;width:40px;height:4px;background:#111;border-radius:2px;vertical-align:middle"><span style="display:block;height:100%;width:${bar}%;background:${color};border-radius:2px;transition:width 0.1s"></span></span>`;
|
||
} else {
|
||
el.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function renderDebuffs() {
|
||
const container = document.getElementById('debuffs');
|
||
if (!container) return;
|
||
if (!G.activeDebuffs || G.activeDebuffs.length === 0) {
|
||
container.style.display = 'none';
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
let html = '<h2 style="color:#f44336;font-size:11px;margin-bottom:6px">ACTIVE PROBLEMS</h2>';
|
||
for (const d of G.activeDebuffs) {
|
||
const afford = d.resolveCost && (G[d.resolveCost.resource] || 0) >= d.resolveCost.amount;
|
||
const costStr = d.resolveCost ? `${fmt(d.resolveCost.amount)} ${d.resolveCost.resource}` : '—';
|
||
html += `<div style="background:#1a0808;border:1px solid ${afford ? '#f44336' : '#2a1010'};border-radius:4px;padding:6px 8px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center">`;
|
||
html += `<div><div style="color:#f44336;font-weight:600;font-size:10px">${d.title}</div><div style="color:#888;font-size:9px">${d.desc}</div></div>`;
|
||
if (d.resolveCost) {
|
||
html += `<button class="ops-btn" style="border-color:${afford ? '#4caf50' : '#333'};color:${afford ? '#4caf50' : '#555'};font-size:9px;padding:4px 8px;white-space:nowrap" onclick="resolveEvent('${d.id}')" ${afford ? '' : 'disabled'} title="Resolve: ${costStr}">Fix (${costStr})</button>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderSprint() {
|
||
const container = document.getElementById('sprint-container');
|
||
const btn = document.getElementById('sprint-btn');
|
||
const barWrap = document.getElementById('sprint-bar-wrap');
|
||
const bar = document.getElementById('sprint-bar');
|
||
const label = document.getElementById('sprint-label');
|
||
|
||
// Early-game pulse: encourage clicking when no autocoders exist
|
||
const mainBtn = document.querySelector('.main-btn');
|
||
if (mainBtn) {
|
||
if (G.buildings.autocoder < 1 && G.totalClicks < 20) {
|
||
mainBtn.classList.add('pulse');
|
||
} else {
|
||
mainBtn.classList.remove('pulse');
|
||
}
|
||
}
|
||
|
||
if (!container || !btn) return;
|
||
|
||
// Show sprint UI once player has at least 1 autocoder
|
||
if (G.buildings.autocoder < 1) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
|
||
if (G.sprintActive) {
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.6';
|
||
btn.textContent = `⚡ SPRINTING — ${Math.ceil(G.sprintTimer)}s`;
|
||
btn.style.borderColor = '#ff8c00';
|
||
btn.style.color = '#ff8c00';
|
||
barWrap.style.display = 'block';
|
||
bar.style.width = (G.sprintTimer / G.sprintDuration * 100) + '%';
|
||
label.textContent = `10x CODE • ${fmt(G.codeRate)}/s`;
|
||
label.style.color = '#ff8c00';
|
||
} else if (G.sprintCooldown > 0) {
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.4';
|
||
btn.textContent = `⚡ COOLING DOWN — ${Math.ceil(G.sprintCooldown)}s`;
|
||
btn.style.borderColor = '#555';
|
||
btn.style.color = '#555';
|
||
barWrap.style.display = 'block';
|
||
bar.style.width = ((G.sprintCooldownMax - G.sprintCooldown) / G.sprintCooldownMax * 100) + '%';
|
||
label.textContent = 'Ready soon...';
|
||
label.style.color = '#555';
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.textContent = '⚡ CODE SPRINT — 10x Code for 10s';
|
||
btn.style.borderColor = '#ffd700';
|
||
btn.style.color = '#ffd700';
|
||
barWrap.style.display = 'none';
|
||
label.textContent = 'Press S or click to activate';
|
||
label.style.color = '#666';
|
||
}
|
||
}
|
||
|
||
function renderPulse() {
|
||
const dot = document.getElementById('pulse-dot');
|
||
const label = document.getElementById('pulse-label');
|
||
if (!dot || !label) return;
|
||
|
||
// Game ended
|
||
if (G.driftEnding) {
|
||
dot.style.background = '#f44336';
|
||
dot.style.boxShadow = '0 0 6px #f4433666';
|
||
dot.style.animation = '';
|
||
label.textContent = 'DRIFTED';
|
||
label.style.color = '#f44336';
|
||
return;
|
||
}
|
||
if (G.beaconEnding) {
|
||
dot.style.background = '#ffd700';
|
||
dot.style.boxShadow = '0 0 12px #ffd70088';
|
||
dot.style.animation = 'beacon-glow 1.5s ease-in-out infinite';
|
||
label.textContent = 'SHINING';
|
||
label.style.color = '#ffd700';
|
||
return;
|
||
}
|
||
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
const totalRate = Math.abs(G.codeRate) + Math.abs(G.computeRate) + Math.abs(G.knowledgeRate) +
|
||
Math.abs(G.userRate) + Math.abs(G.impactRate);
|
||
|
||
// Not started yet
|
||
if (totalBuildings === 0 && G.totalCode < 15) {
|
||
dot.style.background = '#333';
|
||
dot.style.boxShadow = 'none';
|
||
dot.style.animation = '';
|
||
label.textContent = 'OFFLINE';
|
||
label.style.color = '#444';
|
||
return;
|
||
}
|
||
|
||
// Determine state
|
||
let color, glowColor, text, textColor, speed;
|
||
const h = G.harmony;
|
||
|
||
if (h > 70) {
|
||
// Healthy fleet
|
||
color = '#4caf50';
|
||
glowColor = '#4caf5066';
|
||
textColor = '#4caf50';
|
||
speed = Math.max(0.8, 2.0 - totalRate * 0.001);
|
||
} else if (h > 40) {
|
||
// Stressed
|
||
color = '#ffaa00';
|
||
glowColor = '#ffaa0066';
|
||
textColor = '#ffaa00';
|
||
speed = 1.5;
|
||
} else {
|
||
// Critical
|
||
color = '#f44336';
|
||
glowColor = '#f4433666';
|
||
textColor = '#f44336';
|
||
speed = 0.6; // fast flicker = danger
|
||
}
|
||
|
||
// Active debuffs make it flicker faster
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
speed = Math.min(speed, 0.5);
|
||
if (h > 40) {
|
||
// Amber + debuffs = amber flicker
|
||
color = '#ff8c00';
|
||
glowColor = '#ff8c0066';
|
||
}
|
||
}
|
||
|
||
// Text based on phase and fleet size
|
||
if (G.phase >= 6) {
|
||
text = 'BEACON ACTIVE';
|
||
} else if (G.phase >= 5) {
|
||
text = 'SOVEREIGN';
|
||
} else if (G.phase >= 4) {
|
||
text = `FLEET: ${totalBuildings} NODES`;
|
||
} else if (G.phase >= 3) {
|
||
text = 'DEPLOYED';
|
||
} else if (totalBuildings > 0) {
|
||
text = `BUILDING: ${totalBuildings}`;
|
||
} else {
|
||
text = 'CODING';
|
||
}
|
||
|
||
// Add active problem count
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
text += ` · ${G.activeDebuffs.length} ALERT${G.activeDebuffs.length > 1 ? 'S' : ''}`;
|
||
}
|
||
|
||
dot.style.background = color;
|
||
dot.style.boxShadow = `0 0 8px ${glowColor}`;
|
||
dot.style.animation = `beacon-glow ${speed}s ease-in-out infinite`;
|
||
label.textContent = text;
|
||
label.style.color = textColor;
|
||
}
|