const STORAGE_KEY = "levelup-typing-save-v1"; const WORD_BANKS = { easy: [ "apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn", "typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper" ], normal: [ "speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade", "streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum" ], hard: [ "architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize", "compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark" ], expert: [ "extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability", "cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis" ] }; const DIFFICULTY = { easy: { label: "easy", xpMult: 0.9, roundWords: 38 }, normal: { label: "normal", xpMult: 1, roundWords: 45 }, hard: { label: "hard", xpMult: 1.25, roundWords: 50 }, expert: { label: "expert", xpMult: 1.5, roundWords: 54 } }; const PERKS = [ { id: "combo-boost", name: "combo boost", unlockLevel: 2, cost: 1, description: "Earn 15% more XP while your best combo is 5 or higher." }, { id: "accuracy-bounty", name: "accuracy bounty", unlockLevel: 4, cost: 2, description: "If you finish with 96%+ accuracy, gain +30 bonus XP." }, { id: "time-warp", name: "time warp", unlockLevel: 6, cost: 2, description: "Start rounds with +8 seconds for easier high-streak runs." }, { id: "focus-engine", name: "focus engine", unlockLevel: 8, cost: 3, description: "At 98%+ accuracy, your WPM contributes an extra 25% XP." } ]; const defaultState = { profileName: "rookie typer", difficulty: "normal", words: [], index: 0, typedChars: 0, correctWords: 0, wrongWords: 0, combo: 0, bestCombo: 0, timer: null, elapsedSeconds: 0, roundSeconds: 60, running: false, xp: 0, level: 1, shards: 0, lastRoundXp: 0, ownedPerks: [], stats: { totalRounds: 0, bestWpm: 0, bestAccuracy: 0, averageWpm: 0, averageAccuracy: 0 }, recentRounds: [] }; const state = { ...defaultState }; const els = { textDisplay: document.getElementById("textDisplay"), typeInput: document.getElementById("typeInput"), newRoundBtn: document.getElementById("newRoundBtn"), settingsBtn: document.getElementById("settingsBtn"), closeSettingsBtn: document.getElementById("closeSettingsBtn"), settingsModal: document.getElementById("settingsModal"), resetConfirmModal: document.getElementById("resetConfirmModal"), cancelResetBtn: document.getElementById("cancelResetBtn"), confirmResetBtn: document.getElementById("confirmResetBtn"), playAgainBtn: document.getElementById("playAgainBtn"), results: document.getElementById("results"), resultsSummary: document.getElementById("resultsSummary"), wpm: document.getElementById("wpm"), accuracy: document.getElementById("accuracy"), combo: document.getElementById("combo"), time: document.getElementById("time"), profileTitle: document.getElementById("profileTitle"), levelText: document.getElementById("levelText"), xpText: document.getElementById("xpText"), shardText: document.getElementById("shardText"), xpFill: document.getElementById("xpFill"), xpGain: document.getElementById("xpGain"), perks: document.getElementById("perks"), profileName: document.getElementById("profileName"), difficultySelect: document.getElementById("difficultySelect"), difficultyBonus: document.getElementById("difficultyBonus"), resetProgressBtn: document.getElementById("resetProgressBtn") }; function xpNeededForLevel(level) { return 120 + (level - 1) * 60; } function sanitizeDifficulty(value) { return DIFFICULTY[value] ? value : "normal"; } function saveProgress() { const payload = { profileName: state.profileName, difficulty: state.difficulty, xp: state.xp, level: state.level, shards: state.shards, lastRoundXp: state.lastRoundXp, ownedPerks: state.ownedPerks, stats: state.stats, recentRounds: state.recentRounds }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } function loadProgress() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const parsed = JSON.parse(raw); state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim() ? parsed.profileName.trim().slice(0, 24) : defaultState.profileName; state.difficulty = sanitizeDifficulty(parsed.difficulty); state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0; state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1; state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0; state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0; state.ownedPerks = Array.isArray(parsed.ownedPerks) ? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id)) : []; const parsedStats = parsed.stats || {}; state.stats = { totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0, bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0, bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0, averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0, averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0 }; state.recentRounds = Array.isArray(parsed.recentRounds) ? parsed.recentRounds.slice(0, 20) : []; } catch (error) { localStorage.removeItem(STORAGE_KEY); } } function trackRound(summary, earnedXp) { state.stats.totalRounds += 1; state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm); state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy); const rounds = state.stats.totalRounds; state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds; state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds; const snapshot = { stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, bestCombo: summary.bestCombo, xp: earnedXp, difficulty: state.difficulty }; state.recentRounds.unshift(snapshot); if (state.recentRounds.length > 20) { state.recentRounds.length = 20; } } function openSettings() { els.settingsModal.hidden = false; } function closeSettings() { els.settingsModal.hidden = true; } function openResetConfirm() { els.resetConfirmModal.hidden = false; } function closeResetConfirm() { els.resetConfirmModal.hidden = true; } function pickWords() { const config = DIFFICULTY[state.difficulty]; const pool = WORD_BANKS[state.difficulty]; const result = []; for (let i = 0; i < config.roundWords; i += 1) { result.push(pool[Math.floor(Math.random() * pool.length)]); } return result; } function hasPerk(id) { return state.ownedPerks.includes(id); } function renderWords() { els.textDisplay.innerHTML = ""; state.words.forEach((word, idx) => { const span = document.createElement("span"); span.className = "word"; if (idx === state.index) span.classList.add("current"); span.textContent = `${word} `; els.textDisplay.appendChild(span); }); } function renderPerks() { els.perks.innerHTML = ""; PERKS.forEach((perk) => { const levelReady = state.level >= perk.unlockLevel; const owned = hasPerk(perk.id); const canBuy = levelReady && !owned && state.shards >= perk.cost; const card = document.createElement("article"); card.className = "perk"; card.innerHTML = `
${perk.description}