Download static/app.js from build-small-hackathon/pit-wall-chaos: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/build-small-hackathon/pit-wall-chaos/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/build-small-hackathon/pit-wall-chaos/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/build-small-hackathon/pit-wall-chaos/resolve/main/static/app.js
18.8 kB
| 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 = ` | |
| <span>Trust ${state.game.trust}</span> | |
| <span>Pace ${state.game.pace}</span> | |
| <span>Chaos ${state.game.chaos}</span> | |
| <span>Tyre Risk ${state.game.tyre_risk}</span> | |
| `; | |
| 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"); | |