Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Whitestone
3aa1c36c88 beacon: add buy mode toggle (x1/x10/MAX) with B keyboard shortcut
The game had buyMode logic (1, 10, -1 for max) but zero UI to switch
between them. Players could only buy one building at a time.

Changes:
- Added x1/x10/MAX toggle buttons above the BUILDINGS section
- Active mode highlights in gold
- Press B to cycle through modes
- buyMode persists across saves
- Init log mentions the B shortcut
2026-04-10 02:21:19 -04:00
Alexander Whitestone
f362f30fd3 beacon: fix offline progress to award rescues, ops, and trust
Offline gains were missing rescues, ops, and trust — players with
beacon/mesh nodes lost half their progress on return. Now all active
resources are calculated at 50% offline efficiency.

Also includes event system overhaul: active events with durations,
resolve costs, auto-expiry, event UI rendering, and save/load support.
2026-04-10 01:21:05 -04:00
Alexander Whitestone
f97154c37a beacon: add unlock notification toasts for new buildings and projects
Players now get animated toast notifications (top-right) when:
- A new building becomes available to buy
- A new research project becomes available

Toasts are color-coded: blue for buildings, gold for projects.
Seen state is tracked and persisted in saves so toasts don't repeat.

This was the biggest UX gap in the game -- without notifications,
players had to manually scan the building/project lists to notice
new unlocks. Critical for idle games where pacing matters.
2026-04-10 00:25:40 -04:00
1a7db021c8 Merge pull request #29
Merged PR #29
2026-04-10 03:43:54 +00:00
2a12c5210d Merge pull request #28
Merged PR #28
2026-04-10 03:43:50 +00:00
Alexander Whitestone
d467348820 burn: Implement spellf() full number formatting (P0 #18)
- Fixed floating-point precision bug: numbers >= 1e54 now use string-based
  chunking (toExponential digit extraction) instead of Math.pow division,
  which drifts beyond ~54 bits of precision
- Integrated into fmt(): numbers at undecillion+ scale (10^36) automatically
  switch from abbreviated form ('1.0UDc') to spelled-out words ('one undecillion')
- Verified: spellf() correctly handles 0 through 10^303 (centillion)
- All 320 place value names from NUMBER_NAMES array work correctly

The educational effect: when resources hit cosmic scales, digits become
meaningless but NAMES give them soul.
2026-04-09 19:29:07 -04:00
2 changed files with 323 additions and 25 deletions

330
game.js
View File

@@ -85,6 +85,7 @@ const G = {
tick: 0,
saveTimer: 0,
secTimer: 0,
buyMode: 1, // 1, 10, or -1 (max)
// Systems
projects: [],
@@ -106,6 +107,7 @@ const G = {
drift: 0,
lastEventAt: 0,
eventCooldown: 0,
activeEvents: [], // {id, expiresAt} — events auto-resolve after duration
// Time tracking
playTime: 0,
@@ -702,6 +704,9 @@ function fmt(n) {
if (n < 0) return '-' + fmt(-n);
if (n < 1000) return Math.floor(n).toLocaleString();
const scale = Math.floor(Math.log10(n) / 3);
// At undecillion+ (scale >= 12, i.e. 10^36), switch to spelled-out words
// This helps players grasp cosmic scale when digits become meaningless
if (scale >= 12) return spellf(n);
if (scale >= NUMBER_ABBREVS.length) return n.toExponential(2);
const abbrev = NUMBER_ABBREVS[scale];
return (n / Math.pow(10, scale * 3)).toFixed(1) + abbrev;
@@ -739,7 +744,41 @@ function spellf(n) {
// For very large numbers beyond our lookup table, fall back
if (n >= 1e306) return n.toExponential(2) + ' (beyond centillion)';
// Break number into groups of three digits from the top
// Use string-based chunking for numbers >= 1e54 to avoid floating point drift
// Math.log10 / Math.pow lose precision beyond ~54 bits
if (n >= 1e54) {
// Convert to scientific notation string, extract digits
const sci = n.toExponential(); // "1.23456789e+60"
const [coeff, expStr] = sci.split('e+');
const exp = parseInt(expStr);
// Rebuild as integer string with leading digits from coefficient
const coeffDigits = coeff.replace('.', ''); // "123456789"
const totalDigits = exp + 1;
// Pad with zeros to reach totalDigits, then take our coefficient digits
let intStr = coeffDigits;
const zerosNeeded = totalDigits - coeffDigits.length;
if (zerosNeeded > 0) intStr += '0'.repeat(zerosNeeded);
// Split into groups of 3 from the right
const groups = [];
for (let i = intStr.length; i > 0; i -= 3) {
groups.unshift(parseInt(intStr.slice(Math.max(0, i - 3), i)));
}
const parts = [];
const numGroups = groups.length;
for (let i = 0; i < numGroups; i++) {
const chunk = groups[i];
if (chunk === 0) continue;
const scaleIdx = numGroups - 1 - i;
const scaleName = scaleIdx < NUMBER_NAMES.length ? NUMBER_NAMES[scaleIdx] : '';
parts.push(spellSmall(chunk) + (scaleName ? ' ' + scaleName : ''));
}
return parts.join(' ') || 'zero';
}
// Standard math-based chunking for numbers < 1e54
const scale = Math.min(Math.floor(Math.log10(n) / 3), NUMBER_NAMES.length - 1);
const parts = [];
@@ -751,7 +790,7 @@ function spellf(n) {
if (chunk > 0 && chunk < 1000) {
parts.push(spellSmall(chunk) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
} else if (chunk >= 1000) {
// Floating point chunk too large — simplify
// Floating point chunk too large — shouldn't happen below 1e54
parts.push(spellSmall(Math.floor(chunk % 1000)) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
}
}
@@ -770,6 +809,42 @@ 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)) {
@@ -778,6 +853,16 @@ 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)) {
@@ -828,6 +913,7 @@ 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) +
@@ -929,6 +1015,20 @@ 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;
@@ -992,15 +1092,26 @@ 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;
const buyCount = G.buyMode === -1 ? getMaxBuyable(id) : G.buyMode;
if (buyCount <= 0) return;
spendBuilding(id);
G.buildings[id] = (G.buildings[id] || 0) + 1;
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]})`);
}
updateRates();
log(`Built ${def.name} (total: ${G.buildings[id]})`);
render();
}
@@ -1076,46 +1187,49 @@ 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. Code production slowed.',
weight: () => (G.ciFlag === 1 ? 2 : 0),
duration: 45, // seconds
resolveCost: { ops: 10 },
effect: () => {
G.codeRate *= 0.5;
log('EVENT: CI runner stuck. Spend ops to clear the queue.', true);
log('EVENT: CI runner stuck. Click to spend 10 Ops to clear, or wait ~45s.', 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 stalled.',
weight: () => (G.buildings.ezra >= 1 ? 3 : 0),
duration: 60,
resolveCost: { ops: 5, trust: 2 },
effect: () => {
G.userRate *= 0.3;
log('EVENT: Ezra offline. Dispatch required.', true);
log('EVENT: Ezra offline. Click to dispatch, or wait ~60s.', true);
}
},
{
id: 'unreviewed_merge',
title: 'Unreviewed Merge',
desc: 'A change went in without eyes. Trust erodes.',
weight: () => (G.deployFlag === 1 ? 3 : 0),
weight: () => (G.deployFlag === 1 && G.branchProtectionFlag !== 1 ? 3 : 0),
duration: 30,
resolveCost: { ops: 8 },
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);
}
G.trust = Math.max(0, G.trust - 10);
log('EVENT: Unreviewed merge detected. Trust lost. Click to revert.', true);
}
},
{
id: 'api_rate_limit',
title: 'API Rate Limit',
desc: 'External compute provider throttled.',
weight: () => (G.totalCompute >= 1000 ? 2 : 0),
weight: () => (G.totalCompute >= 1000 && G.sovereignFlag !== 1 ? 2 : 0),
duration: 40,
resolveCost: { compute: 200 },
effect: () => {
G.computeRate *= 0.5;
log('EVENT: API rate limit hit. Local compute insufficient.', true);
log('EVENT: API rate limit. Click to provision local compute, or wait ~40s.', true);
}
},
{
@@ -1123,6 +1237,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
effect: () => {
log('ALIGNMENT EVENT: Remove human override for +40% efficiency?', true);
G.pendingAlignment = true;
@@ -1133,9 +1248,11 @@ const EVENTS = [
title: 'Bilbo Vanished',
desc: 'The wildcard building has gone dark.',
weight: () => (G.buildings.bilbo >= 1 ? 2 : 0),
duration: 50,
resolveCost: { trust: 3 },
effect: () => {
G.creativityRate = 0;
log('EVENT: Bilbo has vanished. Creativity halts.', true);
log('EVENT: Bilbo vanished. Click to search, or wait ~50s.', true);
}
}
];
@@ -1149,12 +1266,53 @@ 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) {
@@ -1173,6 +1331,15 @@ function resolveAlignment(accept) {
}
// === 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 writeCode() {
const base = 1;
const bonus = Math.floor(G.buildings.autocoder * 0.5);
@@ -1396,6 +1563,8 @@ function render() {
renderStats();
updateEducation();
renderAlignment();
renderActiveEvents();
checkUnlocks();
}
function renderAlignment() {
@@ -1419,6 +1588,97 @@ 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');
@@ -1450,8 +1710,11 @@ 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,
totalClicks: G.totalClicks, startedAt: G.startedAt, buyMode: G.buyMode,
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,
@@ -1484,12 +1747,21 @@ function loadGame() {
const uc = G.userRate * offSec * f;
const ic = G.impactRate * offSec * f;
const rc = G.rescuesRate * offSec * f;
const oc = G.opsRate * offSec * f;
const tc = G.trustRate * 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.totalCode += gc; G.totalCompute += cc; G.totalKnowledge += kc;
G.totalUsers += uc; G.totalImpact += ic;
G.totalRescues += rc;
log(`Welcome back! While away (${Math.floor(offSec / 60)}m): ${fmt(gc)} code, ${fmt(kc)} knowledge, ${fmt(uc)} users`);
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`);
log(`Welcome back! While away (${Math.floor(offSec / 60)}m): ${parts.join(', ')}`);
}
}
@@ -1514,6 +1786,7 @@ 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.');
}
@@ -1535,6 +1808,9 @@ window.addEventListener('load', function () {
}
}
// Restore buy mode button highlight
setBuyMode(G.buyMode || 1);
// Game loop at 10Hz (100ms tick)
setInterval(tick, 100);
@@ -1551,4 +1827,10 @@ 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);
}
});

View File

@@ -59,6 +59,15 @@ 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>
@@ -95,6 +104,12 @@ 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>
@@ -128,7 +143,8 @@ Drift: <span id="st-drift">0</span>
<h2>SYSTEM LOG</h2>
<div id="log-entries"></div>
</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="save-toast">Save</div>
<div id="unlock-toast"></div>
<div id="drift-ending">
<h2>THE DRIFT</h2>
<p>You became very good at what you do.</p>