Files
the-beacon/js/utils.js
Alexander Whitestone ed48327f3d
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
fix(a11y): add missing aria-labels to overlay buttons, fix exportSave URL revoke race, remove duplicate export/import in utils.js
- Add aria-label to help overlay close button, drift ending START OVER button, offline popup Continue button
- Fix exportSave() URL.revokeObjectURL race condition (delay 1s before revoke)
- Remove duplicate exportSave/importSave definitions in utils.js (render.js has canonical file-based versions)
- All buttons now have aria-labels for screen reader accessibility

Closes #49
2026-04-10 23:56:43 -04:00

288 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// === TOAST NOTIFICATIONS ===
function showToast(msg, type = 'info', duration = 4000) {
if (G.isLoading) return;
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = msg;
container.appendChild(toast);
// Cap at 5 visible toasts
while (container.children.length > 5) {
container.removeChild(container.firstChild);
}
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 400);
}, duration);
}
// === UTILITY FUNCTIONS ===
// Extended number scale abbreviations — covers up to centillion (10^303)
// Inspired by Universal Paperclips' spellf() system
const NUMBER_ABBREVS = [
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', // 10^0 10^27
'No', 'Dc', 'UDc', 'DDc', 'TDc', 'QaDc', 'QiDc', 'SxDc', 'SpDc', 'OcDc', // 10^30 10^57
'NoDc', 'Vg', 'UVg', 'DVg', 'TVg', 'QaVg', 'QiVg', 'SxVg', 'SpVg', 'OcVg', // 10^60 10^87
'NoVg', 'Tg', 'UTg', 'DTg', 'TTg', 'QaTg', 'QiTg', 'SxTg', 'SpTg', 'OcTg', // 10^90 10^117
'NoTg', 'Qd', 'UQd', 'DQd', 'TQd', 'QaQd', 'QiQd', 'SxQd', 'SpQd', 'OcQd', // 10^120 10^147
'NoQd', 'Qq', 'UQq', 'DQq', 'TQq', 'QaQq', 'QiQq', 'SxQq', 'SpQq', 'OcQq', // 10^150 10^177
'NoQq', 'Sg', 'USg', 'DSg', 'TSg', 'QaSg', 'QiSg', 'SxSg', 'SpSg', 'OcSg', // 10^180 10^207
'NoSg', 'St', 'USt', 'DSt', 'TSt', 'QaSt', 'QiSt', 'SxSt', 'SpSt', 'OcSt', // 10^210 10^237
'NoSt', 'Og', 'UOg', 'DOg', 'TOg', 'QaOg', 'QiOg', 'SxOg', 'SpOg', 'OcOg', // 10^240 10^267
'NoOg', 'Na', 'UNa', 'DNa', 'TNa', 'QaNa', 'QiNa', 'SxNa', 'SpNa', 'OcNa', // 10^270 10^297
'NoNa', 'Ce' // 10^300 10^303
];
// Full number scale names for spellf() — educational reference
// Short scale (US/modern British): each new name = 1000x the previous
const NUMBER_NAMES = [
'', 'thousand', 'million', // 10^0, 10^3, 10^6
'billion', 'trillion', 'quadrillion', // 10^9, 10^12, 10^15
'quintillion', 'sextillion', 'septillion', // 10^18, 10^21, 10^24
'octillion', 'nonillion', 'decillion', // 10^27, 10^30, 10^33
'undecillion', 'duodecillion', 'tredecillion', // 10^36, 10^39, 10^42
'quattuordecillion', 'quindecillion', 'sexdecillion', // 10^45, 10^48, 10^51
'septendecillion', 'octodecillion', 'novemdecillion', // 10^54, 10^57, 10^60
'vigintillion', 'unvigintillion', 'duovigintillion', // 10^63, 10^66, 10^69
'tresvigintillion', 'quattuorvigintillion', 'quinvigintillion', // 10^72, 10^75, 10^78
'sesvigintillion', 'septemvigintillion', 'octovigintillion', // 10^81, 10^84, 10^87
'novemvigintillion', 'trigintillion', 'untrigintillion', // 10^90, 10^93, 10^96
'duotrigintillion', 'trestrigintillion', 'quattuortrigintillion', // 10^99, 10^102, 10^105
'quintrigintillion', 'sextrigintillion', 'septentrigintillion', // 10^108, 10^111, 10^114
'octotrigintillion', 'novemtrigintillion', 'quadragintillion', // 10^117, 10^120, 10^123
'unquadragintillion', 'duoquadragintillion', 'tresquadragintillion', // 10^126, 10^129, 10^132
'quattuorquadragintillion', 'quinquadragintillion', 'sesquadragintillion', // 10^135, 10^138, 10^141
'septenquadragintillion', 'octoquadragintillion', 'novemquadragintillion', // 10^144, 10^147, 10^150
'quinquagintillion', 'unquinquagintillion', 'duoquinquagintillion', // 10^153, 10^156, 10^159
'tresquinquagintillion', 'quattuorquinquagintillion','quinquinquagintillion', // 10^162, 10^165, 10^168
'sesquinquagintillion', 'septenquinquagintillion', 'octoquinquagintillion', // 10^171, 10^174, 10^177
'novemquinquagintillion', 'sexagintillion', 'unsexagintillion', // 10^180, 10^183, 10^186
'duosexagintillion', 'tressexagintillion', 'quattuorsexagintillion', // 10^189, 10^192, 10^195
'quinsexagintillion', 'sessexagintillion', 'septensexagintillion', // 10^198, 10^201, 10^204
'octosexagintillion', 'novemsexagintillion', 'septuagintillion', // 10^207, 10^210, 10^213
'unseptuagintillion', 'duoseptuagintillion', 'tresseptuagintillion', // 10^216, 10^219, 10^222
'quattuorseptuagintillion', 'quinseptuagintillion', 'sesseptuagintillion', // 10^225, 10^228, 10^231
'septenseptuagintillion', 'octoseptuagintillion', 'novemseptuagintillion', // 10^234, 10^237, 10^240
'octogintillion', 'unoctogintillion', 'duooctogintillion', // 10^243, 10^246, 10^249
'tresoctogintillion', 'quattuoroctogintillion', 'quinoctogintillion', // 10^252, 10^255, 10^258
'sesoctogintillion', 'septenoctogintillion', 'octooctogintillion', // 10^261, 10^264, 10^267
'novemoctogintillion', 'nonagintillion', 'unnonagintillion', // 10^270, 10^273, 10^276
'duononagintillion', 'trenonagintillion', 'quattuornonagintillion', // 10^279, 10^282, 10^285
'quinnonagintillion', 'sesnonagintillion', 'septennonagintillion', // 10^288, 10^291, 10^294
'octononagintillion', 'novemnonagintillion', 'centillion' // 10^297, 10^300, 10^303
];
/**
* Formats a number into a readable string with abbreviations.
* @param {number} n - The number to format.
* @returns {string} The formatted string.
*/
function fmt(n) {
if (n === undefined || n === null || isNaN(n)) return '0';
if (n === Infinity) return '\u221E';
if (n === -Infinity) return '-\u221E';
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;
}
// getScaleName() — Returns the full name of the number scale (e.g. "quadrillion")
// Educational: helps players understand what the abbreviations mean
function getScaleName(n) {
if (n < 1000) return '';
const scale = Math.floor(Math.log10(n) / 3);
return scale < NUMBER_NAMES.length ? NUMBER_NAMES[scale] : '';
}
// spellf() — Converts numbers to full English word form
// Educational: shows the actual names of number scales
// Examples: spellf(1500) => "one thousand five hundred"
// spellf(2500000) => "two million five hundred thousand"
// spellf(1e33) => "one decillion"
/**
* Formats a number into a full word string (e.g., "1.5 million").
* @param {number} n - The number to format.
* @returns {string} The formatted string.
*/
function spellf(n) {
if (n === undefined || n === null || isNaN(n)) return 'zero';
if (n === Infinity) return 'infinity';
if (n === -Infinity) return 'negative infinity';
if (n < 0) return 'negative ' + spellf(-n);
if (n === 0) return 'zero';
// Small number words (0999)
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
'seventeen', 'eighteen', 'nineteen'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
function spellSmall(num) {
if (num === 0) return '';
if (num < 20) return ones[num];
if (num < 100) {
return tens[Math.floor(num / 10)] + (num % 10 ? ' ' + ones[num % 10] : '');
}
const h = Math.floor(num / 100);
const remainder = num % 100;
return ones[h] + ' hundred' + (remainder ? ' ' + spellSmall(remainder) : '');
}
// For very large numbers beyond our lookup table, fall back
if (n >= 1e306) return n.toExponential(2) + ' (beyond centillion)';
// 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 = [];
let remaining = n;
for (let s = scale; s >= 0; s--) {
const divisor = Math.pow(10, s * 3);
const chunk = Math.floor(remaining / divisor);
remaining = remaining - chunk * divisor;
if (chunk > 0 && chunk < 1000) {
parts.push(spellSmall(chunk) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
} else if (chunk >= 1000) {
// Floating point chunk too large — shouldn't happen below 1e54
parts.push(spellSmall(Math.floor(chunk % 1000)) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
}
}
return parts.join(' ') || 'zero';
}
// Export/import are defined in render.js (file-based approach)
function getBuildingCost(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return {};
const count = G.buildings[id] || 0;
const cost = {};
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = Math.floor(amount * Math.pow(def.costMult, count));
}
return cost;
}
function setBuyAmount(amt) {
G.buyAmount = amt;
render();
}
function getMaxBuyable(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return 0;
const count = G.buildings[id] || 0;
// Simulate purchases WITHOUT mutating G — read-only calculation
let tempResources = {};
for (const r of Object.keys(def.baseCost)) {
tempResources[r] = G[r] || 0;
}
let bought = 0;
let simCount = count;
while (true) {
let canAfford = true;
for (const [resource, amount] of Object.entries(def.baseCost)) {
const cost = Math.floor(amount * Math.pow(def.costMult, simCount));
if ((tempResources[resource] || 0) < 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, simCount));
}
simCount++;
bought++;
}
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)) {
if ((G[resource] || 0) < amount) return false;
}
return true;
}
function spendBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
G[resource] -= amount;
}
}
function canAffordProject(project) {
for (const [resource, amount] of Object.entries(project.cost)) {
if ((G[resource] || 0) < amount) return false;
}
return true;
}
function spendProject(project) {
for (const [resource, amount] of Object.entries(project.cost)) {
G[resource] -= amount;
}
}
function getClickPower() {
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
}
/**
* Calculates production rates for all resources based on buildings and boosts.
*/