Files
speedcode/public/app.js
T
2026-04-19 18:35:34 -05:00

791 lines
29 KiB
JavaScript

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 = `<h3>${perk.name} · lv ${perk.unlock_level}</h3>
<p>${perk.description}</p>
<div class="perk-actions">
<span class="perk-cost">cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
<button class="perk-buy" data-perk-id="${perk.id}" ${can_buy ? "" : "disabled"}>${owned ? "owned" : level_ready ? "buy" : "locked"}</button>
</div>
<span class="status ${owned ? "active" : level_ready ? "unlocked" : "locked"}">${owned ? "active" : level_ready ? "shop open" : "locked"}</span>`;
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();