wpm
0diff --git a/index.js b/index.js index 0c61104..7a21176 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ const http = require("http"); const { readFile, stat } = require("fs"); +const https = require("https"); const { extname, join, normalize } = require("path"); const port = process.env.PORT || 3000; @@ -19,6 +20,208 @@ const mime_types = { ".ico": "image/x-icon" }; +const github_sources_by_track = { + frontend: [ + { repo: "vuejs/core", ref: "main", files: ["packages/reactivity/src/effect.ts", "packages/reactivity/src/ref.ts"] }, + { repo: "twbs/bootstrap", ref: "main", files: ["js/src/modal.js", "js/src/collapse.js"] }, + { repo: "vercel/next.js", ref: "canary", files: ["packages/next/src/client/app-router.ts", "packages/next/src/client/components/router-reducer/router-reducer.ts"] }, + { repo: "microsoft/TypeScript", ref: "main", files: ["src/services/services.ts", "src/compiler/utilities.ts"] } + ], + backend: [ + { repo: "nodejs/node", ref: "main", files: ["lib/fs.js", "lib/http.js"] }, + { repo: "python/cpython", ref: "main", files: ["Lib/pathlib.py", "Lib/http/server.py"] }, + { repo: "golang/go", ref: "master", files: ["src/net/http/server.go", "src/database/sql/sql.go"] }, + { repo: "laravel/framework", ref: "master", files: ["src/Illuminate/Routing/Router.php", "src/Illuminate/Database/Query/Builder.php"] } + ], + systems: [ + { repo: "rust-lang/rust", ref: "master", files: ["library/std/src/fs.rs", "library/std/src/path.rs"] }, + { repo: "redis/redis", ref: "unstable", files: ["src/server.c", "src/networking.c"] }, + { repo: "torvalds/linux", ref: "master", files: ["kernel/sched/core.c", "kernel/fork.c"] }, + { repo: "llvm/llvm-project", ref: "main", files: ["llvm/lib/Support/CommandLine.cpp", "llvm/lib/Support/Path.cpp"] } + ], + "full-stack": [ + { repo: "vercel/next.js", ref: "canary", files: ["packages/next/src/server/base-server.ts", "packages/next/src/client/app-router.ts"] }, + { repo: "remix-run/remix", ref: "main", files: ["packages/remix-server-runtime/server.ts", "packages/remix-react/components.tsx"] }, + { repo: "microsoft/TypeScript", ref: "main", files: ["src/compiler/checker.ts", "src/compiler/watch.ts"] }, + { repo: "nodejs/node", ref: "main", files: ["lib/path.js", "lib/http.js"] } + ] +}; + +const all_tracks = Object.values(github_sources_by_track).flat(); +const sanitize_track = (value) => { + if (github_sources_by_track[value]) return value; + if (value === "easy") return "frontend"; + if (value === "normal") return "backend"; + if (value === "hard" || value === "systems programming" || value === "systems-programming") return "systems"; + if (value === "expert" || value === "fullstack") return "full-stack"; + return "full-stack"; +}; + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; +const count_indent = (line) => (line.match(/^\s*/)?.[0].length || 0); + +const fetch_text = (url) => + new Promise((resolve, reject) => { + https + .get(url, { headers: { "User-Agent": "typeup-snippet-fetcher" } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + fetch_text(res.headers.location).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`status ${res.statusCode}`)); + return; + } + + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve(body)); + }) + .on("error", reject); + }); + +const extension_from_source = (source) => (source.split(".").pop() || "").toLowerCase(); + +const normalize_lines = (text) => text.split("\n").map((line) => line.replace(/\t/g, " ").replace(/\r$/, "")); + +const push_candidate = (list, lines, start, end, min_lines, max_lines) => { + if (start < 0 || end <= start || end >= lines.length) return; + while (start <= end && !lines[start].trim()) start += 1; + while (end >= start && !lines[end].trim()) end -= 1; + const count = end - start + 1; + if (count < min_lines || count > max_lines) return; + const snippet = lines.slice(start, end + 1).join("\n").trim(); + if (!snippet) return; + list.push(snippet); +}; + +const extract_brace_blocks = (lines, min_lines, max_lines) => { + const out = []; + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].includes("{")) continue; + let depth = 0; + let started = false; + for (let j = i; j < lines.length; j += 1) { + const opens = (lines[j].match(/\{/g) || []).length; + const closes = (lines[j].match(/\}/g) || []).length; + if (opens > 0) started = true; + depth += opens; + depth -= closes; + if (started && depth === 0 && j > i) { + push_candidate(out, lines, i, j, min_lines, max_lines); + break; + } + if (depth < 0) break; + } + } + return out; +}; + +const extract_python_blocks = (lines, min_lines, max_lines) => { + const out = []; + const header = /^\s*(def|class|if|elif|else|for|while|try|except|finally|with|match|case)\b.*:\s*$/; + for (let i = 0; i < lines.length; i += 1) { + if (!header.test(lines[i])) continue; + const base = count_indent(lines[i]); + let started_body = false; + let end = i; + for (let j = i + 1; j < lines.length; j += 1) { + const line = lines[j]; + if (!line.trim()) { + if (started_body) end = j; + continue; + } + const indent = count_indent(line); + if (indent > base) { + started_body = true; + end = j; + continue; + } + break; + } + if (started_body) push_candidate(out, lines, i, end, min_lines, max_lines); + } + return out; +}; + +const extract_paragraph_chunks = (lines, min_lines, max_lines) => { + const out = []; + let start = -1; + for (let i = 0; i <= lines.length; i += 1) { + const is_blank = i === lines.length || !lines[i].trim(); + if (!is_blank && start === -1) start = i; + if (is_blank && start !== -1) { + push_candidate(out, lines, start, i - 1, min_lines, max_lines); + start = -1; + } + } + return out; +}; + +const dedupe_candidates = (items) => Array.from(new Set(items)); + +const snippet_from_text = (text, source = "") => { + const lines = normalize_lines(text); + const min_lines = 6; + const max_lines = 36; + const ext = extension_from_source(source); + + let candidates = []; + + if (["js", "mjs", "cjs", "ts", "tsx", "jsx", "css", "json"].includes(ext)) { + candidates = candidates.concat(extract_brace_blocks(lines, min_lines, max_lines)); + } + + if (ext === "py") { + candidates = candidates.concat(extract_python_blocks(lines, min_lines, max_lines)); + } + + candidates = dedupe_candidates(candidates); + if (!candidates.length) { + candidates = dedupe_candidates(extract_paragraph_chunks(lines, min_lines, max_lines)); + } + + if (!candidates.length) return ""; + return pick(candidates); +}; + +const send_json = (res, status, payload) => { + res.writeHead(status, { + "Content-Type": "application/json; charset=UTF-8", + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0" + }); + res.end(JSON.stringify(payload)); +}; + +const send_github_snippet = async (res, track) => { + const selected_track = sanitize_track(track); + const source_pool = github_sources_by_track[selected_track] || all_tracks; + + for (let i = 0; i < 8; i += 1) { + const source = pick(source_pool); + const file = pick(source.files); + const url = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${file}`; + + try { + const text = await fetch_text(url); + const snippet = snippet_from_text(text, file); + if (!snippet) continue; + send_json(res, 200, { snippet, source: `${source.repo}/${file}`, track: selected_track }); + return; + } catch { + // Try another repo/file. + } + } + + send_json(res, 502, { error: "could not fetch snippet" }); +}; + const safe_path = (url_path) => { if (!url_path || url_path === "/") return join(public_dir, "index.html"); const cleaned = normalize(url_path).replace(/^(\.\.([/\\]|$))+/, ""); @@ -45,7 +248,16 @@ const send = (file_path, res) => { http .createServer((req, res) => { - const file_path = safe_path((req.url || "/").split("?")[0]); + const request_url = new URL(req.url || "/", "http://localhost"); + const requested_track = request_url.searchParams.get("d") || request_url.searchParams.get("track") || "full-stack"; + const path_only = (req.url || "/").split("?")[0]; + + if (path_only === "/api/snippet") { + send_github_snippet(res, requested_track); + return; + } + + const file_path = safe_path(path_only); stat(file_path, (err, info) => send(!err && info.isDirectory() ? join(file_path, "index.html") : file_path, res)); }) .listen(port, () => { diff --git a/public/app.js b/public/app.js index a27e734..737fc35 100644 --- a/public/app.js +++ b/public/app.js @@ -1,17 +1,24 @@ -const STORAGE_KEY = "levelup-typing-save-v1"; +const STORAGE_KEY = "speedcode-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 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 = { - easy: { xp_mult: 0.9, round_words: 38 }, - normal: { xp_mult: 1, round_words: 45 }, - hard: { xp_mult: 1.25, round_words: 50 }, - expert: { xp_mult: 1.5, round_words: 54 } + 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 = [ @@ -21,10 +28,18 @@ const PERKS = [ { 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: "normal", - words: [], + difficulty: "full-stack", + challenge: "standard", + target_chars: [], index: 0, typed_chars: 0, correct_words: 0, @@ -40,6 +55,7 @@ const DEFAULT_STATE = { 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: [] }; @@ -50,27 +66,167 @@ 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"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"), + 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"), reset_progress_btn: q("resetProgressBtn") + 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) => (DIFFICULTY_CONFIG[v] ? v : "normal"); +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, @@ -88,6 +244,7 @@ function load_progress() { 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))); @@ -113,7 +270,8 @@ function load_progress() { accuracy: num(r.accuracy), best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")), xp: num(r.xp), - difficulty: sanitize_difficulty(r.difficulty) + difficulty: sanitize_difficulty(r.difficulty), + challenge: sanitize_challenge(r.challenge) })) : []; } catch { @@ -122,19 +280,169 @@ function load_progress() { } function pick_words() { - const cfg = DIFFICULTY_CONFIG[state.difficulty]; - const pool = WORD_BANKS[state.difficulty]; - return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]); + 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 = ""; - state.words.forEach((word, idx) => { + const fragment = document.createDocumentFragment(); + state.target_chars.forEach((item, idx) => { const span = document.createElement("span"); - span.className = `word${idx === state.index ? " current" : ""}`; - span.textContent = `${word} `; - dom.text_display.appendChild(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() { @@ -159,7 +467,12 @@ function render_perks() { function render_settings() { dom.profile_name.value = state.profile_name; dom.difficulty_select.value = state.difficulty; - dom.difficulty_bonus.textContent = `xp bonus: x${DIFFICULTY_CONFIG[state.difficulty].xp_mult.toFixed(2)}`; + 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() { @@ -206,10 +519,11 @@ function award_xp(summary) { 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 * DIFFICULTY_CONFIG[state.difficulty].xp_mult); + 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) { @@ -217,6 +531,7 @@ function award_xp(summary) { 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; @@ -224,7 +539,7 @@ function award_xp(summary) { } state.shards += level_ups; state.last_round_xp = xp; - return xp; + return { xp, milestone_levels, level_ups }; } function track_round(summary, earned_xp) { @@ -234,28 +549,19 @@ function track_round(summary, earned_xp) { 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 }); + 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 mark_word_result(correct) { - const node = dom.text_display.querySelectorAll(".word")[state.index]; +function set_current_word(jump_to_next_line = false) { + const node = dom.text_display.querySelectorAll(".char-unit")[state.index]; if (!node) return; - node.classList.remove("current"); - node.classList.add(correct ? "correct" : "incorrect"); -} - -function set_current_word() { - const node = dom.text_display.querySelectorAll(".word")[state.index]; - if (!node) return; - node.classList.add("current"); - node.scrollIntoView({ block: "center", behavior: "smooth" }); + node.scrollIntoView({ block: jump_to_next_line ? "start" : "nearest", behavior: "smooth" }); } function finish_round() { clearInterval(state.timer); state.running = false; - dom.type_input.disabled = true; const elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed_m); @@ -263,7 +569,10 @@ function finish_round() { const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; const summary = { wpm, accuracy, best_combo: state.best_combo }; - const earned_xp = award_xp(summary); + 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(); @@ -273,26 +582,41 @@ function finish_round() { dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`; } -function reset_round() { +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, { - words: pick_words(), + 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: has_perk("time-warp") ? 68 : 60, + 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.type_input.disabled = false; - dom.type_input.value = ""; - dom.type_input.focus(); dom.results.hidden = true; } @@ -309,6 +633,13 @@ function set_difficulty(value) { 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; @@ -322,21 +653,67 @@ function buy_perk(perk_id) { function reset_progress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); - Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] }); + Object.assign(state, { + ...DEFAULT_STATE, + stats: { ...DEFAULT_STATE.stats }, + recent_rounds: [], + owned_perks: [] + }); render_progress(); reset_round(); save_progress(); } -dom.type_input.addEventListener("input", () => { - const value = dom.type_input.value; - if (!state.running && value.length) start_timer(); - if (!value.endsWith(" ")) return; +function submit_typed_word() { + return; +} - const typed_word = value.trim(); - const correct = typed_word === state.words[state.index]; - state.typed_chars += typed_word.length + 1; +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; @@ -346,20 +723,44 @@ dom.type_input.addEventListener("input", () => { state.combo = 0; } - mark_word_result(correct); state.index += 1; - dom.type_input.value = ""; - if (state.index >= state.words.length) return finish_round(); - set_current_word(); + + 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", () => { @@ -374,6 +775,7 @@ document.addEventListener("keydown", (e) => { 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; @@ -384,4 +786,5 @@ dom.perks.addEventListener("click", (e) => { load_progress(); render_progress(); +set_side_tab("type"); reset_round(); diff --git a/public/index.html b/public/index.html index 040d956..a206903 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,8 @@
-wpm
0accuracy
100%combo
x0time
60swpm
0accuracy
100%combo
x0time
60s
+ language: loading...
+source: loading...
-+0 xp last round
+xp bonus: x1.00
+ + +track xp bonus: x1.00
+challenge xp bonus: x1.00 · round: 60s · snippet size: 100%