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.name} · lv ${perk.unlock_level}

${perk.description}

cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}
${owned ? "active" : level_ready ? "shop open" : "locked"}`; dom.perks.appendChild(card); }); } function render_settings() { dom.profile_name.value = state.profile_name; dom.difficulty_select.value = state.difficulty; if (dom.challenge_select) dom.challenge_select.value = state.challenge; dom.difficulty_bonus.textContent = `track xp bonus: x${get_track_xp_mult().toFixed(2)}`; if (dom.challenge_bonus) { const words_percent = Math.round(CHALLENGE_CONFIG[state.challenge].words_mult * 100); dom.challenge_bonus.textContent = `challenge xp bonus: x${get_challenge_xp_mult().toFixed(2)} · round: ${get_round_seconds()}s · snippet size: ${words_percent}%`; } } function render_progress() { const level_need = xp_needed_for_level(state.level); dom.profile_title.textContent = state.profile_name; dom.level_text.textContent = `level ${state.level}`; dom.xp_text.textContent = `${state.xp} / ${level_need} xp`; dom.shard_text.textContent = `perk shards: ${state.shards}`; dom.xp_fill.style.width = `${Math.min((state.xp / level_need) * 100, 100)}%`; dom.xp_gain.textContent = `+${state.last_round_xp} xp last round`; render_settings(); render_perks(); } function update_live_stats() { const elapsed = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed); const total = state.correct_words + state.wrong_words; const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; dom.wpm.textContent = String(wpm); dom.accuracy.textContent = `${accuracy}%`; dom.combo.textContent = `x${state.combo}`; } function update_timer() { const remaining = Math.max(state.round_seconds - state.elapsed_seconds, 0); dom.time.textContent = `${remaining}s`; dom.time.style.color = remaining <= 10 ? "#ffd587" : ""; } function start_timer() { if (state.running) return; state.running = true; state.timer = setInterval(() => { state.elapsed_seconds += 1; update_timer(); update_live_stats(); if (state.elapsed_seconds >= state.round_seconds) finish_round(); }, 1000); } function award_xp(summary) { let xp = 35 + Math.floor(summary.wpm * 1.4) + Math.floor(summary.accuracy * 0.55) + Math.floor(summary.best_combo * 2.5); if (has_perk("combo-boost") && summary.best_combo >= 5) xp = Math.floor(xp * 1.15); if (has_perk("accuracy-bounty") && summary.accuracy >= 96) xp += 30; if (has_perk("focus-engine") && summary.accuracy >= 98) xp += Math.floor(summary.wpm * 0.25); xp = Math.floor(xp * get_total_xp_mult()); let pending = xp; let level_ups = 0; const milestone_levels = []; while (pending > 0) { const need = xp_needed_for_level(state.level) - state.xp; if (pending >= need) { pending -= need; state.level += 1; state.xp = 0; level_ups += 1; if (state.level % 5 === 0) milestone_levels.push(state.level); } else { state.xp += pending; pending = 0; } } state.shards += level_ups; state.last_round_xp = xp; return { xp, milestone_levels, level_ups }; } function track_round(summary, earned_xp) { const s = state.stats; s.total_rounds += 1; s.best_wpm = Math.max(s.best_wpm, summary.wpm); s.best_accuracy = Math.max(s.best_accuracy, summary.accuracy); s.average_wpm = ((s.average_wpm * (s.total_rounds - 1)) + summary.wpm) / s.total_rounds; s.average_accuracy = ((s.average_accuracy * (s.total_rounds - 1)) + summary.accuracy) / s.total_rounds; state.recent_rounds.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty, challenge: state.challenge }); state.recent_rounds = state.recent_rounds.slice(0, 20); } function set_current_word(jump_to_next_line = false) { const node = dom.text_display.querySelectorAll(".char-unit")[state.index]; if (!node) return; node.scrollIntoView({ block: jump_to_next_line ? "start" : "nearest", behavior: "smooth" }); } function finish_round() { clearInterval(state.timer); state.running = false; const elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed_m); const total = state.correct_words + state.wrong_words; const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; const summary = { wpm, accuracy, best_combo: state.best_combo }; const reward = award_xp(summary); const earned_xp = reward.xp; queue_levelup_milestones(reward.milestone_levels); queue_xp_level_pings(reward.level_ups); track_round(summary, earned_xp); render_progress(); update_live_stats(); save_progress(); dom.results.hidden = false; dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`; } async function reset_round() { clearInterval(state.timer); state.round_nonce += 1; const nonce = state.round_nonce; const loaded = await load_snippet_words(); const fallback_words = pick_words(); const fallback_snippet = fallback_words.join(" "); const fallback_parsed = tokenize_snippet_with_syntax(fallback_snippet, get_round_word_count(), "plaintext"); const tokens = loaded?.tokens || fallback_parsed.tokens; const chars = chars_from_tokens(tokens); const source_label = loaded?.source ? `source: ${loaded.source}${loaded.track ? ` · track: ${loaded.track}` : ""}` : "source: local fallback"; const snippet = loaded?.snippet || fallback_snippet; if (nonce !== state.round_nonce) return; Object.assign(state, { target_chars: chars, index: 0, user_chars: [], typed_chars: 0, correct_words: 0, wrong_words: 0, combo: 0, best_combo: 0, elapsed_seconds: 0, round_seconds: get_round_seconds(), running: false }); set_code_display(snippet, loaded?.source || "fallback.txt"); dom.snippet_source.textContent = source_label; render_words(); update_live_stats(); update_timer(); dom.results.hidden = true; } function set_profile_name(value) { state.profile_name = value.trim().slice(0, 24) || DEFAULT_STATE.profile_name; render_progress(); save_progress(); } function set_difficulty(value) { state.difficulty = sanitize_difficulty(value); render_progress(); reset_round(); save_progress(); } function set_challenge(value) { state.challenge = sanitize_challenge(value); render_progress(); reset_round(); save_progress(); } function buy_perk(perk_id) { const perk = PERKS.find((p) => p.id === perk_id); if (!perk || has_perk(perk_id) || state.level < perk.unlock_level || state.shards < perk.cost) return; state.shards -= perk.cost; state.owned_perks.push(perk.id); render_progress(); reset_round(); save_progress(); } function reset_progress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] }); render_progress(); reset_round(); save_progress(); } function submit_typed_word() { return; } function can_capture_typing(target) { if (target instanceof HTMLInputElement) return true; if (target instanceof HTMLTextAreaElement) return true; if (target instanceof HTMLSelectElement) return true; if (target instanceof HTMLElement && target.isContentEditable) return true; return false; } function handle_typing_keydown(e) { if (!dom.settings_modal.hidden || !dom.reset_confirm_modal.hidden) return; if (!dom.results.hidden || state.index >= state.target_chars.length) return; if (can_capture_typing(e.target)) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === "Backspace") { e.preventDefault(); if (state.index <= 0) return; state.index -= 1; const removed = state.user_chars[state.index]; state.user_chars[state.index] = ""; if (removed) { const expected = state.target_chars[state.index]?.ch || ""; const correct = normalize_token(removed) === normalize_token(expected); if (correct) state.correct_words = Math.max(0, state.correct_words - 1); else state.wrong_words = Math.max(0, state.wrong_words - 1); } state.combo = 0; render_words(); return; } let typed_char = ""; if (e.key === "Enter") typed_char = "\n"; else if (e.key === "Tab") typed_char = "\t"; else if (e.key.length === 1) typed_char = e.key; else return; e.preventDefault(); if (!state.running) start_timer(); const expected = state.target_chars[state.index]?.ch || ""; state.user_chars[state.index] = typed_char; state.typed_chars += 1; let completed_line = false; const correct = normalize_token(typed_char) === normalize_token(expected); if (correct) { state.correct_words += 1; state.combo += 1; state.best_combo = Math.max(state.best_combo, state.combo); } else { state.wrong_words += 1; state.combo = 0; } state.index += 1; while (state.index < state.target_chars.length && state.target_chars[state.index].ch === "\n") { state.user_chars[state.index] = "\n"; state.correct_words += 1; state.combo += 1; state.best_combo = Math.max(state.best_combo, state.combo); state.index += 1; completed_line = true; } if (completed_line) { while (state.index < state.target_chars.length) { const ch = state.target_chars[state.index].ch; if (ch !== " " && ch !== "\t") break; state.user_chars[state.index] = ch; state.correct_words += 1; state.combo += 1; state.best_combo = Math.max(state.best_combo, state.combo); state.index += 1; } } render_words(); update_live_stats(); if (state.index >= state.target_chars.length) return finish_round(); set_current_word(completed_line); } dom.new_round_btn.addEventListener("click", reset_round); dom.play_again_btn.addEventListener("click", reset_round); dom.settings_btn.addEventListener("click", open_settings); dom.close_settings_btn.addEventListener("click", close_settings); dom.tab_type_btn?.addEventListener("click", () => set_side_tab("type")); dom.tab_perks_btn?.addEventListener("click", () => set_side_tab("perks")); dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value)); dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value)); dom.challenge_select?.addEventListener("change", (e) => set_challenge(e.target.value)); dom.reset_progress_btn.addEventListener("click", open_reset_confirm); dom.cancel_reset_btn.addEventListener("click", close_reset_confirm); dom.confirm_reset_btn.addEventListener("click", () => { reset_progress(); close_reset_confirm(); close_settings(); }); dom.settings_modal.addEventListener("click", (e) => e.target === dom.settings_modal && close_settings()); dom.reset_confirm_modal.addEventListener("click", (e) => e.target === dom.reset_confirm_modal && close_reset_confirm()); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (!dom.reset_confirm_modal.hidden) return close_reset_confirm(); if (!dom.settings_modal.hidden) close_settings(); }); document.addEventListener("keydown", handle_typing_keydown); dom.perks.addEventListener("click", (e) => { const target = e.target; if (!(target instanceof HTMLButtonElement)) return; const perk_id = target.dataset.perkId; if (perk_id) buy_perk(perk_id); }); load_progress(); render_progress(); set_side_tab("type"); reset_round();