const STORAGE_KEY = "speedcode-typing-save-v1"; const FALLBACK_WORD_BANKS = { frontend: ["component", "props", "state", "effect", "dom", "render", "hook", "event", "layout", "style", "selector", "responsive", "hydration", "bundle", "asset", "router", "transition", "animation", "markup", "stylesheet"], backend: ["endpoint", "middleware", "request", "response", "session", "database", "query", "migration", "service", "worker", "queue", "cache", "auth", "token", "serializer", "controller", "schema", "webhook", "latency", "throughput"], systems: ["pointer", "thread", "mutex", "allocator", "kernel", "register", "syscall", "segment", "stack", "heap", "compiler", "linker", "interrupt", "buffer", "runtime", "ownership", "lifetime", "scheduler", "pipeline", "throughput"], "full-stack": ["frontend", "backend", "contract", "gateway", "orchestrator", "deployment", "container", "observability", "tracing", "monorepo", "api", "client", "server", "session", "render", "streaming", "database", "replica", "pipeline", "integration"] }; const DIFFICULTY_CONFIG = { frontend: { xp_mult: 1.05, round_words: 44 }, backend: { xp_mult: 1.15, round_words: 46 }, systems: { xp_mult: 1.3, round_words: 50 }, "full-stack": { xp_mult: 1.4, round_words: 54 } }; const CHALLENGE_CONFIG = { casual: { xp_mult: 0.9, round_seconds: 75, words_mult: 0.9 }, standard: { xp_mult: 1, round_seconds: 60, words_mult: 1 }, intense: { xp_mult: 1.2, round_seconds: 52, words_mult: 1.15 }, nightmare: { xp_mult: 1.4, round_seconds: 45, words_mult: 1.3 } }; const PERKS = [ { id: "combo-boost", name: "combo boost", unlock_level: 2, cost: 1, description: "earn 15% more xp while your best combo is 5 or higher." }, { id: "accuracy-bounty", name: "accuracy bounty", unlock_level: 4, cost: 2, description: "if you finish with 96%+ accuracy, gain +30 bonus xp." }, { id: "time-warp", name: "time warp", unlock_level: 6, cost: 2, description: "start rounds with +8 seconds for easier high-streak runs." }, { id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." } ]; const TRACK_REPO_PREFIXES = { frontend: ["vuejs/core/", "twbs/bootstrap/", "vercel/next.js/", "microsoft/TypeScript/"], backend: ["nodejs/node/", "python/cpython/", "golang/go/", "laravel/framework/"], systems: ["rust-lang/rust/", "redis/redis/", "torvalds/linux/", "llvm/llvm-project/"], "full-stack": ["vercel/next.js/", "remix-run/remix/", "microsoft/TypeScript/", "nodejs/node/"] }; const DEFAULT_STATE = { profile_name: "rookie typer", difficulty: "full-stack", challenge: "standard", target_chars: [], index: 0, typed_chars: 0, correct_words: 0, wrong_words: 0, combo: 0, best_combo: 0, timer: null, elapsed_seconds: 0, round_seconds: 60, running: false, xp: 0, level: 1, shards: 0, last_round_xp: 0, owned_perks: [], round_nonce: 0, stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 }, recent_rounds: [] }; const state = { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [] }; const q = (id) => document.getElementById(id); const num = (v, d = 0) => (Number.isFinite(v) ? Number(v) : d); const snake_or_camel = (obj, snake, camel, fallback = undefined) => (obj?.[snake] ?? obj?.[camel] ?? fallback); const dom = { text_display: q("textDisplay"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"), close_settings_btn: q("closeSettingsBtn"), settings_modal: q("settingsModal"), reset_confirm_modal: q("resetConfirmModal"), cancel_reset_btn: q("cancelResetBtn"), confirm_reset_btn: q("confirmResetBtn"), play_again_btn: q("playAgainBtn"), results: q("results"), results_summary: q("resultsSummary"), wpm: q("wpm"), accuracy: q("accuracy"), combo: q("combo"), time: q("time"), profile_title: q("profileTitle"), level_text: q("levelText"), xp_text: q("xpText"), shard_text: q("shardText"), xp_fill: q("xpFill"), xp_gain: q("xpGain"), perks: q("perks"), profile_name: q("profileName"), difficulty_select: q("difficultySelect"), difficulty_bonus: q("difficultyBonus"), challenge_select: q("challengeSelect"), challenge_bonus: q("challengeBonus"), reset_progress_btn: q("resetProgressBtn"), snippet_source: q("snippetSource"), snippet_language: q("snippetLanguage"), code_editor: q("codeEditor"), code_snippet: q("codeSnippet"), tab_type_btn: q("tabTypeBtn"), tab_perks_btn: q("tabPerksBtn"), type_tab: q("typeTab"), perks_tab: q("perksTab") }; if (!dom.text_display) dom.text_display = dom.code_snippet; const detect_language = (source) => { const ext = (source || "").split(".").pop()?.toLowerCase(); if (ext === "js" || ext === "mjs" || ext === "cjs") return "javascript"; if (ext === "ts" || ext === "tsx") return "typescript"; if (ext === "py") return "python"; if (ext === "json") return "json"; if (ext === "html") return "html"; if (ext === "css") return "css"; if (ext === "c" || ext === "h") return "c"; if (ext === "cpp" || ext === "cc" || ext === "cxx" || ext === "hpp" || ext === "hh" || ext === "hxx") return "cpp"; if (ext === "rs") return "rust"; if (ext === "go") return "go"; if (ext === "java") return "java"; if (ext === "php") return "php"; if (ext === "sql") return "sql"; if (ext === "sh") return "shell"; return "plaintext"; }; const prism_language_for = (language) => ( language === "shell" ? "bash" : language === "html" ? "markup" : language ); async function set_code_display(snippet, source) { const language = detect_language(source); const prism_language = prism_language_for(language); dom.snippet_language.textContent = `language: ${language}`; dom.code_snippet.className = `language-${prism_language || "plaintext"}`; } const xp_needed_for_level = (level) => 120 + (level - 1) * 60; const sanitize_difficulty = (v) => { if (DIFFICULTY_CONFIG[v]) return v; if (v === "easy") return "frontend"; if (v === "normal") return "backend"; if (v === "hard") return "systems"; if (v === "expert") return "full-stack"; return "full-stack"; }; const sanitize_challenge = (v) => (CHALLENGE_CONFIG[v] ? v : "standard"); const source_matches_track = (source, track) => { const prefixes = TRACK_REPO_PREFIXES[track] || []; if (!source || !prefixes.length) return true; return prefixes.some((prefix) => source.startsWith(prefix)); }; const has_perk = (id) => state.owned_perks.includes(id); const get_track_xp_mult = () => DIFFICULTY_CONFIG[state.difficulty].xp_mult; const get_challenge_xp_mult = () => CHALLENGE_CONFIG[state.challenge].xp_mult; const get_total_xp_mult = () => get_track_xp_mult() * get_challenge_xp_mult(); const get_round_word_count = () => Math.max(24, Math.floor(DIFFICULTY_CONFIG[state.difficulty].round_words * CHALLENGE_CONFIG[state.challenge].words_mult)); const get_round_seconds = () => CHALLENGE_CONFIG[state.challenge].round_seconds + (has_perk("time-warp") ? 8 : 0); const open_settings = () => { dom.settings_modal.hidden = false; }; const close_settings = () => { dom.settings_modal.hidden = true; }; const open_reset_confirm = () => { dom.reset_confirm_modal.hidden = false; }; const close_reset_confirm = () => { dom.reset_confirm_modal.hidden = true; }; let levelup_animation_queue = Promise.resolve(); let xp_bar_animation_queue = Promise.resolve(); function set_side_tab(tab) { const show_type = tab === "type"; const show_perks = tab === "perks"; if (dom.type_tab) dom.type_tab.hidden = !show_type; if (dom.perks_tab) dom.perks_tab.hidden = !show_perks; if (dom.tab_type_btn) { dom.tab_type_btn.classList.toggle("active", show_type); dom.tab_type_btn.setAttribute("aria-selected", show_type ? "true" : "false"); } if (dom.tab_perks_btn) { dom.tab_perks_btn.classList.toggle("active", show_perks); dom.tab_perks_btn.setAttribute("aria-selected", show_perks ? "true" : "false"); } } function play_levelup_milestone(level) { const overlay = q("levelUpOverlay"); const text = q("levelUpText"); const kicker = q("levelUpKicker"); if (!overlay || !text) return Promise.resolve(); return new Promise((resolve) => { const tier = Math.min(4, Math.max(1, Math.floor(level / 5))); overlay.dataset.tier = String(tier); text.textContent = `level ${level}`; if (kicker) kicker.textContent = level >= 20 ? "legend milestone unlocked" : "milestone unlocked"; overlay.hidden = false; overlay.classList.remove("active"); void overlay.offsetWidth; overlay.classList.add("active"); setTimeout(() => { overlay.classList.remove("active"); overlay.hidden = true; resolve(); }, 2250); }); } function queue_levelup_milestones(levels) { if (!Array.isArray(levels) || !levels.length) return; levelup_animation_queue = levelup_animation_queue.then(async () => { for (const level of levels) { await play_levelup_milestone(level); } }); } function play_xp_level_ping() { const bar = dom.xp_fill?.parentElement; if (!bar || !dom.xp_fill) return Promise.resolve(); return new Promise((resolve) => { bar.classList.remove("level-ping"); dom.xp_fill.classList.remove("level-ping"); void bar.offsetWidth; bar.classList.add("level-ping"); dom.xp_fill.classList.add("level-ping"); setTimeout(() => { bar.classList.remove("level-ping"); dom.xp_fill.classList.remove("level-ping"); resolve(); }, 340); }); } function queue_xp_level_pings(count) { const pings = Math.max(0, Math.floor(count)); if (!pings) return; xp_bar_animation_queue = xp_bar_animation_queue.then(async () => { for (let i = 0; i < pings; i += 1) { await play_xp_level_ping(); } }); } function save_progress() { localStorage.setItem(STORAGE_KEY, JSON.stringify({ profile_name: state.profile_name, difficulty: state.difficulty, challenge: state.challenge, xp: state.xp, level: state.level, shards: state.shards, last_round_xp: state.last_round_xp, owned_perks: state.owned_perks, stats: state.stats, recent_rounds: state.recent_rounds })); } function load_progress() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const parsed = JSON.parse(raw); state.profile_name = String(snake_or_camel(parsed, "profile_name", "profileName", DEFAULT_STATE.profile_name)).trim().slice(0, 24) || DEFAULT_STATE.profile_name; state.difficulty = sanitize_difficulty(parsed.difficulty); state.challenge = sanitize_challenge(parsed.challenge); state.xp = Math.max(0, Math.floor(num(parsed.xp))); state.level = Math.max(1, Math.floor(num(parsed.level, 1))); state.shards = Math.max(0, Math.floor(num(parsed.shards))); state.last_round_xp = Math.max(0, Math.floor(num(snake_or_camel(parsed, "last_round_xp", "lastRoundXp")))); const owned = snake_or_camel(parsed, "owned_perks", "ownedPerks", []); state.owned_perks = Array.isArray(owned) ? owned.filter((id) => PERKS.some((p) => p.id === id)) : []; const stats = parsed.stats || {}; state.stats = { total_rounds: Math.max(0, Math.floor(num(snake_or_camel(stats, "total_rounds", "totalRounds")))), best_wpm: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_wpm", "bestWpm")))), best_accuracy: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_accuracy", "bestAccuracy")))), average_wpm: Math.max(0, num(snake_or_camel(stats, "average_wpm", "averageWpm"))), average_accuracy: Math.max(0, num(snake_or_camel(stats, "average_accuracy", "averageAccuracy"))) }; const rounds = snake_or_camel(parsed, "recent_rounds", "recentRounds", []); state.recent_rounds = Array.isArray(rounds) ? rounds.slice(0, 20).map((r) => ({ stamp: num(r.stamp, Date.now()), wpm: num(r.wpm), accuracy: num(r.accuracy), best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")), xp: num(r.xp), difficulty: sanitize_difficulty(r.difficulty), challenge: sanitize_challenge(r.challenge) })) : []; } catch { localStorage.removeItem(STORAGE_KEY); } } function pick_words() { const round_words = get_round_word_count(); const pool = FALLBACK_WORD_BANKS[state.difficulty]; return Array.from({ length: round_words }, () => pool[Math.floor(Math.random() * pool.length)]); } function tokenize_snippet(snippet, max_words) { const language = "plaintext"; return tokenize_snippet_with_syntax(snippet, max_words, language); } function tokenize_snippet_with_syntax(snippet, max_words, language) { const source = String(snippet || "").replace(/\r/g, ""); const words = []; const tokens = []; const prism_language = prism_language_for(language || "plaintext"); const grammar = window.Prism?.languages?.[prism_language]; const tokenize_plain = () => { const parts = source.split(/(\s+)/); for (const part of parts) { if (!part) continue; if (/^\s+$/.test(part)) { if (tokens.length) tokens.push({ type: "space", text: part }); continue; } if (words.length >= max_words) break; const word = part.trim(); if (!word) continue; const index = words.length; words.push(word); tokens.push({ type: "word", text: word, index, syntax_class: "" }); } while (tokens.length && tokens[tokens.length - 1].type === "space") tokens.pop(); return { words, tokens }; }; if (!window.Prism || !grammar) { return tokenize_plain(); } let highlighted = ""; try { highlighted = window.Prism.highlight(source, grammar, prism_language); } catch { return tokenize_plain(); } const root = document.createElement("div"); root.innerHTML = highlighted; const push_text = (text, syntax_class) => { const parts = text.split(/(\s+)/); for (const part of parts) { if (!part) continue; if (/^\s+$/.test(part)) { if (tokens.length) tokens.push({ type: "space", text: part }); continue; } if (words.length >= max_words) return false; const word = part.trim(); if (!word) continue; const index = words.length; words.push(word); tokens.push({ type: "word", text: word, index, syntax_class: syntax_class || "" }); } return words.length < max_words; }; const walk = (node, active_class) => { if (words.length >= max_words) return; if (node.nodeType === Node.TEXT_NODE) { push_text(node.nodeValue || "", active_class); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const element = node; const class_name = element.className && String(element.className).includes("token") ? String(element.className) : active_class; for (const child of Array.from(element.childNodes)) { if (words.length >= max_words) break; walk(child, class_name); } }; for (const child of Array.from(root.childNodes)) { if (words.length >= max_words) break; walk(child, ""); } while (tokens.length && tokens[tokens.length - 1].type === "space") tokens.pop(); return { words, tokens }; } function chars_from_tokens(tokens) { const chars = []; for (const token of tokens || []) { const text = String(token.text || ""); const syntax_class = token.type === "word" ? (token.syntax_class || "") : ""; for (const ch of text) { chars.push({ ch, syntax_class }); } } return chars; } const normalize_token = (value) => String(value || "") .normalize("NFKC") .replace(/[\u200B-\u200D\uFEFF]/g, "") .replace(/[‘’]/g, "'") .replace(/[“”]/g, '"') .replace(/[–—]/g, "-") .trim() .toLowerCase(); async function load_snippet_words() { const max_words = get_round_word_count(); for (let i = 0; i < 6; i += 1) { try { const params = new URLSearchParams({ track: state.difficulty, d: state.difficulty, t: `${Date.now()}-${i}` }); const res = await fetch(`/api/snippet?${params.toString()}`, { cache: "no-store" }); if (!res.ok) continue; const data = await res.json(); const source = data.source || "github"; if (!source_matches_track(source, state.difficulty)) continue; const snippet = String(data.snippet || ""); const language = detect_language(source); const parsed = tokenize_snippet_with_syntax(snippet, max_words, language); const words = parsed.words; const tokens = parsed.tokens; if (words.length < 12) continue; return { words, tokens, snippet, source, track: data.track || state.difficulty }; } catch { continue; } } return null; } function render_words() { dom.text_display.innerHTML = ""; const fragment = document.createDocumentFragment(); state.target_chars.forEach((item, idx) => { const span = document.createElement("span"); span.className = `char-unit${item.syntax_class ? ` ${item.syntax_class}` : ""}`; if (idx < state.index) { const typed = state.user_chars?.[idx] || ""; const correct = normalize_token(typed) === normalize_token(item.ch); span.classList.add(correct ? "char-ok" : "char-bad"); span.textContent = item.ch; } else if (idx === state.index) { span.classList.add("char-current"); span.textContent = item.ch; } else { span.textContent = item.ch; } fragment.appendChild(span); }); dom.text_display.appendChild(fragment); } function render_perks() { dom.perks.innerHTML = ""; PERKS.forEach((perk) => { const level_ready = state.level >= perk.unlock_level; const owned = has_perk(perk.id); const can_buy = level_ready && !owned && state.shards >= perk.cost; const card = document.createElement("article"); card.className = "perk"; card.innerHTML = `
${perk.description}