// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI. import { abandonContract, acceptContract, claimEvent } from './engine/contracts.js'; import { advance, buyGenerator, buyMarketing, buyMaterial, buyUpgrade, click, derive, setPrice, simulateOffline, } from './engine/engine.js'; import { buyEndless } from './engine/endless.js'; import { buyChip, buyReliability, gamble } from './engine/gamble.js'; import { buyShares, sellShares } from './engine/investments.js'; import { buyPerk, prestige } from './engine/legacy.js'; import { buyLab, buyResearch } from './engine/research.js'; import { createState, sanitizeState, serializeState } from './engine/state.js'; import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js'; import { createFormatter } from './services/format.js'; import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js'; import { buildExport, parseImport } from './services/saveio.js'; import { cookiesAvailable, deleteGame, loadGameText, loadPrefs, saveGameText, savePrefs, takeLegacySaves, } from './services/storage.js'; import { applyThemeStyles } from './services/themes.js'; import { createAchievementsView } from './ui/achievementsView.js'; import { createContractsView } from './ui/contractsView.js'; import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js'; import { createEndlessView } from './ui/endlessView.js'; import { createGambleView } from './ui/gambleView.js'; import { createInvestView } from './ui/investView.js'; import { createLegacyView } from './ui/legacyView.js'; import { createResearchView } from './ui/researchView.js'; import { createSettings } from './ui/settings.js'; import { createTabs } from './ui/tabs.js'; import { formatDuration, moneyFormatter, rewardText } from './ui/text.js'; import { showToast } from './ui/toast.js'; import { createView } from './ui/view.js'; const TICK_MS = 100; const INCOME_SMOOTHING_SECONDS = 3; const SAVE_ERROR_COOLDOWN_MS = 60_000; let ctx; let themes = []; let locales = []; let themeId; let locale; let state; let fmt; let tabs; let income = 0; let lastSaveError = 0; const themeNames = new Map(); // ------------------------------------------------------------------ saving function persist() { if (!state) return; state.savedAt = Date.now(); try { saveGameText(serializeState(state)); } catch { if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) { lastSaveError = Date.now(); showToast(t('toast.saveFailed'), { error: true }); } } } function parseSaved(text) { try { return sanitizeState(JSON.parse(text), ctx); } catch { return null; } } /** * The shared game from cookies. Falls back to the per-theme saves of the first * version: the most advanced one becomes the shared game, the others are dropped. */ function loadSavedState() { const text = loadGameText(); if (text) return parseSaved(text); const legacy = takeLegacySaves(themes.map((th) => th.id)).map(parseSaved).filter(Boolean); legacy.sort((a, b) => b.totalProduced - a.totalProduced); return legacy[0] ?? null; } function persistPrefs() { savePrefs({ theme: themeId, lang: locale }); } // ------------------------------------------------------------ theme / language function pickLocale(preferred) { const codes = locales.map((l) => l.code); if (codes.includes(preferred)) return preferred; for (const tag of navigator.languages ?? [navigator.language]) { const match = codes.find((code) => code === tag || code === tag?.split('-')[0]); if (match) return match; } return FALLBACK_LOCALE; } async function themeLabel(id) { const key = `${id}:${locale}`; if (!themeNames.has(key)) themeNames.set(key, await fetchThemeName(id, locale)); return themeNames.get(key); } async function fillSelects() { const themeSelect = $('#theme-select'); const labels = await Promise.all(themes.map((th) => themeLabel(th.id))); themeSelect.replaceChildren( ...themes.map((th, i) => new Option(labels[i], th.id, false, th.id === themeId)), ); $('#locale-select').replaceChildren( ...locales.map((l) => new Option(l.name, l.code, false, l.code === locale)), ); } /** Reload texts, assets and every screen for the current theme + language. */ async function refreshTexts() { await loadDictionary(locale, themeId); fmt = createFormatter(locale); setThemeAssets(themeId, themes.find((th) => th.id === themeId)?.assets); applyTranslations(); document.title = t('theme.title'); view.rebuild(ctx, fmt); endless.rebuild(ctx, fmt); for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt); tabs.rebuild(); refreshStaticIcons(); await fillSelects(); } /** Change the skin only: the game state is shared by every theme. */ async function setTheme(id) { themeId = id; await applyThemeStyles(id); await refreshTexts(); persistPrefs(); if (state) render(); } // ------------------------------------------------------------------ the loop /** Tell the player about what just happened (see `notices` in engine.advance). */ function announce(notice) { switch (notice.kind) { case 'contract_done': showToast(t('toast.contractDone', { reward: rewardText(notice.reward, fmt, notice.seconds) })); break; case 'contract_failed': showToast(t('toast.contractFailed'), { error: true }); break; case 'event_incident': showToast(t('toast.eventIncident', { title: t(`event.${notice.type}.title`) }), { error: true }); break; case 'achievement': showToast(t('toast.achievement', { title: t(`ach.title.${notice.id}`) })); break; default: } } /** Apply progress made while the game was closed or the device asleep. */ function catchUp(elapsedSeconds) { const report = simulateOffline(state, ctx, elapsedSeconds); if (!report || (report.produced <= 0 && report.revenue <= 0)) return; showToast( t('toast.welcomeBack', { duration: formatDuration(report.seconds), produced: fmt.number(Math.floor(report.produced)), funds: moneyFormatter(fmt)(report.revenue), }), ); } /** Tell the player when an older save had to be rebalanced (see repairOlderSave in state.js). */ function reportRepair() { if (!state.repaired) return; state.repaired = false; showToast(t('toast.repaired'), { sticky: true }); } let lastFrame = Date.now(); function frame() { const now = Date.now(); const dt = (now - lastFrame) / 1000; lastFrame = now; if (!state || dt <= 0) return; if (dt >= ctx.rules.offline.minSeconds) { catchUp(dt); // tab was frozen or the device slept } else { const totals = advance(state, ctx, dt); income += (totals.revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS); totals.notices.forEach(announce); } render(); } /** The resource bar and the unlocked tabs always; the open section only. */ function render() { const d = derive(state, ctx); view.update(state, d, { income }); tabs.update(state); if (tabs.active === 'main') endless.update(state); else screens[tabs.active]?.update(state, d); } // ------------------------------------------------------------------- actions /** Wrap an engine action: run it on the current state, refresh the screen, report if it happened. */ const act = (fn) => (...args) => { const done = fn(state, ctx, ...args); render(); return done; }; const view = createView({ make: act(click), buyMaterial: act(buyMaterial), buyMarketing: act(buyMarketing), buyGenerator: act(buyGenerator), buyUpgrade: act(buyUpgrade), setPrice: act(setPrice), nudgePrice: (delta) => act(setPrice)(state.price + delta), }); const endless = createEndlessView($('#endless'), { buy: act(buyEndless) }); function restart() { if (!confirm(t('legacy.confirm'))) return; const gain = prestige(state, ctx); if (!gain) return; income = 0; persist(); tabs.select('main'); showToast(t('toast.restarted', { gain: fmt.number(gain) })); } /** One screen per section; each is built and refreshed by its own module. */ const screens = { contracts: createContractsView($('#view-contracts'), { accept: act(acceptContract), abandon: act(abandonContract), claimEvent: () => { claimEvent(state, ctx, derive(state, ctx)); render(); }, }), invest: createInvestView($('#view-invest'), { buy: act(buyShares), sell: act(sellShares) }), research: createResearchView($('#view-research'), { buyLab: act(buyLab), buyNode: act(buyResearch) }), gamble: createGambleView($('#view-gamble'), { buyChip: act(buyChip), buyReliability: act(buyReliability), run(stake) { const result = gamble(state, ctx, stake, derive(state, ctx).mods); render(); return result; }, }), legacy: createLegacyView($('#view-legacy'), { buyPerk: act(buyPerk), restart }), achievements: createAchievementsView($('#view-achievements')), }; createSettings({ getExport: () => ({ text: buildExport(state), name: `idle-clicker-${new Date().toISOString().slice(0, 10)}`, }), importText(text) { state = parseImport(text, ctx); // throws before anything changes reportRepair(); income = 0; persist(); render(); }, reset() { deleteGame(); state = createState(ctx); income = 0; persist(); tabs.select('main'); render(); }, }); // ---------------------------------------------------------------------- boot function showFatal(error) { console.error(error); document.body.replaceChildren(Object.assign(document.createElement('pre'), { textContent: String(error) })); } async function boot() { [ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]); if (themes.length === 0) throw new Error('No theme installed in public/themes/'); tabs = createTabs(ctx.tabs, () => state && render()); const prefs = loadPrefs(); locale = pickLocale(prefs.lang); const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0]; $('#cookie-warning').hidden = cookiesAvailable(); $('#theme-select').addEventListener('change', (e) => setTheme(e.target.value)); $('#locale-select').addEventListener('change', async (e) => { locale = e.target.value; persistPrefs(); await refreshTexts(); render(); }); await setTheme(initialTheme.id); state = loadSavedState() ?? createState(ctx); reportRepair(); catchUp((Date.now() - state.savedAt) / 1000); persist(); render(); lastFrame = Date.now(); setInterval(frame, TICK_MS); setInterval(persist, ctx.rules.autosaveSeconds * 1000); document.addEventListener('visibilitychange', () => document.hidden && persist()); addEventListener('pagehide', persist); } boot().catch(showFatal);