← back to Model Wars
public/app.js
347 lines
// app.js — MODEL WARS orchestration: champions, battles, judging, ELO ladder.
(function () {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
let CHAMPS = []; // from /api/champions
let JUDGE = null;
let last = null; // last battle payload (for evidence + voting)
// ── battle catalogue ───────────────────────────────────
const MODES = [
{ id: 'joust', icon: '⚔️', name: 'Joust', desc: 'Full-spectrum clash. Every category feeds the strike.',
prompt: 'Explain why the sky is blue in exactly three sentences, then name one common misconception about it.',
w: { correctness: 2, reasoning: 2, relevance: 2, completeness: 2, instruction_adherence: 2, hallucination_risk: 2 } },
{ id: 'archery', icon: '🏹', name: 'Archery', desc: 'Precision questions. Accuracy places the arrow.',
prompt: 'Answer precisely and concisely:\n1) The 12th prime number?\n2) The year the Magna Carta was sealed?\n3) The chemical symbol for tungsten?',
w: { correctness: 4, reasoning: 1, relevance: 2, completeness: 1, instruction_adherence: 2, hallucination_risk: 4 } },
{ id: 'siege', icon: '🏰', name: 'Castle Siege', desc: 'Multi-step reasoning topples gate, walls, towers, keep.',
prompt: 'A farmer has 17 sheep. All but 9 run away. He then buys three times as many as remain, and finally sells half of his flock. How many sheep does he have? Show each reasoning step.',
w: { correctness: 3, reasoning: 4, relevance: 1, completeness: 3, instruction_adherence: 2, hallucination_risk: 2 } },
{ id: 'duel', icon: '🗡️', name: 'Duel', desc: 'Coding, math or logic. Correct reasoning strikes true.',
prompt: 'Write a JavaScript function isPalindrome(s) that ignores case and non-alphanumeric characters, then state its time and space complexity.',
w: { correctness: 4, reasoning: 3, relevance: 1, completeness: 2, instruction_adherence: 3, hallucination_risk: 2 } },
{ id: 'dragon', icon: '🐉', name: 'Dragon', desc: 'One brutal problem. Race to defeat the beast first.',
prompt: 'Prove there are infinitely many prime numbers, then estimate how many primes lie below 1,000,000 and justify the estimate.',
w: { correctness: 3, reasoning: 4, relevance: 1, completeness: 3, instruction_adherence: 1, hallucination_risk: 3 } },
];
let mode = MODES[0];
const DIMS = ['correctness', 'reasoning', 'relevance', 'completeness', 'instruction_adherence', 'hallucination_risk'];
const DIM_LABEL = { correctness: 'Correctness', reasoning: 'Reasoning', relevance: 'Relevance', completeness: 'Completeness', instruction_adherence: 'Instruction', hallucination_risk: 'Halluc. risk' };
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('show'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('show'), 2600); };
const fmtMs = (ms) => ms == null ? '—' : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
const fmtCost = (c) => c == null ? '—' : c < 0.01 ? `$${c.toFixed(5)}` : `$${c.toFixed(4)}`;
// ── boot ───────────────────────────────────────────────
async function boot() {
let r;
try { r = await fetch('/api/champions').then((x) => x.json()); }
catch (e) { toast('Failed to load champions — is the server running? ' + e.message); return; }
CHAMPS = r.champions; JUDGE = r.judge;
const liveCount = CHAMPS.filter((c) => c.available).length;
const flag = $('#modeFlag'), txt = $('#modeText');
if (liveCount >= 2) { flag.className = 'mode-flag mode-live'; txt.textContent = `LIVE ARENA · ${liveCount} champions armed · Judge: ${JUDGE || 'none'}`; }
else { flag.className = 'mode-flag mode-exh'; txt.textContent = `EXHIBITION MODE · add API keys in .env to battle live (${liveCount}/6 armed)`; }
$('#footMode').innerHTML = liveCount >= 2
? 'Live battles move the King\'s Table. Exhibition bouts are labeled and never counted.'
: 'Running in <b>exhibition</b> — bouts are simulated & clearly labeled; they do not move the King\'s Table.';
fillSelect($('#selA'), CHAMPS[0].id);
fillSelect($('#selB'), CHAMPS[1].id);
renderFighter('A'); renderFighter('B');
renderModes();
$('#prompt').value = mode.prompt;
loadLadder();
}
function fillSelect(sel, chosen) {
sel.innerHTML = CHAMPS.map((c) => `<option value="${c.id}" ${c.id === chosen ? 'selected' : ''}>${c.crest} ${c.name} — ${c.title}${c.available ? '' : ' (no key)'}</option>`).join('');
}
function champById(id) { return CHAMPS.find((c) => c.id === id); }
function renderFighter(side) {
const sel = $(side === 'A' ? '#selA' : '#selB');
const box = $(side === 'A' ? '#fighterA' : '#fighterB');
const c = champById(sel.value);
const portrait = box.querySelector('.portrait');
const crest = box.querySelector('.crest');
if (c.img) { portrait.src = c.img; portrait.style.display = ''; crest.style.display = 'none'; portrait.style.boxShadow = `0 0 30px ${c.color}55`; }
else { portrait.style.display = 'none'; crest.style.display = ''; }
crest.textContent = c.crest;
crest.style.filter = `drop-shadow(0 0 14px ${c.color})`;
const cname = box.querySelector('.cname');
cname.textContent = c.name; cname.style.color = c.color;
box.querySelector('.ctitle').textContent = c.title;
const av = box.querySelector('.avail');
av.className = 'avail ' + (c.available ? 'on' : 'off');
av.querySelector('.txt').textContent = c.available ? `Armed · ${c.model}` : 'No API key — exhibition only';
updateHint();
}
function renderModes() {
$('#modes').innerHTML = MODES.map((m) => `
<div class="mode-card glass ${m.id === mode.id ? 'sel' : ''}" data-mode="${m.id}">
<div class="mi">${m.icon}</div><div class="mn">${m.name}</div><div class="md">${m.desc}</div>
</div>`).join('');
$$('#modes .mode-card').forEach((el) => el.onclick = () => {
mode = MODES.find((m) => m.id === el.dataset.mode);
$('#prompt').value = mode.prompt;
renderModes();
});
}
function bothLive() {
const a = champById($('#selA').value), b = champById($('#selB').value);
return a.available && b.available && a.id !== b.id;
}
function updateHint() {
const a = champById($('#selA').value), b = champById($('#selB').value);
const h = $('#battleHint');
if (a.id === b.id) { h.textContent = '⚠ Choose two different champions.'; return; }
h.textContent = bothLive()
? '● LIVE — identical prompt sent to both real APIs, judged, and scored on the King\'s Table.'
: '◐ EXHIBITION — simulated bout (labeled). Arm both champions in .env for a real, counted battle.';
}
// ── deterministic exhibition scoring (clearly a simulation) ──
function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967295; }
function exhibitionDims(champ) {
const base = 55 + hash(champ.id) * 30; // per-champion baseline 55–85
const d = {};
for (const k of DIMS) {
const v = clamp(base + (hash(champ.id + k + mode.id) - 0.5) * 34, 8, 99);
d[k] = Math.round(k === 'hallucination_risk' ? 100 - v * 0.55 : v);
}
return d;
}
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
// composite 0..1 from judge dims using the mode weights
function composite(dims) {
let num = 0, den = 0;
for (const k of DIMS) {
const w = mode.w[k];
const score = k === 'hallucination_risk' ? (100 - (dims[k] ?? 50)) : (dims[k] ?? 50);
num += w * score; den += w * 100;
}
return clamp(num / den, 0, 1);
}
function speedScore(total, other) {
if (total == null || other == null) return 0.5;
const max = Math.max(total, other, 1);
return clamp(1 - total / (max * 1.4), 0, 1);
}
// ── run a battle ───────────────────────────────────────
let battling = false;
async function begin() {
if (battling) return;
const a = champById($('#selA').value), b = champById($('#selB').value);
if (a.id === b.id) { toast('Choose two different champions'); return; }
battling = true;
window.SFX && (SFX.init(), SFX.horn()); // unlock audio on this user gesture
const prompt = $('#prompt').value.trim() || mode.prompt;
const btn = $('#beginBtn'); btn.disabled = true; btn.textContent = 'Battling…';
$('#results').style.display = 'none';
const live = bothLive();
// scoreMode: 'judge' (real AI judge) · 'sim' (exhibition simulation) · 'none'
// (real responses but no judge available → decided on measured speed only).
let A, B, dimsA = null, dimsB = null, exhibition = !live, scoreMode = 'sim';
try {
if (live) {
const res = await fetch('/api/battle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, a: a.id, b: b.id }) }).then((x) => x.json());
if (!res.a.ok || !res.b.ok) {
toast('A champion failed to answer — running exhibition. ' + (res.a.error || res.b.error || ''));
exhibition = true;
} else {
A = res.a; B = res.b; exhibition = false;
const jr = await fetch('/api/judge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, aText: A.text, bText: B.text, aName: a.name, bName: b.name }) }).then((x) => x.json());
if (jr.scored) { dimsA = jr.scored.a; dimsB = jr.scored.b; scoreMode = 'judge'; }
else { toast('Judge unavailable — winner decided on measured speed only'); scoreMode = 'none'; dimsA = dimsB = null; }
}
}
if (exhibition) {
// Simulated bout — clearly labeled, never presented as real model output,
// and (below) never allowed to move the persistent King's Table.
scoreMode = 'sim';
dimsA = exhibitionDims(a); dimsB = exhibitionDims(b);
A = { ok: true, text: `⟪ EXHIBITION — SIMULATED ⟫ ${a.name} has no live API key, so this bout is a deterministic simulation, NOT a real model response. Add ${a.name}'s key to .env to see genuine output and a judged, counted battle.`, ttft: 300 + hash(a.id) * 500, total: 900 + hash(a.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(a.id) * 120), model: 'simulated', cost: 0, simulated: true };
B = { ok: true, text: `⟪ EXHIBITION — SIMULATED ⟫ ${b.name} has no live API key, so this bout is a deterministic simulation, NOT a real model response. Add ${b.name}'s key to .env to see genuine output and a judged, counted battle.`, ttft: 300 + hash(b.id) * 500, total: 900 + hash(b.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(b.id) * 120), model: 'simulated', cost: 0, simulated: true };
}
// Power = AI-judge/simulated composite (when we have dims) folded with the
// MEASURED speed (objective). When there is no judge, the outcome is decided
// by objective speed ALONE — no fabricated score ever moves the ladder.
const sA = speedScore(A.total, B.total), sB = speedScore(B.total, A.total);
const cA = dimsA ? composite(dimsA) : null, cB = dimsB ? composite(dimsB) : null;
const pA = cA != null ? 0.82 * cA + 0.18 * sA : sA;
const pB = cB != null ? 0.82 * cB + 0.18 * sB : sB;
const eps = 0.015;
const outcome = Math.abs(pA - pB) < eps ? 0.5 : (pA > pB ? 1 : 0);
// animate (drive the fight by composite when present, else by speed)
await Stage.play(mode.id, {
aColor: a.color, bColor: b.color, aName: a.name, bName: b.name,
aPower: cA != null ? cA : sA, bPower: cB != null ? cB : sB, outcome, stages: 4,
});
last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, scoreMode, prompt };
if (window.SFX) (outcome === 0.5 ? SFX.draw() : SFX.fanfare());
renderResults();
// Only LIVE, measured battles move the persistent King's Table.
if (!exhibition) {
const r = await fetch('/api/result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
aId: a.id, bId: b.id, outcome, category: mode.id,
aStats: { total: A.total, cost: A.cost }, bStats: { total: B.total, cost: B.cost },
}) }).then((x) => x.json());
if (r.deltas) toast(`King's Table updated · ${a.name} ${fmtDelta(r.deltas[a.id])} · ${b.name} ${fmtDelta(r.deltas[b.id])}`);
renderLadderRows(r.rows);
}
} catch (e) {
toast('Battle error: ' + (e.message || e));
} finally {
battling = false;
btn.disabled = false; btn.textContent = 'Begin Battle';
}
}
const fmtDelta = (d) => d == null ? '' : (d >= 0 ? `+${d}` : `${d}`);
// ── result cards ───────────────────────────────────────
function renderResults() {
const { a, b, A, B, dimsA, dimsB, outcome, exhibition, scoreMode } = last;
const tag = exhibition ? ' · EXHIBITION (simulated · not counted on the King\'s Table)'
: scoreMode === 'none' ? ' · decided on measured speed (no AI judge available)' : '';
const wb = $('#winnerBanner');
if (outcome === 0.5) wb.innerHTML = `A Draw of Honour<div class="sub">${a.name} and ${b.name} are evenly matched${tag}</div>`;
else {
const win = outcome === 1 ? a : b;
wb.innerHTML = `<span style="color:${win.color}">${win.crest} ${win.name} is Victorious!</span><div class="sub">${win.title} claims the ${mode.name}${tag}</div>`;
}
$('#cardA').innerHTML = card(a, A, dimsA, B, scoreMode);
$('#cardB').innerHTML = card(b, B, dimsB, A, scoreMode);
$('#results').style.display = 'block';
renderPeoples();
$('#evidenceBtn').textContent = '📜 View The Evidence';
$('#results').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// scoreMode: 'judge' real AI-judge · 'sim' exhibition simulation · 'none' measured-only
function card(c, R, dims, other, scoreMode) {
const faster = R.total != null && other.total != null && R.total <= other.total;
const cheaper = R.cost != null && other.cost != null && R.cost <= other.cost;
let scoreBlock = '';
if (scoreMode === 'none') {
scoreBlock = `<div class="aiscore none"><span class="of">⚖ No AI judge configured — winner decided on <b>measured speed</b> only. The six-dimension scores need a judge model (add a key to <code>.env</code>).</span></div>`;
} else {
const aiScore = Math.round(composite(dims) * 100);
const label = scoreMode === 'sim' ? '/ 100 · SIMULATED estimate' : '/ 100 · AI-Judge opinion';
const dimRows = DIMS.map((k) => {
const v = dims[k] ?? 50; const risk = k === 'hallucination_risk';
return `<div class="dim ${risk ? 'risk' : ''}"><span>${DIM_LABEL[k]}</span><span class="bar"><i style="width:${v}%"></i></span><span class="num">${v}</span></div>`;
}).join('');
scoreBlock = `<div class="aiscore ${scoreMode}"><span class="big">${aiScore}</span><span class="of">${label}</span></div>
<div class="dims">${dimRows}</div>
${dims.rationale ? `<div class="rationale">“${dims.rationale}”</div>` : ''}`;
}
const badge = scoreMode === 'sim'
? `<span class="badge sim">SIMULATED</span>`
: `<span class="badge">${R.model || '—'}</span>`;
return `
<div class="resp-head"><div class="n" style="color:${c.color}">${c.crest} ${c.name}</div>${badge}</div>
${scoreBlock}
<div class="objective-note"><b>Measured${scoreMode === 'sim' ? ' (simulated)' : ''}:</b> TTFT ${fmtMs(R.ttft)} · total ${fmtMs(R.total)}${faster ? ' ⚡faster' : ''} · ${R.inTok + R.outTok} tokens · ${fmtCost(R.cost)}${cheaper ? ' 💰cheaper' : ''}</div>
<div class="metrics">
<div class="metric"><div class="k">Time to first token</div><div class="v">${fmtMs(R.ttft)}</div></div>
<div class="metric"><div class="k">Total time</div><div class="v">${fmtMs(R.total)}</div></div>
<div class="metric"><div class="k">Est. API cost</div><div class="v">${fmtCost(R.cost)}</div></div>
</div>
<div class="response-full" data-full>${escapeHtml(R.text || '')}</div>`;
}
function escapeHtml(s) { return (s || '').replace(/[&<>]/g, (m) => ({ '&': '&', '<': '<', '>': '>' }[m])); }
// ── People's Choice (client-side tally, separate from AI score) ──
function peopleKey() { return `pc:${[last.a.id, last.b.id].sort().join('-')}`; }
function renderPeoples() {
const t = JSON.parse(localStorage.getItem(peopleKey()) || '{}');
const na = t[last.a.id] || 0, nb = t[last.b.id] || 0, tot = na + nb;
$('#peoplesChoice').innerHTML = tot
? `👥 People's Choice for this matchup — <b style="color:${last.a.color}">${last.a.name} ${Math.round(na / tot * 100)}%</b> vs <b style="color:${last.b.color}">${last.b.name} ${Math.round(nb / tot * 100)}%</b> (${tot} votes) · kept separate from the AI Score`
: `👥 No public votes yet for this matchup — cast yours above. People's Choice is tallied separately from the AI Score.`;
$('#voteA').textContent = `🗳 Vote ${last.a.name}`;
$('#voteB').textContent = `🗳 Vote ${last.b.name}`;
}
function vote(id) {
if (!last) return;
const k = peopleKey(); const t = JSON.parse(localStorage.getItem(k) || '{}');
t[id] = (t[id] || 0) + 1; localStorage.setItem(k, JSON.stringify(t));
renderPeoples(); toast('Your vote is counted in the People\'s Choice');
}
// ── King's Table ───────────────────────────────────────
async function loadLadder() {
const r = await fetch('/api/leaderboard').then((x) => x.json());
renderLadderRows(r.rows);
}
function renderLadderRows(rows) {
if (!rows) return;
$('#ladderBody').innerHTML = rows.map((r) => `
<tr class="${r.rank === 1 ? 'rank1' : ''}">
<td>${r.rank === 1 ? '<span class="crown">👑</span> ' : ''}${r.rank}</td>
<td><div class="champcell">${champById(r.id)?.img ? `<img class="avatar" src="${champById(r.id).img}" alt="" style="box-shadow:0 0 10px ${r.color}66">` : `<span class="cr" style="filter:drop-shadow(0 0 8px ${r.color})">${r.crest}</span>`}
<span><div class="nm" style="color:${r.color}">${r.name}</div><div class="tt">${r.title}</div></span></div></td>
<td>${r.provider}</td>
<td class="elo">${r.elo}</td>
<td>${r.wins}</td><td>${r.losses}</td><td>${r.draws}</td>
<td>${r.winPct}%</td>
<td class="streak ${r.streak > 0 ? 'pos' : r.streak < 0 ? 'neg' : ''}">${r.streak > 0 ? 'W' + r.streak : r.streak < 0 ? 'L' + (-r.streak) : '—'}</td>
<td>${cap(r.strongest)}</td>
<td>${r.avgRespMs == null ? '—' : r.avgRespMs + 'ms'}</td>
<td>${r.costPerBattle == null ? '—' : fmtCost(r.costPerBattle)}</td>
</tr>`).join('');
}
const cap = (s) => s && s !== '—' ? s[0].toUpperCase() + s.slice(1) : '—';
// ── wire up ────────────────────────────────────────────
$('#selA').onchange = () => renderFighter('A');
$('#selB').onchange = () => renderFighter('B');
$('#beginBtn').onclick = begin;
const muteBtn = $('#muteBtn');
if (muteBtn && window.SFX) {
const paint = () => { const m = SFX.isMuted(); muteBtn.textContent = m ? '🔇 Muted' : '🔊 Sound'; muteBtn.classList.toggle('muted', m); };
paint();
muteBtn.onclick = () => { SFX.init(); SFX.toggleMute(); if (!SFX.isMuted()) SFX.click(); paint(); };
}
$('#voteA').onclick = () => { if (last) vote(last.a.id); };
$('#voteB').onclick = () => { if (last) vote(last.b.id); };
$('#evidenceBtn').onclick = () => {
const shown = $$('#results .response-full').some((e) => e.style.display === 'block');
$$('#results .response-full').forEach((e) => e.style.display = shown ? 'none' : 'block');
$('#evidenceBtn').textContent = shown ? '📜 View The Evidence' : '📜 Hide The Evidence';
};
$$('.tab').forEach((t) => t.onclick = () => {
$$('.tab').forEach((x) => x.classList.remove('active')); t.classList.add('active');
$$('.view').forEach((v) => v.classList.remove('active'));
$(`#view-${t.dataset.view}`).classList.add('active');
if (t.dataset.view === 'table') loadLadder();
});
$('#addBtn').onclick = async () => {
const name = $('#addName').value.trim(); if (!name) { toast('Name required'); return; }
const id = name.toLowerCase().replace(/[^a-z0-9]/g, '') || ('c' + Date.now());
const body = { id, name, title: $('#addTitle').value.trim() || 'The Challenger', crest: $('#addCrest').value.trim() || '⚜', provider: $('#addProvider').value };
const r = await fetch('/api/champions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((x) => x.json());
if (r.ok) { toast(`${name} joins the ladder at ELO 1500`); $('#addName').value = $('#addTitle').value = $('#addCrest').value = ''; loadLadder(); }
};
$('#resetBtn').onclick = async () => {
if (!confirm('Reset the King\'s Table to seed ratings?')) return;
const r = await fetch('/api/leaderboard/reset', { method: 'POST' }).then((x) => x.json());
renderLadderRows(r.rows); toast('Ladder reset to seeds');
};
boot();
})();