const MODEL_ID = "onnx-community/tiny-aya-global-ONNX"; const TRANSFORMERS_CDN = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.1"; const MODEL_DTYPE = "q4f16"; const GENERATION_OPTIONS = { max_new_tokens: 360, do_sample: true, temperature: 0.3, top_p: 0.95, }; const state = { game: null, clip: null, wordLimit: 12, history: [], currentJudged: false, transformers: null, modelPromise: null, judge: null, judgeAvailable: false, }; const $ = (id) => document.getElementById(id); const screens = { start: $("start-screen"), loading: $("loading-screen"), game: $("game-screen"), end: $("end-screen"), }; function showScreen(name) { Object.entries(screens).forEach(([key, node]) => { node.classList.toggle("hidden", key !== name); }); } async function api(path, payload = null) { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload === null ? "{}" : JSON.stringify(payload), }); const data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || "Race control could not complete that request."); } return data; } function setLoading(title, detail, progress = null) { $("loading-title").textContent = title; $("loading-detail").textContent = detail || ""; if (progress !== null) { $("loading-progress").style.width = `${Math.max(0, Math.min(100, progress))}%`; } } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes <= 0) return ""; const units = ["B", "KB", "MB", "GB"]; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } function countWords(text) { const matches = String(text || "").match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?/g); return matches ? matches.length : 0; } function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number(value) || 0))); } function updateWordCount() { const words = countWords($("reply-input").value); $("word-count").textContent = `Words: ${words} / ${state.wordLimit}`; $("word-count").classList.toggle("over", words > state.wordLimit); } function renderRoundDots() { const dots = $("round-dots"); dots.replaceChildren(); for (let index = 1; index <= state.game.max_rounds; index += 1) { const dot = document.createElement("span"); dot.className = index === state.game.round ? "active" : index < state.game.round ? "done" : ""; dots.append(dot); } } function renderMeters() { for (const key of ["trust", "pace", "chaos", "tyre_risk"]) { const domKey = key.replace("_", "-"); const value = clamp(state.game[key]); $(`${domKey}-value`).textContent = value; $(`${domKey}-meter`).style.width = `${value}%`; } } function formatDate(raw) { if (!raw || raw === "Unknown date") return raw || "--"; const year = String(raw).slice(0, 10); return year; } function renderClip() { state.currentJudged = false; $("round-label").textContent = `Round ${state.game.round} / ${state.game.max_rounds}`; $("clip-audio").src = state.clip.audio_url; $("transcript").textContent = `\u201c${state.clip.transcription}\u201d`; $("driver-id").textContent = state.clip.driver_id || "--"; $("grand-prix").textContent = state.clip.grand_prix || "--"; $("session-date").textContent = formatDate(state.clip.session_date); $("reply-input").value = ""; $("reply-input").disabled = false; $("submit-button").disabled = false; $("submit-button").classList.remove("hidden"); $("next-button").classList.add("hidden"); $("next-button").textContent = "Next Radio"; updateWordCount(); renderRoundDots(); renderMeters(); } function racingTermScore(reply) { const terms = [ "box", "push", "manage", "tyre", "tyres", "fronts", "rears", "plan", "delta", "target", "lap", "undercut", "overcut", "brake", "lift", "coast", "mode", "safety", "car", "drs", "gap", "copy", ]; const text = reply.toLowerCase(); return terms.reduce((total, term) => total + (text.includes(term) ? 1 : 0), 0); } function fallbackJudge(reply) { const wordCount = countWords(reply); const over = wordCount > state.wordLimit; const empty = wordCount === 0; const vibe = Math.min(10, 3 + racingTermScore(reply) * 2); const clarity = empty ? 0 : Math.min(10, reply.length < 90 ? 7 : 5); const brevity = over ? 2 : Math.max(5, 10 - Math.max(0, wordCount - 7)); const risk = over ? 7 : reply.toLowerCase().includes("push") ? 5 : 3; const fit = Math.min(10, Math.round((clarity + vibe) / 2)); let delta = { trust: empty ? -8 : Math.round((clarity - 5) + (vibe > 6 ? 2 : 0)), pace: empty ? -4 : reply.toLowerCase().includes("push") ? 4 : Math.max(0, fit - 6), chaos: empty ? 8 : Math.round(5 - brevity), tyre_risk: reply.toLowerCase().includes("push") ? 3 : reply.toLowerCase().includes("manage") ? -2 : 1, }; if (over) { delta = { trust: -4, pace: -1, chaos: 5, tyre_risk: 0 }; } const verdict = empty ? "Silence is rarely a strategy." : over ? "Too many words. The radio channel gave up halfway through." : vibe >= 7 ? "Clear enough for a stressed driver and nicely pit-wall coded." : "Understandable, but it needs more race-engineer bite."; const driverReaction = empty ? "Say again? I got nothing." : over ? "Mate, I cannot process all that." : vibe >= 7 ? "Copy. That is a plan." : "Okay, but be more specific next time."; return { word_count: wordCount, is_over_limit: over, clarity, brevity, fit, f1_vibe: vibe, risk, delta, verdict, driver_reaction: driverReaction, }; } function hasJudgeShape(value) { return Boolean( value && typeof value === "object" && value.delta && typeof value.delta === "object" && ("verdict" in value || "driver_reaction" in value) ); } function findJsonObject(text) { const source = String(text || ""); const starts = []; for (let i = 0; i < source.length; i += 1) { if (source[i] === "{") starts.push(i); } const candidates = []; for (const start of starts) { let depth = 0; for (let i = start; i < source.length; i += 1) { if (source[i] === "{") depth += 1; if (source[i] === "}") depth -= 1; if (depth === 0) { try { const raw = source.slice(start, i + 1); candidates.push({ value: JSON.parse(raw), length: raw.length }); } catch { // Keep scanning; the model may wrap valid JSON after malformed text. } break; } } } const judgeCandidates = candidates.filter((candidate) => hasJudgeShape(candidate.value)); return judgeCandidates.at(-1)?.value || candidates.sort((a, b) => b.length - a.length)[0]?.value || null; } function score10(value, fallback) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(0, Math.min(10, Math.round(number))); } function normalizeJudge(raw, reply) { const fallback = fallbackJudge(reply); if (!raw || typeof raw !== "object") return fallback; const delta = raw.delta && typeof raw.delta === "object" ? raw.delta : {}; return { word_count: countWords(reply), is_over_limit: countWords(reply) > state.wordLimit, clarity: score10(raw.clarity, fallback.clarity), brevity: score10(raw.brevity, fallback.brevity), fit: score10(raw.fit, fallback.fit), f1_vibe: score10(raw.f1_vibe, fallback.f1_vibe), risk: score10(raw.risk, fallback.risk), delta: { trust: Math.max(-10, Math.min(10, Math.round(Number(delta.trust ?? fallback.delta.trust)))), pace: Math.max(-10, Math.min(10, Math.round(Number(delta.pace ?? fallback.delta.pace)))), chaos: Math.max(-10, Math.min(10, Math.round(Number(delta.chaos ?? fallback.delta.chaos)))), tyre_risk: Math.max(-10, Math.min(10, Math.round(Number(delta.tyre_risk ?? fallback.delta.tyre_risk)))), }, verdict: String(raw.verdict || fallback.verdict).slice(0, 160), driver_reaction: String(raw.driver_reaction || fallback.driver_reaction).slice(0, 120), }; } async function ensureJudgeModel() { if (state.judge) return state.judge; if (state.modelPromise) return state.modelPromise; if (!navigator.gpu) { throw new Error("WebGPU is not available in this browser."); } state.modelPromise = (async () => { const mod = await import(TRANSFORMERS_CDN); mod.env.allowLocalModels = false; state.transformers = mod; const progress_callback = (event) => { if (!event) return; const file = event.file ? ` ${event.file}` : ""; const status = event.status || "Downloading"; const rawProgress = Number(event.progress); const loaded = formatBytes(Number(event.loaded)); const total = formatBytes(Number(event.total)); const sizeDetail = loaded && total ? ` (${loaded} / ${total})` : ""; const progress = Number.isFinite(rawProgress) ? rawProgress : null; setLoading("Downloading Tiny Aya", `${status}${file}${sizeDetail}`.trim(), progress); }; const tokenizer = await mod.AutoTokenizer.from_pretrained(MODEL_ID, { progress_callback }); const model = await mod.AutoModelForCausalLM.from_pretrained(MODEL_ID, { dtype: MODEL_DTYPE, device: "webgpu", progress_callback, }); state.judge = { tokenizer, model }; state.judgeAvailable = true; return state.judge; })().catch((error) => { state.modelPromise = null; state.judgeAvailable = false; throw error; }); return state.modelPromise; } async function judgeWithModel(reply) { const { tokenizer, model } = await ensureJudgeModel(); const { TextStreamer } = state.transformers; const schema = `{ "word_count": 7, "is_over_limit": false, "clarity": 8, "brevity": 10, "fit": 7, "f1_vibe": 9, "risk": 3, "delta": {"trust": 6, "pace": 3, "chaos": -2, "tyre_risk": 1}, "verdict": "Clear, decisive, and properly pit-wall coded." | "Incomprehensible - do you want your driver to crash??", "driver_reaction": "Copy. Finally, a plan." | "What? I'll end up dead last if I do that!" }`; const messages = [ { role: "system", content: "You are the AI race steward for a tiny F1 radio game. Return only one valid JSON object. No markdown. No explanation.", }, { role: "user", content: `The player is a race engineer replying to a messy real F1 team-radio clip. Judge whether the reply is brief, clear, useful, plausible F1 radio, and risky or chaotic. This is a fantasy game - try to be fun or chaotic when judging the effects of the team radio messages in "verdict" and "driver_reaction". Avoid boring responses - push back when the strategy isn't sound. Required JSON schema example: ${schema} Scores are integers from 0 to 10. Delta values are integers from -10 to 10. Current state: ${JSON.stringify({ round: state.game.round, max_rounds: state.game.max_rounds, trust: state.game.trust, pace: state.game.pace, chaos: state.game.chaos, tyre_risk: state.game.tyre_risk, })} Grand Prix: ${state.clip.grand_prix} Date: ${state.clip.session_date} Garbled radio feed: ${state.clip.transcription} Player reply: ${reply} Word limit: ${state.wordLimit} Return only the JSON object.`, }, ]; const inputs = tokenizer.apply_chat_template(messages, { add_generation_prompt: true, return_dict: true, }); let streamedText = ""; const streamer = new TextStreamer(tokenizer, { skip_prompt: true, skip_special_tokens: true, callback_function: (text) => { streamedText += text; }, }); const output = await model.generate({ ...inputs, ...GENERATION_OPTIONS, streamer, }); const generatedText = streamedText.trim() || tokenizer.decode(output[0], { skip_special_tokens: true }); console.debug("Tiny Aya judge output:", generatedText); const parsed = findJsonObject(generatedText); return normalizeJudge(parsed, reply); } function applyJudgeResult(result, reply) { if (result.is_over_limit) { state.game.over_limit_count = (state.game.over_limit_count || 0) + 1; result.delta = { trust: -4, pace: -1, chaos: 5, tyre_risk: 0 }; } for (const key of ["trust", "pace", "chaos", "tyre_risk"]) { state.game[key] = clamp(state.game[key] + result.delta[key]); } state.history.push({ reply, clip: state.clip, result }); } function deltaText(delta) { return Object.entries(delta) .map(([key, value]) => `${key.replace("_", " ")} ${value >= 0 ? "+" : ""}${value}`) .join(" | "); } function renderVerdict(result) { const score = Math.round((result.clarity + result.brevity + result.fit + result.f1_vibe - result.risk) / 4); $("verdict-grade").textContent = result.is_over_limit ? "Penalty" : score >= 7 ? "Good" : score >= 5 ? "Okay" : "Messy"; $("verdict-text").textContent = `${result.verdict} (${deltaText(result.delta)})`; $("reaction-text").textContent = result.driver_reaction; renderMeters(); } async function submitReply() { if (state.currentJudged) return; const reply = $("reply-input").value.trim(); $("submit-button").disabled = true; $("reply-input").disabled = true; $("verdict-grade").textContent = "Judging"; $("verdict-text").textContent = state.judgeAvailable ? "Tiny Aya is listening to the radio..." : "Race control is applying fallback stewarding..."; $("reaction-text").textContent = "Stand by."; let result; try { result = state.judgeAvailable ? await judgeWithModel(reply) : fallbackJudge(reply); } catch (error) { console.warn("Model judge failed; falling back to local rules.", error); result = fallbackJudge(reply); } result.word_count = countWords(reply); result.is_over_limit = result.word_count > state.wordLimit; applyJudgeResult(result, reply); renderVerdict(result); state.currentJudged = true; $("submit-button").classList.add("hidden"); $("next-button").textContent = state.game.round >= state.game.max_rounds ? "Final Result" : "Next Radio"; $("next-button").classList.remove("hidden"); } async function loadNextClip() { if (state.game.round >= state.game.max_rounds && state.currentJudged) { showEndScreen(); return; } $("next-button").disabled = true; $("verdict-grade").textContent = "Loading"; $("reaction-text").textContent = "Finding the next radio burst."; try { const data = await api("/next_clip", { driver_id: state.game.driver_id, grand_prix: state.clip.grand_prix, used_clip_ids: state.game.used_clip_ids, }); state.clip = data.clip; state.game.round += 1; state.game.used_clip_ids.push(state.clip.id); $("verdict-grade").textContent = "Ready"; $("verdict-text").textContent = "The driver is back on the radio."; $("reaction-text").textContent = "Your call."; renderClip(); } catch (error) { $("verdict-grade").textContent = "Error"; $("verdict-text").textContent = error.message; } finally { $("next-button").disabled = false; } } function finalResult() { const { trust, pace, chaos, tyre_risk: tyreRisk, over_limit_count: overLimit } = state.game; if (overLimit >= 3) return ["DSQ - Message Exceeded Track Limits", "Too Many Words Penalty"]; if (trust < 25 || chaos > 78) return ["DNF - Driver Muted The Radio", "Radio Silence"]; if (chaos > 62) return ["P13 - Strategy Department Incident", "Incident Report Enjoyer"]; if (trust > 68 && pace > 65 && chaos < 36) return ["P1 - Tactical Genius", "Delta Whisperer"]; if (trust > 56 && pace > 54 && chaos < 55 && tyreRisk < 58) return ["P4 - Sensible Pit Wall", "Box Box Certified"]; return ["P8 - We Will Take The Points", "Brevity Merchant"]; } function showEndScreen() { const [result, badge] = finalResult(); $("final-result").textContent = result; $("final-badge").textContent = `Badge: ${badge}`; const best = [...state.history].sort((a, b) => { const scoreA = a.result.clarity + a.result.fit + a.result.f1_vibe - a.result.risk; const scoreB = b.result.clarity + b.result.fit + b.result.f1_vibe - b.result.risk; return scoreB - scoreA; })[0]; $("best-reply").textContent = best?.reply || "--"; $("final-meters").innerHTML = ` Trust ${state.game.trust} Pace ${state.game.pace} Chaos ${state.game.chaos} Tyre Risk ${state.game.tyre_risk} `; showScreen("end"); } async function startRace() { showScreen("loading"); $("loading-note").textContent = ""; setLoading("Preparing radio deck", "Loading the local F1 radio manifest...", 8); state.history = []; state.currentJudged = false; try { const dataPromise = api("/prepare"); const modelPromise = ensureJudgeModel().catch((error) => { state.judgeAvailable = false; $("loading-note").textContent = `Tiny Aya fallback active: ${String(error.message || error).slice(0, 90)}`; return null; }); const data = await dataPromise; setLoading("Preparing race state", `${data.clip_count} playable clips indexed.`, 72); await modelPromise; if (state.judgeAvailable) { setLoading("Tiny Aya ready", "Race control is online in the browser.", 100); } state.game = data.game_state; state.clip = data.clip; state.wordLimit = data.word_limit || 12; renderClip(); showScreen("game"); $("reply-input").focus(); } catch (error) { setLoading("Race start failed", error.message, 100); } } function restartToStart() { $("clip-audio").pause(); showScreen("start"); } function showHowTo() { $("how-to-modal").classList.remove("hidden"); } function hideHowTo() { $("how-to-modal").classList.add("hidden"); } $("start-button").addEventListener("click", startRace); $("how-to-button").addEventListener("click", showHowTo); $("close-how-to-button").addEventListener("click", hideHowTo); $("close-how-to-x").addEventListener("click", hideHowTo); $("how-to-modal").addEventListener("click", (event) => { if (event.target === $("how-to-modal")) hideHowTo(); }); $("restart-button").addEventListener("click", restartToStart); $("play-again-button").addEventListener("click", startRace); $("submit-button").addEventListener("click", submitReply); $("next-button").addEventListener("click", loadNextClip); $("reply-input").addEventListener("input", updateWordCount); document.querySelectorAll(".quick-button").forEach((button) => { button.addEventListener("click", () => { $("reply-input").value = button.dataset.reply || ""; $("reply-input").focus(); updateWordCount(); }); }); showScreen("start");