Compare commits

..

11 Commits

Author SHA1 Message Date
Alexander Whitestone
7c3510a680 beacon: add progression toast notifications for phase/building/project unlocks
Animated toasts slide in from the right when:
- A new phase is reached (gold border)
- A building unlocks for the first time (blue border)
- A research project completes (green border, gold title for milestones)

Toasts auto-dismiss after 4s with fade-out. Max 4 visible at once.
seenBuildings state persisted in save data so toasts don't re-fire on reload.

This makes progression events impossible to miss — previously they only
showed in the scrolling system log.
2026-04-10 08:32:29 -04:00
6081844387 [auto-merge] Welcome Back popup
Auto-merged by PR review bot: Welcome Back popup
2026-04-10 11:48:34 +00:00
Timmy-Sprint
09b8c02307 beacon: add Welcome Back popup for offline gains + fix missing resource tracking
- Added modal popup showing detailed offline resource gains when player returns
- Fixed offline tracking to include ops, trust, and creativity (were silently missing)
- Popup shows all resources with color-coded labels and 50% efficiency note
- Log message now shows time in human-readable format (minutes/seconds)
2026-04-10 07:35:52 -04:00
Alexander Whitestone
9106d3f84c beacon: show locked buildings as dimmed previews up to 2 phases ahead
Previously, buildings from later phases were completely invisible until
unlocked. Players had no idea what was coming next. Now buildings up to
2 phases ahead appear as dimmed (25% opacity) locked entries showing:
- Name and lock icon
- Phase number and name
- Description text
- Education tooltip on hover

