MikCil's picture
Deploy Pit Wall Chaos with Codex
6b135a8 verified
Raw History Blame Contribute Delete
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");