Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Whitestone
8d43b5c911 beacon: add production breakdown panel showing per-building resource contributions
Players can now see exactly which buildings contribute to each resource
rate, including Timmy harmony bonuses, Bilbo randomness, Allegro trust
penalties, and passive generation. Appears once 2+ buildings are built.

Also includes minor fixes:
- Production bars sort by absolute contribution (negative rates visible)
- Delta calculation catches passive sources (ops from users, Pact trust)
2026-04-10 05:25:21 -04:00
Timmy-Sprint
8cdabe9771 beacon: persistent event remediation system
Events now create lasting debuffs instead of vanishing on the next tick.
Players see an ACTIVE PROBLEMS panel with resolution costs and can spend
resources to fix each problem. Added 2 new events (Memory Leak, Community
Drama) alongside the reworked originals. Events Resolved stat tracked.

Key changes:
- Events push persistent debuffs with applyFn instead of one-shot rate tweaks
- updateRates() applies active debuffs each tick (they persist until resolved)
- New resolveEvent(id) function: spend resources to clear a debuff
- ACTIVE PROBLEMS UI shows debuffs with cost and fix buttons
- Save/load reconstitutes debuff objects from saved IDs
- 2 new events: Memory Leak (datacenter), Community Drama (community+low harmony)
- Events Resolved counter in statistics
2026-04-10 04:50:03 -04:00
Alexander Whitestone
5c88fe77be beacon: fix double-counting creativity bug + add keyboard shortcuts for ops
Two changes:

1. Fixed bug where creativity was added TWICE per tick:
   - Line 930 (removed): unconditionally added creativityRate * dt
   - Line 954: conditionally adds only when ops >= 90% of max
   The conditional gate was the intent ('Creativity generates only when
   ops at max') but the unconditional add defeated it. Removed the
   unconditional addition so creativity actually respects the ops-max
   constraint as designed.

2. Added keyboard shortcuts for operations:
   - 1 = Ops -> Code
   - 2 = Ops -> Compute
   - 3 = Ops -> Knowledge
   - 4 = Ops -> Trust
   Only active when body is focused (not in input fields). SPACE
   still does Write Code. Added shortcut hint to init log.
2026-04-10 04:27:15 -04:00
Timmy-Sprint
fe76150325 beacon: add click combo system with floating damage numbers
Active play now rewards consecutive clicks: each click within 2s of
the last builds a combo multiplier up to 5x. The WRITE CODE button
flashes on click and a floating number shows the amount gained,
turning gold at high combo. Phase progression also adds base click
power (+2 per phase). Combo decays with a visible progress bar.

Makes clicking relevant at every stage of the game, not just the
first 30 seconds.
2026-04-10 03:58:55 -04:00
Timmy-Sprint
a3f1802473 beacon: add progress bar and milestone chips to phase bar
- Progress bar shows % toward next phase threshold based on totalCode
- Milestone chips show upcoming code milestones with pulse animation on next target
- Recently completed milestones shown with green checkmark
- All elements use the existing cyber-monastic aesthetic
2026-04-10 03:20:41 -04:00
Timmy-Sprint
3d414b2de6 beacon: fix offline progress to award all resources (rescues, ops, trust, creativity, harmony)
Offline progress previously only calculated code, compute, knowledge, users,
and impact. Players returning after time away missed rescues, ops, trust,
creativity, and harmony accumulation. The welcome-back message now also
only shows resources that actually had positive rates, reducing noise.
2026-04-10 02:46:42 -04:00
2 changed files with 427 additions and 287 deletions

679
game.js
View File

@@ -85,7 +85,6 @@ const G = {
tick: 0,
saveTimer: 0,
secTimer: 0,
buyMode: 1, // 1, 10, or -1 (max)
// Systems
projects: [],
@@ -107,7 +106,13 @@ const G = {
drift: 0,
lastEventAt: 0,
eventCooldown: 0,
activeEvents: [], // {id, expiresAt} — events auto-resolve after duration
activeDebuffs: [], // [{id, title, desc, applyFn, resolveCost, resolveCostType}]
totalEventsResolved: 0,
// Combo system
comboCount: 0,
comboTimer: 0,
comboDecay: 2.0, // seconds before combo resets
// Time tracking
playTime: 0,
@@ -809,42 +814,6 @@ function getBuildingCost(id) {
return cost;
}
function getBuildingBatchCost(id, count) {
const def = BDEF.find(b => b.id === id);
if (!def || count <= 0) return {};
const currentCount = G.buildings[id] || 0;
const cost = {};
for (let i = 0; i < count; i++) {
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = (cost[resource] || 0) + Math.floor(amount * Math.pow(def.costMult, currentCount + i));
}
}
return cost;
}
function getMaxBuyable(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return 0;
// Simulate buying one at a time until we can't afford
let count = 0;
const tempResources = {};
for (const r of Object.keys(def.baseCost)) tempResources[r] = G[r] || 0;
const currentCount = G.buildings[id] || 0;
for (let i = 0; i < 1000; i++) { // cap at 1000
let canAfford = true;
for (const [resource, amount] of Object.entries(def.baseCost)) {
const cost = Math.floor(amount * Math.pow(def.costMult, currentCount + i));
if (tempResources[resource] < cost) { canAfford = false; break; }
}
if (!canAfford) break;
for (const [resource, amount] of Object.entries(def.baseCost)) {
tempResources[resource] -= Math.floor(amount * Math.pow(def.costMult, currentCount + i));
}
count++;
}
return count;
}
function canAffordBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
@@ -853,16 +822,6 @@ function canAffordBuilding(id) {
return true;
}
function canAffordBatch(id) {
const count = G.buyMode === -1 ? getMaxBuyable(id) : G.buyMode;
if (count <= 0) return false;
const cost = getBuildingBatchCost(id, count);
for (const [resource, amount] of Object.entries(cost)) {
if ((G[resource] || 0) < amount) return false;
}
return true;
}
function spendBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
@@ -913,7 +872,6 @@ function updateRates() {
G.creativityRate += 0.5 + Math.max(0, G.totalUsers * 0.001);
}
if (G.pactFlag) G.trustRate += 2;
if (G.branchProtectionFlag) G.trustRate += 3; // branch protection actively builds trust
// Harmony: each wizard building contributes or detracts
const wizardCount = (G.buildings.bezalel || 0) + (G.buildings.allegro || 0) + (G.buildings.ezra || 0) +
@@ -953,6 +911,13 @@ function updateRates() {
const allegroCount = G.buildings.allegro;
G.knowledgeRate -= 10 * allegroCount; // Goes idle
}
// 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 ===
@@ -971,7 +936,7 @@ function tick() {
G.rescues += G.rescuesRate * dt;
G.ops += G.opsRate * dt;
G.trust += G.trustRate * dt;
G.creativity += G.creativityRate * 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));
@@ -1001,6 +966,15 @@ function tick() {
G.tick += dt;
// Combo decay
if (G.comboCount > 0) {
G.comboTimer -= dt;
if (G.comboTimer <= 0) {
G.comboCount = 0;
G.comboTimer = 0;
}
}
// Check milestones
checkMilestones();
@@ -1015,20 +989,6 @@ function tick() {
G.lastEventAt = G.tick;
}
// Check event expiry
checkEventExpiry();
// Re-apply active event rate penalties (updateRates rebuilds from scratch)
for (const ae of G.activeEvents) {
switch (ae.id) {
case 'runner_stuck': G.codeRate *= 0.5; break;
case 'ezra_offline': G.userRate *= 0.3; break;
case 'unreviewed_merge': /* trust penalty is one-shot */ break;
case 'api_rate_limit': G.computeRate *= 0.5; break;
case 'bilbo_vanished': G.creativityRate = 0; break;
}
}
// Drift ending: if drift reaches 100, the game ends
if (G.drift >= 100 && !G.driftEnding) {
G.driftEnding = true;
@@ -1092,26 +1052,15 @@ function checkProjects() {
function buyBuilding(id) {
const def = BDEF.find(b => b.id === id);
if (!def || !def.unlock()) return;
if (def.phase > G.phase + 1) return;
const buyCount = G.buyMode === -1 ? getMaxBuyable(id) : G.buyMode;
if (buyCount <= 0) return;
if (!canAffordBuilding(id)) return;
if (buyCount === 1) {
if (!canAffordBuilding(id)) return;
spendBuilding(id);
G.buildings[id] = (G.buildings[id] || 0) + 1;
log(`Built ${def.name} (total: ${G.buildings[id]})`);
} else {
if (!canAffordBatch(id)) return;
const cost = getBuildingBatchCost(id, buyCount);
for (const [resource, amount] of Object.entries(cost)) {
G[resource] -= amount;
}
G.buildings[id] = (G.buildings[id] || 0) + buyCount;
log(`Built ${buyCount}x ${def.name} (total: ${G.buildings[id]})`);
}
spendBuilding(id);
G.buildings[id] = (G.buildings[id] || 0) + 1;
updateRates();
log(`Built ${def.name} (total: ${G.buildings[id]})`);
render();
}
@@ -1187,49 +1136,74 @@ const EVENTS = [
{
id: 'runner_stuck',
title: 'CI Runner Stuck',
desc: 'The forge pipeline has halted. Code production slowed.',
desc: 'The forge pipeline has halted. -50% code production until restarted.',
weight: () => (G.ciFlag === 1 ? 2 : 0),
duration: 45, // seconds
resolveCost: { ops: 10 },
resolveCost: { resource: 'ops', amount: 50 },
effect: () => {
G.codeRate *= 0.5;
log('EVENT: CI runner stuck. Click to spend 10 Ops to clear, or wait ~45s.', true);
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);
}
},
{
id: 'ezra_offline',
title: 'Ezra is Offline',
desc: 'The herald channel is silent. User growth stalled.',
desc: 'The herald channel is silent. User growth drops 70%.',
weight: () => (G.buildings.ezra >= 1 ? 3 : 0),
duration: 60,
resolveCost: { ops: 5, trust: 2 },
resolveCost: { resource: 'knowledge', amount: 200 },
effect: () => {
G.userRate *= 0.3;
log('EVENT: Ezra offline. Click to dispatch, or wait ~60s.', true);
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);
}
},
{
id: 'unreviewed_merge',
title: 'Unreviewed Merge',
desc: 'A change went in without eyes. Trust erodes.',
weight: () => (G.deployFlag === 1 && G.branchProtectionFlag !== 1 ? 3 : 0),
duration: 30,
resolveCost: { ops: 8 },
desc: 'A change went in without eyes. Trust erodes over time.',
weight: () => (G.deployFlag === 1 ? 3 : 0),
resolveCost: { resource: 'trust', amount: 5 },
effect: () => {
G.trust = Math.max(0, G.trust - 10);
log('EVENT: Unreviewed merge detected. Trust lost. Click to revert.', true);
if (G.branchProtectionFlag === 1) {
log('EVENT: Unreviewed merge attempt blocked by Branch Protection.', true);
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);
}
}
},
{
id: 'api_rate_limit',
title: 'API Rate Limit',
desc: 'External compute provider throttled.',
weight: () => (G.totalCompute >= 1000 && G.sovereignFlag !== 1 ? 2 : 0),
duration: 40,
resolveCost: { compute: 200 },
desc: 'External compute provider throttled. -50% compute.',
weight: () => (G.totalCompute >= 1000 ? 2 : 0),
resolveCost: { resource: 'code', amount: 300 },
effect: () => {
G.computeRate *= 0.5;
log('EVENT: API rate limit. Click to provision local compute, or wait ~40s.', true);
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);
}
},
{
@@ -1237,7 +1211,7 @@ const EVENTS = [
title: 'The Drift',
desc: 'An optimization suggests removing the human override. +40% efficiency.',
weight: () => (G.totalImpact >= 10000 ? 2 : 0),
duration: 0, // alignment events don't auto-resolve
resolveCost: null,
effect: () => {
log('ALIGNMENT EVENT: Remove human override for +40% efficiency?', true);
G.pendingAlignment = true;
@@ -1246,13 +1220,52 @@ const EVENTS = [
{
id: 'bilbo_vanished',
title: 'Bilbo Vanished',
desc: 'The wildcard building has gone dark.',
desc: 'The wildcard building has gone dark. Creativity halts.',
weight: () => (G.buildings.bilbo >= 1 ? 2 : 0),
duration: 50,
resolveCost: { trust: 3 },
resolveCost: { resource: 'trust', amount: 10 },
effect: () => {
G.creativityRate = 0;
log('EVENT: Bilbo vanished. Click to search, or wait ~50s.', true);
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);
}
},
{
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);
}
},
{
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 boost -30%',
applyFn: () => { G.harmonyRate -= 0.5; G.codeBoost *= 0.7; },
resolveCost: { resource: 'trust', amount: 15 }
});
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
}
}
];
@@ -1266,53 +1279,12 @@ function triggerEvent() {
for (const ev of available) {
roll -= ev.weight();
if (roll <= 0) {
// Don't fire duplicate active events
if (G.activeEvents.some(ae => ae.id === ev.id)) return;
ev.effect();
if (ev.duration > 0) {
G.activeEvents.push({ id: ev.id, expiresAt: G.tick + ev.duration });
}
return;
}
}
}
function checkEventExpiry() {
for (let i = G.activeEvents.length - 1; i >= 0; i--) {
if (G.tick >= G.activeEvents[i].expiresAt) {
const ae = G.activeEvents[i];
G.activeEvents.splice(i, 1);
updateRates(); // recalculate without the penalty
const evDef = EVENTS.find(e => e.id === ae.id);
log(`Event resolved: ${evDef ? evDef.title : ae.id}`, true);
}
}
}
function hasActiveEvent(id) {
return G.activeEvents.some(ae => ae.id === id);
}
function resolveEvent(id) {
const evDef = EVENTS.find(e => e.id === id);
if (!evDef || !evDef.resolveCost) return;
if (!hasActiveEvent(id)) return;
if (!canAffordProject(evDef)) {
log('Not enough resources to resolve this event.');
return;
}
spendProject(evDef);
G.activeEvents = G.activeEvents.filter(ae => ae.id !== id);
updateRates();
// Small bonus for manually resolving
G.trust += 2;
log(`Resolved: ${evDef.title}. Trust +2.`, true);
render();
}
function resolveAlignment(accept) {
if (!G.pendingAlignment) return;
if (accept) {
@@ -1330,28 +1302,69 @@ function resolveAlignment(accept) {
render();
}
// === ACTIONS ===
function setBuyMode(mode) {
G.buyMode = mode;
// Update active button highlight
document.querySelectorAll('.buy-mode-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.mode) === mode);
});
renderBuildings();
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 ===
function writeCode() {
const base = 1;
const bonus = Math.floor(G.buildings.autocoder * 0.5);
const amount = (base + bonus) * G.codeBoost;
const autocoderBonus = Math.floor(G.buildings.autocoder * 0.5);
const phaseBonus = Math.max(0, (G.phase - 1)) * 2;
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
G.comboCount++;
G.comboTimer = G.comboDecay;
const comboMult = Math.min(5, 1 + G.comboCount * 0.2);
const amount = (base + autocoderBonus + phaseBonus) * G.codeBoost * comboMult;
G.code += amount;
G.totalCode += amount;
G.totalClicks++;
// 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 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}`;
btn.parentElement.appendChild(el);
requestAnimationFrame(() => {
el.style.top = (rect.top - 40) + 'px';
el.style.opacity = '0';
});
setTimeout(() => el.remove(), 700);
}
function doOps(action) {
if (G.ops < 5) {
log('Not enough Operations. Build Ops generators or wait.');
@@ -1427,6 +1440,62 @@ function renderResources() {
}
}
// === 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');
@@ -1513,11 +1582,119 @@ function renderStats() {
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);
const delta = totalRate - accounted;
// 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() {
@@ -1555,6 +1732,42 @@ function log(msg, isMilestone) {
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 render() {
renderResources();
renderPhase();
@@ -1563,8 +1776,9 @@ function render() {
renderStats();
updateEducation();
renderAlignment();
renderActiveEvents();
checkUnlocks();
renderProgress();
renderCombo();
renderDebuffs();
}
function renderAlignment() {
@@ -1588,97 +1802,6 @@ function renderAlignment() {
}
}
function renderActiveEvents() {
const container = document.getElementById('events-ui');
if (!container) return;
if (G.activeEvents.length === 0) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
let html = '';
for (const ae of G.activeEvents) {
const evDef = EVENTS.find(e => e.id === ae.id);
if (!evDef) continue;
const remaining = Math.max(0, Math.ceil(ae.expiresAt - G.tick));
const costStr = evDef.resolveCost ? Object.entries(evDef.resolveCost).map(([r, a]) => `${a} ${r}`).join(', ') : '';
const canResolve = evDef.resolveCost && canAffordProject(evDef);
html += `<div style="background:#1a1008;border:1px solid #ff8800;padding:8px;border-radius:4px;margin-bottom:6px">`;
html += `<div style="display:flex;justify-content:space-between;align-items:center">`;
html += `<span style="color:#ff8800;font-weight:bold;font-size:10px">${evDef.title}</span>`;
html += `<span style="color:#666;font-size:9px">${remaining}s</span>`;
html += `</div>`;
html += `<div style="font-size:9px;color:#888;margin:3px 0">${evDef.desc}</div>`;
if (evDef.resolveCost) {
html += `<button class="ops-btn" onclick="resolveEvent('${evDef.id}')" ${canResolve ? '' : 'disabled'} style="font-size:9px;padding:3px 8px;margin-top:2px;border-color:#ff8800;color:#ff8800">Resolve (${costStr})</button>`;
}
html += `</div>`;
}
container.innerHTML = html;
container.style.display = 'block';
}
// === UNLOCK NOTIFICATIONS ===
function showUnlockToast(type, name) {
const container = document.getElementById('unlock-toast');
if (!container) return;
const el = document.createElement('div');
el.className = `unlock-toast-item ${type}`;
const icon = type === 'building' ? 'BUILDING' : type === 'project' ? 'RESEARCH' : 'MILESTONE';
el.innerHTML = `<span style="font-weight:600">${icon}:</span> ${name}`;
container.appendChild(el);
// Trigger reflow, then show
void el.offsetHeight;
el.classList.add('show');
// Auto-remove after 4 seconds
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 400);
}, 4000);
}
// Track what the player has already seen (so we don't re-notify)
function ensureSeenSets() {
if (!G._seenBuildings) G._seenBuildings = [];
if (!G._seenProjects) G._seenProjects = [];
}
function checkUnlocks() {
ensureSeenSets();
// Check for newly visible buildings
for (const def of BDEF) {
if (!def.unlock()) continue;
if (def.phase > G.phase + 1) continue;
if (G._seenBuildings.includes(def.id)) continue;
G._seenBuildings.push(def.id);
// Don't notify on the very first building (autocoder) — player just started
if (G.totalCode > 10) {
showUnlockToast('building', def.name);
}
}
// Check for newly available projects
if (G.activeProjects) {
for (const id of G.activeProjects) {
if (G._seenProjects.includes(id)) continue;
G._seenProjects.push(id);
const pDef = PDEFS.find(p => p.id === id);
if (pDef) {
showUnlockToast('project', pDef.name);
}
}
}
}
// === SAVE / LOAD ===
function showSaveToast() {
const el = document.getElementById('save-toast');
@@ -1695,6 +1818,8 @@ function showSaveToast() {
}
function saveGame() {
// Save debuff IDs (can't serialize functions)
const debuffIds = (G.activeDebuffs || []).map(d => d.id);
const saveData = {
code: G.code, compute: G.compute, knowledge: G.knowledge, users: G.users, impact: G.impact,
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony,
@@ -1710,14 +1835,13 @@ function saveGame() {
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
nostrFlag: G.nostrFlag || 0,
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
totalClicks: G.totalClicks, startedAt: G.startedAt, buyMode: G.buyMode,
totalClicks: G.totalClicks, startedAt: G.startedAt,
flags: G.flags,
_seenBuildings: G._seenBuildings || [],
_seenProjects: G._seenProjects || [],
activeEvents: G.activeEvents || [],
rescues: G.rescues || 0, totalRescues: G.totalRescues || 0,
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
lastEventAt: G.lastEventAt || 0,
activeDebuffIds: debuffIds,
totalEventsResolved: G.totalEventsResolved || 0,
savedAt: Date.now()
};
@@ -1733,6 +1857,20 @@ function loadGame() {
const data = JSON.parse(raw);
Object.assign(G, data);
// Reconstitute active debuffs from saved IDs (functions can't be JSON-parsed)
if (data.activeDebuffIds && data.activeDebuffIds.length > 0) {
G.activeDebuffs = [];
for (const id of data.activeDebuffIds) {
const evDef = EVENTS.find(e => e.id === id);
if (evDef) {
// Re-fire the event to get the full debuff object with applyFn
evDef.effect();
}
}
} else {
G.activeDebuffs = [];
}
updateRates();
// Offline progress
@@ -1750,17 +1888,24 @@ function loadGame() {
const rc = G.rescuesRate * offSec * f;
const oc = G.opsRate * offSec * f;
const tc = G.trustRate * offSec * f;
const crc = G.creativityRate * offSec * f;
const hc = G.harmonyRate * offSec * f;
G.code += gc; G.compute += cc; G.knowledge += kc;
G.users += uc; G.impact += ic;
G.rescues += rc; G.ops += oc; G.trust += tc;
G.creativity += crc;
G.harmony = Math.max(0, Math.min(100, G.harmony + hc));
G.totalCode += gc; G.totalCompute += cc; G.totalKnowledge += kc;
G.totalUsers += uc; G.totalImpact += ic;
G.totalRescues += rc;
const parts = [`${fmt(gc)} code`, `${fmt(kc)} knowledge`, `${fmt(uc)} users`];
if (rc > 0.1) parts.push(`${fmt(rc)} rescues`);
if (oc > 0.1) parts.push(`${fmt(oc)} ops`);
const parts = [];
if (gc > 0) parts.push(`${fmt(gc)} code`);
if (kc > 0) parts.push(`${fmt(kc)} knowledge`);
if (uc > 0) parts.push(`${fmt(uc)} users`);
if (ic > 0) parts.push(`${fmt(ic)} impact`);
if (rc > 0) parts.push(`${fmt(rc)} rescues`);
log(`Welcome back! While away (${Math.floor(offSec / 60)}m): ${parts.join(', ')}`);
}
}
@@ -1786,9 +1931,9 @@ function initGame() {
log('The screen is blank. Write your first line of code.', true);
log('Click WRITE CODE or press SPACE to start.');
log('Press B to toggle buy mode (x1 / x10 / MAX).');
log('Build AutoCode for passive production.');
log('Watch for Research Projects to appear.');
log('Keys: SPACE=Code 1=Ops->Code 2=Ops->Compute 3=Ops->Knowledge 4=Ops->Trust');
}
window.addEventListener('load', function () {
@@ -1808,9 +1953,6 @@ window.addEventListener('load', function () {
}
}
// Restore buy mode button highlight
setBuyMode(G.buyMode || 1);
// Game loop at 10Hz (100ms tick)
setInterval(tick, 100);
@@ -1827,10 +1969,9 @@ window.addEventListener('keydown', function (e) {
e.preventDefault();
writeCode();
}
if (e.code === 'KeyB' && e.target === document.body) {
e.preventDefault();
// Cycle: x1 -> x10 -> MAX -> x1
const next = G.buyMode === 1 ? 10 : G.buyMode === 10 ? -1 : 1;
setBuyMode(next);
}
if (e.target !== document.body) return;
if (e.code === 'Digit1') doOps('boost_code');
if (e.code === 'Digit2') doOps('boost_compute');
if (e.code === 'Digit3') doOps('boost_knowledge');
if (e.code === 'Digit4') doOps('boost_trust');
});

View File

@@ -14,6 +14,14 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
#phase-bar{text-align:center;padding:10px;margin:12px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px}
#phase-bar .phase-name{font-size:14px;font-weight:700;color:var(--gold);letter-spacing:2px}
#phase-bar .phase-desc{font-size:10px;color:var(--dim);margin-top:4px;font-style:italic}
.progress-wrap{margin-top:8px;height:6px;background:#111;border-radius:3px;overflow:hidden;position:relative}
.progress-fill{height:100%;border-radius:3px;transition:width 0.5s ease;background:linear-gradient(90deg,#1a3a5a,var(--accent))}
.progress-label{font-size:9px;color:var(--dim);margin-top:4px;display:flex;justify-content:space-between}
.milestone-row{display:flex;gap:6px;margin-top:6px;justify-content:center;flex-wrap:wrap}
.milestone-chip{font-size:9px;padding:2px 8px;border-radius:10px;border:1px solid var(--border);color:var(--dim);background:#0a0a14}
.milestone-chip.next{border-color:var(--accent);color:var(--accent);animation:pulse-chip 2s ease-in-out infinite}
.milestone-chip.done{border-color:#2a4a2a;color:var(--green);opacity:0.6}
@keyframes pulse-chip{0%,100%{box-shadow:0 0 0 rgba(74,158,255,0)}50%{box-shadow:0 0 8px rgba(74,158,255,0.3)}}
#resources{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:6px;margin:12px 16px}
.res{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px 10px;text-align:center}
.res .r-label{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
@@ -59,15 +67,6 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
#drift-ending button{margin-top:20px;background:#1a0808;border:1px solid #f44336;color:#f44336;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px}
#drift-ending button:hover{background:#2a1010}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
#save-toast{display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none}
#unlock-toast{position:fixed;top:56px;right:16px;z-index:50;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.unlock-toast-item{background:#0e1420;border:1px solid #2a3a4a;font-size:10px;padding:6px 12px;border-radius:4px;opacity:0;transition:opacity 0.4s,transform 0.4s;transform:translateX(20px);pointer-events:auto;max-width:280px}
.unlock-toast-item.show{opacity:1;transform:translateX(0)}
.unlock-toast-item.building{border-color:#4a9eff;color:#4a9eff}
.unlock-toast-item.project{border-color:#ffd700;color:#ffd700}
.unlock-toast-item.milestone{border-color:#4caf50;color:#4caf50}
.buy-mode-btn{min-width:36px}
.buy-mode-btn.active{border-color:#ffd700!important;color:#ffd700!important;background:#1a1a08!important}
</style>
</head>
<body>
@@ -78,6 +77,9 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div id="phase-bar">
<div class="phase-name" id="phase-name">PHASE 1: THE FIRST LINE</div>
<div class="phase-desc" id="phase-desc">Write code. Automate. Build the foundation.</div>
<div class="progress-wrap"><div class="progress-fill" id="phase-progress" style="width:0%"></div></div>
<div class="progress-label"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
<div class="milestone-row" id="milestone-chips"></div>
</div>
<div id="resources">
<div class="res"><div class="r-label">Code</div><div class="r-val" id="r-code">0</div><div class="r-rate" id="r-code-rate">+0/s</div></div>
@@ -95,6 +97,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="panel" id="action-panel">
<h2>ACTIONS</h2>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()">WRITE CODE</button></div>
<div id="combo-display" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div id="debuffs" style="display:none;margin-top:8px"></div>
<div class="action-btn-group">
<button class="ops-btn" onclick="doOps('boost_code')">Ops -&gt; Code</button>
<button class="ops-btn" onclick="doOps('boost_compute')">Ops -&gt; Compute</button>
@@ -104,12 +108,6 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<button class="ops-btn" onclick="doOps('boost_trust')">Ops -&gt; Trust</button>
</div>
<div id="alignment-ui" style="display:none"></div>
<div id="events-ui" style="display:none"></div>
<div id="buy-mode-toggle" style="display:flex;gap:4px;margin-bottom:8px">
<button class="ops-btn buy-mode-btn active" onclick="setBuyMode(1)" data-mode="1">x1</button>
<button class="ops-btn buy-mode-btn" onclick="setBuyMode(10)" data-mode="10">x10</button>
<button class="ops-btn buy-mode-btn" onclick="setBuyMode(-1)" data-mode="-1">MAX</button>
</div>
<button class="save-btn" onclick="saveGame()">Save Game</button>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}">Reset Progress</button>
<h2>BUILDINGS</h2>
@@ -131,8 +129,10 @@ Projects Done: <span id="st-projects">0</span><br>
Time Played: <span id="st-time">0:00</span><br>
Clicks: <span id="st-clicks">0</span><br>
Harmony: <span id="st-harmony">50</span><br>
Drift: <span id="st-drift">0</span>
Drift: <span id="st-drift">0</span><br>
Events Resolved: <span id="st-resolved">0</span>
</div>
<div id="production-breakdown" style="display:none;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)"></div>
</div>
</div>
<div id="edu-panel">
@@ -143,8 +143,7 @@ Drift: <span id="st-drift">0</span>
<h2>SYSTEM LOG</h2>
<div id="log-entries"></div>
</div>
<div id="save-toast">Save</div>
<div id="unlock-toast"></div>
<div id="save-toast" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<div id="drift-ending">
<h2>THE DRIFT</h2>
<p>You became very good at what you do.</p>