This gives players a roadmap of what they're building toward and creates
anticipation for future phases. The preview is a non-interactive div
(not a button) so it cannot be clicked.
2026-04-10 06:45:54 -04:00
3f02359748 Merge pull request 'burn: Add MemPalace Archive as late-game building (closes #25)' (#39) from burn/20260410-0423-25-mempalace-building into main
Merge PR #39: burn: Add MemPalace Archive as late-game building (closes #25)
2026-04-10 09:37:15 +00:00
85a146b690 Merge pull request 'burn: add favicon, meta tags, and social sharing cards (closes #13)' (#31) from burn/20260410-0052-13-static-site-meta into main
Merge PR #31: burn: add favicon, meta tags, and social sharing cards (closes #13)
2026-04-10 09:35:58 +00:00
cb2e48bf9a Merge pull request 'beacon: add production breakdown panel' (#42) from feature/production-breakdown into main
Merge PR #42: beacon: add production breakdown panel
2026-04-10 09:35:52 +00:00
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
931473e8f8 burn: Add MemPalace Archive as late-game building (closes #25)
- Added memPalace to buildings state object
- Added MemPalace Archive to BDEF with Phase 5 unlock
- Requires MemPalace v3 research project (mempalaceFlag) + 50k total knowledge
- Cost: 500k knowledge, 200k compute, 100 trust (1.25x scaling)
- Rates: +250 knowledge/s, +100 impact/s
- Educational tooltip on Memory Palace technique and LLM vector space analogy
- Building rates auto-applied via existing updateRates() loop
- Save/load handles new field via G.buildings serialization
2026-04-10 04:23:16 -04:00
Alexander Whitestone
612eb1f4d5 burn: add favicon, meta tags, and social sharing cards (closes #13)
- Inline SVG favicon (beacon emoji) — no external file needed
- Open Graph tags for link previews (title, description, type)
- Twitter Card meta for rich social sharing
- Theme-color for mobile browser chrome
- Meta description for search engines
2026-04-10 00:53:03 -04:00
2 changed files with 532 additions and 30 deletions

517
game.js
View File

@@ -58,7 +58,8 @@ const G = {
ezra: 0,
timmy: 0,
fenrir: 0,
bilbo: 0
bilbo: 0,
memPalace: 0
},
// Boost multipliers
@@ -106,12 +107,20 @@ const G = {
drift: 0,
lastEventAt: 0,
eventCooldown: 0,
activeDebuffs: [], // [{id, title, desc, applyFn, resolveCost, resolveCostType}]
totalEventsResolved: 0,
// Combo system
comboCount: 0,
comboTimer: 0,
comboDecay: 2.0, // seconds before combo resets
// Bulk buy multiplier (1, 10, or -1 for max)
buyAmount: 1,
// Track which buildings have had their unlock toasted
seenBuildings: {},
// Time tracking
playTime: 0,
startTime: 0
@@ -298,6 +307,14 @@ const BDEF = [
rates: { creativity: 1 },
unlock: () => G.totalUsers >= 100 && G.flags && G.flags.creativity, phase: 4,
edu: 'Bilbo is unpredictable. That is his value and his cost.'
},
{
id: 'memPalace', name: 'MemPalace Archive',
desc: 'Semantic memory. The AI remembers what matters and forgets what does not.',
baseCost: { knowledge: 500000, compute: 200000, trust: 100 }, costMult: 1.25,
rates: { knowledge: 250, impact: 100 },
unlock: () => G.totalKnowledge >= 50000 && G.mempalaceFlag === 1, phase: 5,
edu: 'The Memory Palace technique: attach information to spatial locations. LLMs use vector spaces the same way — semantic proximity = spatial proximity. MemPalace gives sovereign AI persistent, structured recall.'
}
];
@@ -812,6 +829,57 @@ function getBuildingCost(id) {
return cost;
}
function setBuyAmount(amt) {
G.buyAmount = amt;
render();
}
function getMaxBuyable(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return 0;
let count = G.buildings[id] || 0;
let bought = 0;
while (true) {
const cost = {};
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = Math.floor(amount * Math.pow(def.costMult, count));
}
let canAfford = true;
for (const [resource, amount] of Object.entries(cost)) {
if ((G[resource] || 0) < amount) { canAfford = false; break; }
}
if (!canAfford) break;
// Spend from temporary copy
for (const [resource, amount] of Object.entries(cost)) {
G[resource] -= amount;
}
count++;
bought++;
}
// Refund: add back what we spent
let count2 = G.buildings[id] || 0;
for (let i = 0; i < bought; i++) {
for (const [resource, amount] of Object.entries(def.baseCost)) {
G[resource] += Math.floor(amount * Math.pow(def.costMult, count2));
}
count2++;
}
return bought;
}
function getBulkCost(id, qty) {
const def = BDEF.find(b => b.id === id);
if (!def || qty <= 0) return {};
const count = G.buildings[id] || 0;
const cost = {};
for (let i = 0; i < qty; i++) {
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = (cost[resource] || 0) + Math.floor(amount * Math.pow(def.costMult, count + i));
}
}
return cost;
}
function canAffordBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
@@ -909,6 +977,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 ===
@@ -1000,6 +1075,21 @@ function tick() {
}
}
// === TOAST NOTIFICATIONS ===
function showToast(title, msg, type) {
const container = document.getElementById('toast-container');
if (!container) return;
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.innerHTML = `<div class="toast-title">${title}</div><div class="toast-msg">${msg}</div>`;
container.appendChild(el);
// Auto-dismiss after 4s
setTimeout(() => { el.classList.add('fade-out'); }, 4000);
setTimeout(() => { el.remove(); }, 4600);
// Cap visible toasts at 4
while (container.children.length > 4) container.removeChild(container.firstChild);
}
function checkMilestones() {
for (const m of MILESTONES) {
if (!G.milestones.includes(m.flag)) {
@@ -1017,6 +1107,7 @@ function checkMilestones() {
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}`, phase.desc, 'phase');
}
}
}
@@ -1043,15 +1134,31 @@ 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;
if (!canAffordBuilding(id)) 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;
}
}
spendBuilding(id);
G.buildings[id] = (G.buildings[id] || 0) + 1;
// 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();
log(`Built ${def.name} (total: ${G.buildings[id]})`);
const label = qty > 1 ? `x${qty}` : '';
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
render();
}
@@ -1073,6 +1180,8 @@ function buyProject(id) {
G.activeProjects = G.activeProjects.filter(aid => aid !== pDef.id);
}
showToast(pDef.milestone ? '★ ' + pDef.name : pDef.name, pDef.desc, 'project');
updateRates();
render();
}
@@ -1127,46 +1236,74 @@ const EVENTS = [
{
id: 'runner_stuck',
title: 'CI Runner Stuck',
desc: 'The forge pipeline has halted. Production slows until restarted.',
desc: 'The forge pipeline has halted. -50% code production until restarted.',
weight: () => (G.ciFlag === 1 ? 2 : 0),
resolveCost: { resource: 'ops', amount: 50 },
effect: () => {
G.codeRate *= 0.5;
log('EVENT: CI runner stuck. Spend ops to clear the queue.', 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 stalls.',
desc: 'The herald channel is silent. User growth drops 70%.',
weight: () => (G.buildings.ezra >= 1 ? 3 : 0),
resolveCost: { resource: 'knowledge', amount: 200 },
effect: () => {
G.userRate *= 0.3;
log('EVENT: Ezra offline. Dispatch required.', 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.',
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);
G.trust += 2;
} else {
G.trust = Math.max(0, G.trust - 10);
log('EVENT: Unreviewed merge detected. Trust lost.', true);
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.',
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 hit. Local compute insufficient.', 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);
}
},
{
@@ -1174,6 +1311,7 @@ const EVENTS = [
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);
G.pendingAlignment = true;
@@ -1182,11 +1320,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),
resolveCost: { resource: 'trust', amount: 10 },
effect: () => {
G.creativityRate = 0;
log('EVENT: Bilbo has vanished. Creativity halts.', 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);
}
}
];
@@ -1223,6 +1402,26 @@ function resolveAlignment(accept) {
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 ===
function writeCode() {
const base = 1;
@@ -1409,18 +1608,68 @@ function renderBuildings() {
const container = document.getElementById('buildings');
if (!container) return;
let html = '';
// 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">${label}</button>`;
}
html += '</div>';
let visibleCount = 0;
for (const def of BDEF) {
if (!def.unlock()) continue;
if (def.phase > G.phase + 1) continue;
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 cost = getBuildingCost(def.id);
const costStr = Object.entries(cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
const afford = canAffordBuilding(def.id);
const count = G.buildings[def.id] || 0;
// Toast on first unlock
if (isUnlocked && !G.seenBuildings[def.id]) {
G.seenBuildings[def.id] = true;
showToast('NEW: ' + def.name, def.desc, 'building');
}
// 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}`;
}
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => `+${v}/${r}/s`).join(', ') : '';
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}">`;
@@ -1483,11 +1732,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() {
@@ -1538,6 +1895,29 @@ function renderCombo() {
}
}
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();
@@ -1548,6 +1928,7 @@ function render() {
renderAlignment();
renderProgress();
renderCombo();
renderDebuffs();
}
function renderAlignment() {
@@ -1571,6 +1952,35 @@ function renderAlignment() {
}
}
// === OFFLINE GAINS POPUP ===
function showOfflinePopup(timeLabel, gains, offSec) {
const el = document.getElementById('offline-popup');
if (!el) return;
const timeEl = document.getElementById('offline-time-label');
if (timeEl) timeEl.textContent = `You were away for ${timeLabel}.`;
const listEl = document.getElementById('offline-gains-list');
if (listEl) {
let html = '';
for (const g of gains) {
html += `<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid #111">`;
html += `<span style="color:${g.color}">${g.label}</span>`;
html += `<span style="color:#4caf50;font-weight:600">+${fmt(g.value)}</span>`;
html += `</div>`;
}
// Show offline efficiency note
html += `<div style="color:#555;font-size:9px;margin-top:8px;font-style:italic">Offline efficiency: 50%</div>`;
listEl.innerHTML = html;
}
el.style.display = 'flex';
}
function dismissOfflinePopup() {
const el = document.getElementById('offline-popup');
if (el) el.style.display = 'none';
}
// === SAVE / LOAD ===
function showSaveToast() {
const el = document.getElementById('save-toast');
@@ -1587,6 +1997,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,
@@ -1607,6 +2019,10 @@ function saveGame() {
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,
buyAmount: G.buyAmount || 1,
seenBuildings: G.seenBuildings || {},
savedAt: Date.now()
};
@@ -1622,6 +2038,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
@@ -1651,13 +2081,36 @@ function loadGame() {
G.totalUsers += uc; G.totalImpact += ic;
G.totalRescues += rc;
// Show welcome-back popup with all gains
const gains = [];
if (gc > 0) gains.push({ label: 'Code', value: gc, color: '#4a9eff' });
if (cc > 0) gains.push({ label: 'Compute', value: cc, color: '#4a9eff' });
if (kc > 0) gains.push({ label: 'Knowledge', value: kc, color: '#4a9eff' });
if (uc > 0) gains.push({ label: 'Users', value: uc, color: '#4a9eff' });
if (ic > 0) gains.push({ label: 'Impact', value: ic, color: '#4a9eff' });
if (rc > 0) gains.push({ label: 'Rescues', value: rc, color: '#4caf50' });
if (oc > 0) gains.push({ label: 'Ops', value: oc, color: '#b388ff' });
if (tc > 0) gains.push({ label: 'Trust', value: tc, color: '#4caf50' });
if (crc > 0) gains.push({ label: 'Creativity', value: crc, color: '#ffd700' });
const awayMin = Math.floor(offSec / 60);
const awaySec = Math.floor(offSec % 60);
const timeLabel = awayMin >= 1 ? `${awayMin} minute${awayMin !== 1 ? 's' : ''}` : `${awaySec} seconds`;
if (gains.length > 0) {
showOfflinePopup(timeLabel, gains, offSec);
}
// Log summary
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(', ')}`);
if (oc > 0) parts.push(`${fmt(oc)} ops`);
if (tc > 0) parts.push(`${fmt(tc)} trust`);
log(`Welcome back! While away (${timeLabel}): ${parts.join(', ')}`);
}
}
@@ -1684,7 +2137,7 @@ function initGame() {
log('Click WRITE CODE or press SPACE to start.');
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');
log('Keys: SPACE=Code 1=Ops->Code 2=Ops->Compute 3=Ops->Knowledge 4=Ops->Trust B=Buy x1/x10/MAX');
}
window.addEventListener('load', function () {
@@ -1725,4 +2178,10 @@ window.addEventListener('keydown', function (e) {
if (e.code === 'Digit2') doOps('boost_compute');
if (e.code === 'Digit3') doOps('boost_knowledge');
if (e.code === 'Digit4') doOps('boost_trust');
if (e.code === 'KeyB') {
// Cycle: 1 -> 10 -> MAX -> 1
if (G.buyAmount === 1) setBuyAmount(10);
else if (G.buyAmount === 10) setBuyAmount(-1);
else setBuyAmount(1);
}
});

View File

@@ -3,6 +3,23 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="The Beacon — a sovereign AI idle game. Build an AI from scratch. Write code, train models, save lives.">
<meta name="theme-color" content="#0a0a0a">
<meta name="author" content="Timmy Foundation">
<!-- Open Graph -->
<meta property="og:title" content="The Beacon">
<meta property="og:description" content="A sovereign AI idle game. Build an AI from scratch. Write code, train models, save lives.">
<meta property="og:type" content="website">
<meta property="og:image" content="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="The Beacon">
<meta name="twitter:description" content="A sovereign AI idle game. Build an AI from scratch. Write code, train models, save lives.">
<!-- Favicon (inline SVG beacon) -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
<title>The Beacon - Build Sovereign AI</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
@@ -67,6 +84,18 @@ 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}
#toast-container{position:fixed;top:60px;right:16px;z-index:80;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{background:#0e1420;border:1px solid #2a3a4a;border-radius:6px;padding:10px 16px;max-width:320px;animation:toast-in 0.3s ease-out;opacity:1;transition:opacity 0.5s ease-out;pointer-events:auto}
.toast.fade-out{opacity:0}
.toast-phase{border-color:var(--gold);border-width:2px}
.toast-phase .toast-title{color:var(--gold);text-shadow:0 0 12px rgba(255,215,0,0.3)}
.toast-building{border-color:var(--accent)}
.toast-building .toast-title{color:var(--accent)}
.toast-project{border-color:var(--green)}
.toast-project .toast-title{color:var(--green)}
.toast-title{font-size:12px;font-weight:700;letter-spacing:1px;margin-bottom:2px}
.toast-msg{font-size:10px;color:#888;line-height:1.4}
@keyframes toast-in{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
</style>
</head>
<body>
@@ -98,6 +127,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<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>
@@ -128,8 +158,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">
@@ -140,6 +172,7 @@ Drift: <span id="st-drift">0</span>
<h2>SYSTEM LOG</h2>
<div id="log-entries"></div>
</div>
<div id="toast-container"></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>
@@ -154,5 +187,15 @@ The light is on. The room is empty."
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
</div>
<script src="game.js"></script>
<div id="offline-popup" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:400px;width:100%">
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px">WELCOME BACK</h3>
<p style="color:#888;font-size:10px;margin-bottom:12px" id="offline-time-label">You were away for 0 minutes.</p>
<div id="offline-gains-list" style="text-align:left;font-size:11px;line-height:1.8;margin-bottom:16px"></div>
<button onclick="dismissOfflinePopup()" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
</div>
</div>
</body>
</html>