← 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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();
})();