Some checks failed
Smoke Test / smoke (push) Failing after 3s
Merge PR #67: fix: debuff corruption + persist playTime
330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
function render() {
|
|
renderResources();
|
|
renderPhase();
|
|
renderBuildings();
|
|
renderProjects();
|
|
renderStats();
|
|
updateEducation();
|
|
renderAlignment();
|
|
renderProgress();
|
|
renderCombo();
|
|
renderDebuffs();
|
|
renderSprint();
|
|
renderPulse();
|
|
renderStrategy();
|
|
renderClickPower();
|
|
}
|
|
|
|
function renderClickPower() {
|
|
const el = document.getElementById('click-power-display');
|
|
if (!el) return;
|
|
const power = getClickPower();
|
|
el.textContent = `Click power: ${fmt(power)} code`;
|
|
// Also update the button's aria-label for accessibility
|
|
const btn = document.querySelector('.main-btn');
|
|
if (btn) btn.setAttribute('aria-label', `Write code, generates ${fmt(power)} code per click`);
|
|
}
|
|
|
|
function renderStrategy() {
|
|
if (window.SSE) {
|
|
window.SSE.update();
|
|
const el = document.getElementById('strategy-recommendation');
|
|
if (el) el.textContent = window.SSE.getRecommendation();
|
|
}
|
|
}
|
|
|
|
function renderAlignment() {
|
|
const container = document.getElementById('alignment-ui');
|
|
if (!container) return;
|
|
if (G.pendingAlignment) {
|
|
container.innerHTML = `
|
|
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
|
|
<div style="color:#f44336;font-weight:bold;margin-bottom:6px">ALIGNMENT EVENT: The Drift</div>
|
|
<div style="font-size:10px;color:#aaa;margin-bottom:8px">An optimization suggests removing the human override. +40% efficiency.</div>
|
|
<div class="action-btn-group">
|
|
<button class=\"ops-btn\" onclick=\"resolveAlignment(true)\" style=\"border-color:#f44336;color:#f44336\" aria-label=\"Accept alignment event, gain 40 percent efficiency but increase drift\">Accept (+40% eff, +Drift)</button>
|
|
<button class=\"ops-btn\" onclick=\"resolveAlignment(false)\" style=\"border-color:#4caf50;color:#4caf50\" aria-label=\"Refuse alignment event, gain trust and harmony\">Refuse (+Trust, +Harmony)</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.style.display = 'block';
|
|
} else {
|
|
container.innerHTML = '';
|
|
container.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// === 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';
|
|
}
|
|
|
|
// === EXPORT / IMPORT SAVE FILES ===
|
|
function exportSave() {
|
|
const raw = localStorage.getItem('the-beacon-v2');
|
|
if (!raw) { log('No save data to export.'); return; }
|
|
const blob = new Blob([raw], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const ts = new Date().toISOString().slice(0, 10);
|
|
a.download = `beacon-save-${ts}.json`;
|
|
a.click();
|
|
// Delay revoke to avoid race — some browsers need time to start the download
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
log('Save exported to file.');
|
|
}
|
|
|
|
function importSave() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json,application/json';
|
|
input.onchange = function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = function(ev) {
|
|
try {
|
|
const data = JSON.parse(ev.target.result);
|
|
if (!data.code && !data.totalCode && !data.buildings) {
|
|
log('Import failed: file does not look like a Beacon save.');
|
|
return;
|
|
}
|
|
if (confirm('Import this save? Current progress will be overwritten.')) {
|
|
localStorage.setItem('the-beacon-v2', ev.target.result);
|
|
location.reload();
|
|
}
|
|
} catch (err) {
|
|
log('Import failed: invalid JSON file.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
// === SAVE / LOAD ===
|
|
function showSaveToast() {
|
|
const el = document.getElementById('save-toast');
|
|
if (!el) return;
|
|
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
|
const m = Math.floor(elapsed / 60);
|
|
const s = elapsed % 60;
|
|
el.textContent = `Saved [${m}:${s.toString().padStart(2, '0')}]`;
|
|
el.style.display = 'block';
|
|
void el.offsetHeight;
|
|
el.style.opacity = '1';
|
|
setTimeout(() => { el.style.opacity = '0'; }, 1500);
|
|
setTimeout(() => { el.style.display = 'none'; }, 2000);
|
|
}
|
|
|
|
/**
|
|
* Persists the current game state to localStorage.
|
|
*/
|
|
function saveGame() {
|
|
// Save debuff IDs (can't serialize functions)
|
|
const debuffIds = (G.activeDebuffs || []).map(d => d.id);
|
|
const saveData = {
|
|
version: 1,
|
|
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,
|
|
totalCode: G.totalCode, totalCompute: G.totalCompute, totalKnowledge: G.totalKnowledge,
|
|
totalUsers: G.totalUsers, totalImpact: G.totalImpact,
|
|
buildings: G.buildings,
|
|
codeBoost: G.codeBoost, computeBoost: G.computeBoost, knowledgeBoost: G.knowledgeBoost,
|
|
userBoost: G.userBoost, impactBoost: G.impactBoost,
|
|
milestoneFlag: G.milestoneFlag, phase: G.phase,
|
|
deployFlag: G.deployFlag, sovereignFlag: G.sovereignFlag, beaconFlag: G.beaconFlag,
|
|
memoryFlag: G.memoryFlag, pactFlag: G.pactFlag,
|
|
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
|
|
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,
|
|
flags: G.flags,
|
|
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,
|
|
playTime: G.playTime || 0,
|
|
sprintActive: G.sprintActive || false,
|
|
sprintTimer: G.sprintTimer || 0,
|
|
sprintCooldown: G.sprintCooldown || 0,
|
|
swarmFlag: G.swarmFlag || 0,
|
|
swarmRate: G.swarmRate || 0,
|
|
strategicFlag: G.strategicFlag || 0,
|
|
projectsCollapsed: G.projectsCollapsed !== false,
|
|
savedAt: Date.now()
|
|
};
|
|
|
|
localStorage.setItem('the-beacon-v2', JSON.stringify(saveData));
|
|
showSaveToast();
|
|
}
|
|
|
|
/**
|
|
* Loads the game state from localStorage and reconstitutes the game engine.
|
|
* @returns {boolean} True if load was successful.
|
|
*/
|
|
function loadGame() {
|
|
const raw = localStorage.getItem('the-beacon-v2');
|
|
if (!raw) return false;
|
|
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
|
|
// Whitelist properties that can be loaded
|
|
const whitelist = [
|
|
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony',
|
|
'totalCode', 'totalCompute', 'totalKnowledge', 'totalUsers', 'totalImpact',
|
|
'buildings', 'codeBoost', 'computeBoost', 'knowledgeBoost', 'userBoost', 'impactBoost',
|
|
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
|
|
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
|
|
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
|
|
'milestones', 'completedProjects', 'activeProjects',
|
|
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
|
|
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
|
|
'lastEventAt', 'totalEventsResolved', 'buyAmount',
|
|
'sprintActive', 'sprintTimer', 'sprintCooldown',
|
|
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
|
|
];
|
|
|
|
G.isLoading = true;
|
|
|
|
whitelist.forEach(key => {
|
|
if (data.hasOwnProperty(key)) {
|
|
G[key] = data[key];
|
|
}
|
|
});
|
|
|
|
// Restore sprint state properly
|
|
// codeBoost was saved with the sprint multiplier baked in
|
|
if (data.sprintActive) {
|
|
// Sprint was active when saved — check if it expired during offline time
|
|
const offSec = data.savedAt ? (Date.now() - data.savedAt) / 1000 : 0;
|
|
const remaining = (data.sprintTimer || 0) - offSec;
|
|
if (remaining > 0) {
|
|
// Sprint still going — keep boost, update timer
|
|
G.sprintActive = true;
|
|
G.sprintTimer = remaining;
|
|
G.sprintCooldown = 0;
|
|
} else {
|
|
// Sprint expired during offline — remove boost, start cooldown
|
|
G.sprintActive = false;
|
|
G.sprintTimer = 0;
|
|
G.codeBoost /= G.sprintMult;
|
|
const cdRemaining = G.sprintCooldownMax + remaining; // remaining is negative
|
|
G.sprintCooldown = Math.max(0, cdRemaining);
|
|
}
|
|
}
|
|
// If not sprintActive at save time, codeBoost is correct as-is
|
|
|
|
// 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();
|
|
G.isLoading = false;
|
|
|
|
// Offline progress
|
|
if (data.savedAt) {
|
|
const offSec = (Date.now() - data.savedAt) / 1000;
|
|
if (offSec > 30) { // Only if away for more than 30 seconds
|
|
updateRates();
|
|
const f = CONFIG.OFFLINE_EFFICIENCY; // 50% offline efficiency
|
|
const gc = G.codeRate * offSec * f;
|
|
const cc = G.computeRate * offSec * f;
|
|
const kc = G.knowledgeRate * offSec * f;
|
|
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;
|
|
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;
|
|
|
|
// 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`);
|
|
if (oc > 0) parts.push(`${fmt(oc)} ops`);
|
|
if (tc > 0) parts.push(`${fmt(tc)} trust`);
|
|
log(`Welcome back! While away (${timeLabel}): ${parts.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
console.error('Load failed:', e);
|
|
return false;
|
|
}
|
|
}
|