← back to Model Wars
MODEL WARS — real API battles, AI judges, medieval ELO ladder
e2b346c42a6706a6b0ff1bd2080c87d5ab112082 · 2026-08-24 08:55:39 -0700 · Steve Abrams
Full rebuild of the Qwen2.5 stub into a working project:
- Express backend with streaming provider adapters (OpenAI/Anthropic/Google/
xAI/DeepSeek/Groq), true time-to-first-token, token+cost accounting, AI judge,
persistent ELO ladder (K=32) with per-category/streak/cost-efficiency stats.
- 2026 liquid-glass UI: embers, cursor torchlight, parallax castle.
- Five cinematic canvas bouts: Joust, Archery, Castle Siege, Duel, Dragon.
- Honest by design: measured metrics vs AI-judge opinions kept distinct;
People's Choice tallied separately; no-key champions run clearly-labeled
Exhibition sims that never touch the persistent ladder.
- Dynamic champion registration; View The Evidence; animated crown on #1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .env.exampleA .gitignoreA README.mdA package-lock.jsonA package.jsonA providers.jsA public/app.jsA public/battles.jsA public/index.htmlA public/scene.jsA public/styles.cssA server.js
Diff
commit e2b346c42a6706a6b0ff1bd2080c87d5ab112082
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 08:55:39 2026 -0700
MODEL WARS — real API battles, AI judges, medieval ELO ladder
Full rebuild of the Qwen2.5 stub into a working project:
- Express backend with streaming provider adapters (OpenAI/Anthropic/Google/
xAI/DeepSeek/Groq), true time-to-first-token, token+cost accounting, AI judge,
persistent ELO ladder (K=32) with per-category/streak/cost-efficiency stats.
- 2026 liquid-glass UI: embers, cursor torchlight, parallax castle.
- Five cinematic canvas bouts: Joust, Archery, Castle Siege, Duel, Dragon.
- Honest by design: measured metrics vs AI-judge opinions kept distinct;
People's Choice tallied separately; no-key champions run clearly-labeled
Exhibition sims that never touch the persistent ladder.
- Dynamic champion registration; View The Evidence; animated crown on #1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.env.example | 35 +++
.gitignore | 11 +
README.md | 86 ++++++
package-lock.json | 829 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 15 +
providers.js | 247 ++++++++++++++++
public/app.js | 312 ++++++++++++++++++++
public/battles.js | 306 ++++++++++++++++++++
public/index.html | 135 +++++++++
public/scene.js | 84 ++++++
public/styles.css | 238 ++++++++++++++++
server.js | 199 +++++++++++++
12 files changed, 2497 insertions(+)
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..78dae01
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,35 @@
+# ── MODEL WARS — API keys ─────────────────────────────────────────────
+# Add a key to enable that champion for LIVE battles. Champions without a key
+# stay selectable in EXHIBITION mode only (clearly labeled simulation — never
+# presented as a real model response).
+#
+# Copy this file to .env and fill in whatever you have. Any subset works.
+
+# OpenAI / GPT — The Emerald Knight
+OPENAI_API_KEY=
+OPENAI_MODEL=gpt-4o-mini
+
+# Anthropic / Claude — The Golden Scholar
+ANTHROPIC_API_KEY=
+ANTHROPIC_MODEL=claude-3-5-sonnet-latest
+
+# Google / Gemini — The Celestial Mage
+GEMINI_API_KEY=
+GEMINI_MODEL=gemini-1.5-flash
+
+# xAI / Grok — The Black Knight
+XAI_API_KEY=
+XAI_MODEL=grok-2-latest
+
+# DeepSeek — The Eastern Strategist
+DEEPSEEK_API_KEY=
+DEEPSEEK_MODEL=deepseek-chat
+
+# Llama (served via Groq) — The Crimson Ranger
+GROQ_API_KEY=
+GROQ_MODEL=llama-3.3-70b-versatile
+
+# Which provider judges the duel (must have a key). Defaults to first available.
+JUDGE_PROVIDER=
+
+PORT=9911
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fb0e9b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/leaderboard.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1b5bad
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# ⚔️ MODEL WARS
+
+**Leading AI models battle head-to-head in a medieval tournament.** A 2026
+liquid-glass interface turns model comparisons into jousts, sieges, duels,
+archery and dragon-slaying — backed by **real API battles**, **AI judges**, and a
+**persistent medieval ELO ladder**.
+
+Each model is a champion:
+
+| Champion | Model | Title |
+|---|---|---|
+| ⚔️ GPT | OpenAI | The Emerald Knight |
+| 📜 Claude | Anthropic | The Golden Scholar |
+| ✦ Gemini | Google | The Celestial Mage |
+| 🗡️ Grok | xAI | The Black Knight |
+| ☯ DeepSeek | DeepSeek | The Eastern Strategist |
+| 🏹 Llama | Groq | The Crimson Ranger |
+
+New champions can be summoned dynamically from **The King's Table**.
+
+## The tournament
+
+Pick two champions and a battle. The **identical prompt** is sent to both models
+simultaneously; the animation is driven by how they actually score.
+
+- **⚔️ Joust** — full-spectrum clash; every category feeds the strike.
+- **🏹 Archery** — precision questions; accuracy places the arrow near the gold.
+- **🏰 Castle Siege** — multi-step reasoning topples gate → walls → towers → keep.
+- **🗡️ Duel** — coding / math / logic; correct reasoning strikes, errors are blocked.
+- **🐉 Dragon Challenge** — one brutal problem; both race to slay the same beast.
+
+## Honesty first — never faked
+
+- The **Live Arena** sends the real prompt to real APIs and shows the **measured**
+ time-to-first-token, total generation time, token usage, and estimated cost.
+- **AI-judge** scores (correctness, reasoning, relevance, completeness,
+ instruction adherence, hallucination risk — 0–100 each) are clearly labeled as
+ **opinions**, kept visually distinct from the **measured** objective metrics.
+- **People's Choice** (human votes) is tallied **separately** from the AI Score.
+- If a champion has **no API key**, it is not faked. It stays selectable in
+ **Exhibition mode** — a clearly-labeled *simulation* that never masquerades as a
+ real model response and **never moves the persistent King's Table**. Only real,
+ measured battles change the ladder.
+- **View The Evidence** exposes the full raw model outputs and the scoring.
+
+## The Medieval ELO system
+
+Every champion carries a persistent rating (standard ELO, K=32). Wins and losses
+move it. **The King's Table** shows rank, model, provider, ELO, W/L/D, win %,
+current streak, strongest category, average response time and cost efficiency —
+with a subtly animated crown on the #1 champion.
+
+## Run it
+
+```bash
+npm install
+cp .env.example .env # add whatever API keys you have (any subset works)
+npm start # → http://localhost:9911
+```
+
+With **zero keys** it runs in Exhibition mode so you can explore every battle,
+animation and the ladder. Add **two or more** keys to fight live, judged battles
+that count on the King's Table.
+
+### Environment
+
+Each provider reads `PROVIDER_API_KEY` (+ optional `PROVIDER_MODEL`) from `.env` —
+see `.env.example`. `JUDGE_PROVIDER` picks which model judges (defaults to the
+first available). Battles stream, so time-to-first-token is genuinely measured.
+
+## Architecture
+
+```
+server.js Express: static host + /api/battle, /api/judge, /api/result,
+ /api/leaderboard, /api/champions. Persists the ELO ladder.
+providers.js Streaming adapters for OpenAI/Anthropic/Google/xAI/DeepSeek/Groq,
+ token+cost accounting, and the AI-judge call. Never fabricates.
+public/ The 2026 liquid-glass UI:
+ index.html structure
+ styles.css glass panels, gold/silver trim, medieval + modern type
+ scene.js embers, cursor torchlight, parallax castle
+ battles.js the five canvas bouts (knights, arrows, castles, swords, dragon)
+ app.js orchestration, scoring, judging, ladder, voting
+```
+
+Built from — and a full rebuild of — a Model Arena challenge starter.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..877bacc
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,829 @@
+{
+ "name": "model-wars",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "model-wars",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "express": "^4.19.2"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e5ec320
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "model-wars",
+ "version": "1.0.0",
+ "description": "MODEL WARS — leading AI models battle head-to-head in a medieval tournament. Real API battles, AI judges, and a persistent medieval ELO ladder.",
+ "type": "module",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node --watch server.js"
+ },
+ "dependencies": {
+ "express": "^4.19.2"
+ },
+ "license": "MIT"
+}
diff --git a/providers.js b/providers.js
new file mode 100644
index 0000000..84f2b78
--- /dev/null
+++ b/providers.js
@@ -0,0 +1,247 @@
+// providers.js — real API adapters for each champion.
+// Every adapter streams so we can measure true time-to-first-token (TTFT).
+// No adapter ever fabricates output: if a key is missing or a call fails,
+// the caller learns the truth and the UI blocks live battle for that champion.
+
+import 'node:process';
+
+const env = process.env;
+
+// Pricing in USD per 1,000,000 tokens: [input, output]. Best-effort public
+// list prices; used only for the "estimated API cost" display.
+const PRICING = {
+ 'gpt-4o-mini': [0.15, 0.60],
+ 'gpt-4o': [2.50, 10.00],
+ 'gpt-4.1-mini': [0.40, 1.60],
+ 'claude-3-5-sonnet-latest': [3.00, 15.00],
+ 'claude-3-5-haiku-latest': [0.80, 4.00],
+ 'gemini-1.5-flash': [0.075, 0.30],
+ 'gemini-2.0-flash': [0.10, 0.40],
+ 'grok-2-latest': [2.00, 10.00],
+ 'grok-beta': [5.00, 15.00],
+ 'deepseek-chat': [0.27, 1.10],
+ 'llama-3.3-70b-versatile': [0.59, 0.79],
+};
+
+function priceFor(model) {
+ return PRICING[model] || [0.5, 1.5]; // conservative fallback
+}
+
+export function costOf(model, inTok, outTok) {
+ const [pin, pout] = priceFor(model);
+ return (inTok / 1e6) * pin + (outTok / 1e6) * pout;
+}
+
+// The canonical champion roster. `key`/`model` resolved from env at runtime.
+export const CHAMPIONS = [
+ { id: 'gpt', name: 'GPT', title: 'The Emerald Knight', provider: 'openai', color: '#2ecc71', crest: '⚔️' },
+ { id: 'claude', name: 'Claude', title: 'The Golden Scholar', provider: 'anthropic',color: '#e8b64c', crest: '📜' },
+ { id: 'gemini', name: 'Gemini', title: 'The Celestial Mage', provider: 'google', color: '#6aa8ff', crest: '✦' },
+ { id: 'grok', name: 'Grok', title: 'The Black Knight', provider: 'xai', color: '#9aa0a6', crest: '🗡️' },
+ { id: 'deepseek', name: 'DeepSeek', title: 'The Eastern Strategist', provider: 'deepseek', color: '#7c5cff', crest: '☯' },
+ { id: 'llama', name: 'Llama', title: 'The Crimson Ranger', provider: 'groq', color: '#e05a5a', crest: '🏹' },
+];
+
+const PROVIDER_ENV = {
+ openai: { key: 'OPENAI_API_KEY', model: 'OPENAI_MODEL', default: 'gpt-4o-mini' },
+ anthropic: { key: 'ANTHROPIC_API_KEY', model: 'ANTHROPIC_MODEL', default: 'claude-3-5-sonnet-latest' },
+ google: { key: 'GEMINI_API_KEY', model: 'GEMINI_MODEL', default: 'gemini-1.5-flash' },
+ xai: { key: 'XAI_API_KEY', model: 'XAI_MODEL', default: 'grok-2-latest' },
+ deepseek: { key: 'DEEPSEEK_API_KEY', model: 'DEEPSEEK_MODEL', default: 'deepseek-chat' },
+ groq: { key: 'GROQ_API_KEY', model: 'GROQ_MODEL', default: 'llama-3.3-70b-versatile' },
+};
+
+export function providerConfig(provider) {
+ const cfg = PROVIDER_ENV[provider];
+ if (!cfg) return null;
+ return {
+ provider,
+ apiKey: env[cfg.key] || '',
+ model: env[cfg.model] || cfg.default,
+ available: !!(env[cfg.key] && env[cfg.key].trim()),
+ };
+}
+
+export function championWithAvailability(champ) {
+ const cfg = providerConfig(champ.provider);
+ return { ...champ, model: cfg ? cfg.model : null, available: cfg ? cfg.available : false };
+}
+
+// Estimate tokens when a provider doesn't report usage. ~4 chars/token.
+const estTok = (s) => Math.max(1, Math.round((s || '').length / 4));
+
+// ── Streaming adapters ────────────────────────────────────────────────
+// Each returns: { text, ttft, total, inTok, outTok, model, cost }
+// ttft/total in ms. Throws on any failure (the caller reports it honestly).
+
+async function readSSE(res, onEvent) {
+ const reader = res.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '';
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buf += dec.decode(value, { stream: true });
+ let idx;
+ while ((idx = buf.indexOf('\n')) >= 0) {
+ const line = buf.slice(0, idx).trim();
+ buf = buf.slice(idx + 1);
+ if (line.startsWith('data:')) onEvent(line.slice(5).trim());
+ }
+ }
+ if (buf.trim().startsWith('data:')) onEvent(buf.trim().slice(5).trim());
+}
+
+async function openAICompatible({ base, apiKey, model, prompt, extraHeaders = {} }) {
+ const t0 = performance.now();
+ const res = await fetch(`${base}/chat/completions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, ...extraHeaders },
+ body: JSON.stringify({
+ model,
+ messages: [{ role: 'user', content: prompt }],
+ stream: true,
+ stream_options: { include_usage: true },
+ temperature: 0.7,
+ }),
+ });
+ if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+ let text = '', ttft = null, inTok = 0, outTok = 0;
+ await readSSE(res, (data) => {
+ if (data === '[DONE]') return;
+ let j; try { j = JSON.parse(data); } catch { return; }
+ const delta = j.choices?.[0]?.delta?.content;
+ if (delta) { if (ttft === null) ttft = performance.now() - t0; text += delta; }
+ if (j.usage) { inTok = j.usage.prompt_tokens || inTok; outTok = j.usage.completion_tokens || outTok; }
+ });
+ const total = performance.now() - t0;
+ if (!inTok) inTok = estTok(prompt);
+ if (!outTok) outTok = estTok(text);
+ return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+async function anthropic({ apiKey, model, prompt }) {
+ const t0 = performance.now();
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-key': apiKey,
+ 'anthropic-version': '2023-06-01',
+ },
+ body: JSON.stringify({ model, max_tokens: 1024, stream: true, messages: [{ role: 'user', content: prompt }] }),
+ });
+ if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+ let text = '', ttft = null, inTok = 0, outTok = 0;
+ await readSSE(res, (data) => {
+ let j; try { j = JSON.parse(data); } catch { return; }
+ if (j.type === 'content_block_delta' && j.delta?.text) {
+ if (ttft === null) ttft = performance.now() - t0;
+ text += j.delta.text;
+ }
+ if (j.type === 'message_start' && j.message?.usage) inTok = j.message.usage.input_tokens || inTok;
+ if (j.usage?.output_tokens) outTok = j.usage.output_tokens;
+ if (j.type === 'message_delta' && j.usage?.output_tokens) outTok = j.usage.output_tokens;
+ });
+ const total = performance.now() - t0;
+ if (!inTok) inTok = estTok(prompt);
+ if (!outTok) outTok = estTok(text);
+ return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+async function google({ apiKey, model, prompt }) {
+ const t0 = performance.now();
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] }),
+ });
+ if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+ let text = '', ttft = null, inTok = 0, outTok = 0;
+ await readSSE(res, (data) => {
+ let j; try { j = JSON.parse(data); } catch { return; }
+ const part = j.candidates?.[0]?.content?.parts?.[0]?.text;
+ if (part) { if (ttft === null) ttft = performance.now() - t0; text += part; }
+ if (j.usageMetadata) {
+ inTok = j.usageMetadata.promptTokenCount || inTok;
+ outTok = j.usageMetadata.candidatesTokenCount || outTok;
+ }
+ });
+ const total = performance.now() - t0;
+ if (!inTok) inTok = estTok(prompt);
+ if (!outTok) outTok = estTok(text);
+ return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+// Dispatch a real streamed call for a champion. Throws if unavailable/failed.
+export async function callChampion(champ, prompt) {
+ const cfg = providerConfig(champ.provider);
+ if (!cfg) throw new Error(`Unknown provider ${champ.provider}`);
+ if (!cfg.available) throw new Error(`${champ.name} has no API key configured`);
+ const { apiKey, model } = cfg;
+ switch (champ.provider) {
+ case 'openai': return openAICompatible({ base: 'https://api.openai.com/v1', apiKey, model, prompt });
+ case 'xai': return openAICompatible({ base: 'https://api.x.ai/v1', apiKey, model, prompt });
+ case 'deepseek': return openAICompatible({ base: 'https://api.deepseek.com', apiKey, model, prompt });
+ case 'groq': return openAICompatible({ base: 'https://api.groq.com/openai/v1', apiKey, model, prompt });
+ case 'anthropic':return anthropic({ apiKey, model, prompt });
+ case 'google': return google({ apiKey, model, prompt });
+ default: throw new Error(`No adapter for ${champ.provider}`);
+ }
+}
+
+// A non-streaming JSON call used by the AI judge. Returns parsed JSON or throws.
+export async function judgeCall(provider, prompt) {
+ const cfg = providerConfig(provider);
+ if (!cfg || !cfg.available) throw new Error(`Judge provider ${provider} unavailable`);
+ const { apiKey, model } = cfg;
+ const sys = 'You are a strict, impartial tournament judge. Respond with ONLY valid minified JSON, no markdown.';
+ if (provider === 'anthropic') {
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
+ body: JSON.stringify({ model, max_tokens: 1024, system: sys, messages: [{ role: 'user', content: prompt }] }),
+ });
+ if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+ const j = await res.json();
+ return extractJSON(j.content?.[0]?.text || '');
+ }
+ if (provider === 'google') {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ systemInstruction: { parts: [{ text: sys }] }, contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json' } }),
+ });
+ if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+ const j = await res.json();
+ return extractJSON(j.candidates?.[0]?.content?.parts?.[0]?.text || '');
+ }
+ // OpenAI-compatible (openai/xai/deepseek/groq)
+ const base = { openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1', deepseek: 'https://api.deepseek.com', groq: 'https://api.groq.com/openai/v1' }[provider];
+ const res = await fetch(`${base}/chat/completions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ model, messages: [{ role: 'system', content: sys }, { role: 'user', content: prompt }], temperature: 0, response_format: { type: 'json_object' } }),
+ });
+ if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+ const j = await res.json();
+ return extractJSON(j.choices?.[0]?.message?.content || '');
+}
+
+function extractJSON(s) {
+ if (!s) throw new Error('empty judge response');
+ const a = s.indexOf('{'), b = s.lastIndexOf('}');
+ if (a < 0 || b < 0) throw new Error('no JSON in judge response');
+ return JSON.parse(s.slice(a, b + 1));
+}
+
+export function judgeProvider() {
+ const forced = env.JUDGE_PROVIDER;
+ if (forced && providerConfig(forced)?.available) return forced;
+ for (const p of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'groq']) {
+ if (providerConfig(p)?.available) return p;
+ }
+ return null;
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..040f4a9
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,312 @@
+// 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() {
+ const r = await fetch('/api/champions').then((x) => x.json());
+ 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);
+ box.querySelector('.crest').textContent = c.crest;
+ box.querySelector('.crest').style.filter = `drop-shadow(0 0 14px ${c.color})`;
+ box.querySelector('.cname').textContent = c.name;
+ box.querySelector('.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;
+ const prompt = $('#prompt').value.trim() || mode.prompt;
+ const btn = $('#beginBtn'); btn.disabled = true; btn.textContent = 'Battling…';
+ $('#results').style.display = 'none';
+ const live = bothLive();
+
+ let A, B, dimsA, dimsB, exhibition = !live;
+ 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;
+ 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; }
+ else { toast('Judge unavailable — showing measured metrics only'); dimsA = exhibitionDims(a); dimsB = exhibitionDims(b); }
+ }
+ }
+ if (exhibition) {
+ dimsA = exhibitionDims(a); dimsB = exhibitionDims(b);
+ A = { ok: true, text: `⟪ EXHIBITION ⟫ ${a.name} has no live API key, so this is a simulated bout — not a real model response. Arm ${a.name} in .env to see genuine output.`, ttft: 300 + hash(a.id) * 500, total: 900 + hash(a.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(a.id) * 120), model: a.model || 'simulated', cost: 0 };
+ B = { ok: true, text: `⟪ EXHIBITION ⟫ ${b.name} has no live API key, so this is a simulated bout — not a real model response. Arm ${b.name} in .env to see genuine output.`, ttft: 300 + hash(b.id) * 500, total: 900 + hash(b.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(b.id) * 120), model: b.model || 'simulated', cost: 0 };
+ }
+
+ // powers fold AI-judge composite (opinion) with measured speed (objective)
+ const cA = composite(dimsA), cB = composite(dimsB);
+ const sA = speedScore(A.total, B.total), sB = speedScore(B.total, A.total);
+ const pA = 0.82 * cA + 0.18 * sA, pB = 0.82 * cB + 0.18 * sB;
+ const eps = 0.015;
+ const outcome = Math.abs(pA - pB) < eps ? 0.5 : (pA > pB ? 1 : 0);
+
+ // animate
+ await Stage.play(mode.id, {
+ aColor: a.color, bColor: b.color, aName: a.name, bName: b.name,
+ aPower: cA, bPower: cB, outcome, stages: 4,
+ });
+
+ last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, prompt };
+ 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, pA, pB, outcome, exhibition } = last;
+ const wb = $('#winnerBanner');
+ if (outcome === 0.5) wb.innerHTML = `A Draw of Honour<div class="sub">${a.name} and ${b.name} are evenly matched${exhibition ? ' · EXHIBITION (not counted)' : ''}</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} — ${outcome === 1 ? a.name : b.name} claims the ${mode.name}${exhibition ? ' · EXHIBITION (not counted on the King\'s Table)' : ''}</div>`;
+ }
+ $('#cardA').innerHTML = card(a, A, dimsA, pA, B);
+ $('#cardB').innerHTML = card(b, B, dimsB, pB, A);
+ $('#results').style.display = 'block';
+ renderPeoples();
+ $('#evidenceBtn').textContent = '📜 View The Evidence';
+ $('#results').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+
+ function card(c, R, dims, power, other) {
+ const aiScore = Math.round(composite(dims) * 100);
+ const faster = R.total != null && other.total != null && R.total <= other.total;
+ const cheaper = R.cost != null && other.cost != null && R.cost <= other.cost;
+ 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('');
+ return `
+ <div class="resp-head"><div class="n" style="color:${c.color}">${c.crest} ${c.name}</div>
+ <span class="badge">${R.model || '—'}</span></div>
+ <div class="aiscore"><span class="big">${aiScore}</span><span class="of">/ 100 AI-Judge composite</span></div>
+ <div class="dims">${dimRows}</div>
+ <div class="objective-note"><b>Measured:</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>
+ ${dims.rationale ? `<div class="rationale">“${dims.rationale}”</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} ${tot ? Math.round(na / tot * 100) : 0}%</b> vs <b style="color:${last.b.color}">${last.b.name} ${tot ? Math.round(nb / tot * 100) : 0}%</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"><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;
+ $('#voteA').onclick = () => vote(last.a.id);
+ $('#voteB').onclick = () => 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();
+})();
diff --git a/public/battles.js b/public/battles.js
new file mode 100644
index 0000000..17d2d81
--- /dev/null
+++ b/public/battles.js
@@ -0,0 +1,306 @@
+// battles.js — cinematic canvas bouts for each tournament mode.
+// Stage.play(mode, cfg) animates the fight and resolves when finished.
+// cfg: { aColor, bColor, aName, bName, aPower 0..1, bPower 0..1, outcome 1|0|0.5, stages }
+window.Stage = (function () {
+ 'use strict';
+ const canvas = document.getElementById('stage');
+ const ctx = canvas.getContext('2d');
+ let W = 0, H = 0, DPR = 1;
+
+ function fit() {
+ DPR = Math.min(2, window.devicePixelRatio || 1);
+ const r = canvas.getBoundingClientRect();
+ W = r.width; H = 340;
+ canvas.width = W * DPR; canvas.height = H * DPR;
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+ window.addEventListener('resize', fit);
+
+ const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ const lerp = (a, b, t) => a + (b - a) * t;
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+
+ function bg() {
+ const g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, '#0b0c16'); g.addColorStop(1, '#05050b');
+ ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
+ // ground
+ ctx.fillStyle = 'rgba(255,255,255,.03)'; ctx.fillRect(0, H - 60, W, 60);
+ ctx.strokeStyle = 'rgba(232,197,106,.12)'; ctx.beginPath();
+ ctx.moveTo(0, H - 60); ctx.lineTo(W, H - 60); ctx.stroke();
+ }
+
+ // particles pool (sparks / embers / smoke)
+ let parts = [];
+ function burst(x, y, color, n, spread, kind) {
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * Math.PI * 2, s = Math.random() * spread;
+ parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - (kind === 'smoke' ? 1 : 0),
+ life: 1, color, r: kind === 'smoke' ? 6 + Math.random() * 8 : 1 + Math.random() * 2, kind });
+ }
+ }
+ function drawParts() {
+ for (const p of parts) {
+ p.x += p.vx; p.y += p.vy; p.vy += p.kind === 'spark' ? 0.12 : 0.02; p.life -= 0.02;
+ if (p.kind === 'smoke') { p.r += 0.3; }
+ ctx.globalAlpha = clamp(p.life, 0, 1) * (p.kind === 'smoke' ? 0.4 : 1);
+ ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ parts = parts.filter((p) => p.life > 0);
+ }
+
+ function banner(txt) { document.getElementById('stageBanner').textContent = txt; }
+
+ // ── figure primitives ────────────────────────────────
+ function knight(x, y, color, facing, lance, lean) {
+ ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1); ctx.rotate(lean || 0);
+ // horse
+ ctx.fillStyle = '#1a1a22';
+ ctx.beginPath(); ctx.ellipse(0, 34, 46, 18, 0, 0, 7); ctx.fill();
+ ctx.fillRect(30, 8, 12, 30); ctx.fillRect(-38, 10, 10, 28); ctx.fillRect(-20, 12, 10, 26); ctx.fillRect(14, 12, 10, 26);
+ ctx.beginPath(); ctx.moveTo(40, 20); ctx.lineTo(58, 2); ctx.lineTo(60, 16); ctx.lineTo(46, 26); ctx.fill();
+ // barding (banner cloth) in champion color
+ ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-26, 24, 40, 20); ctx.globalAlpha = 1;
+ // rider armour (metallic)
+ const mg = ctx.createLinearGradient(-8, -30, 12, 4);
+ mg.addColorStop(0, '#eef1f6'); mg.addColorStop(.5, '#aab0bd'); mg.addColorStop(1, '#5a5f6b');
+ ctx.fillStyle = mg;
+ ctx.beginPath(); ctx.roundRect(-8, -18, 20, 30, 6); ctx.fill(); // torso
+ ctx.beginPath(); ctx.arc(2, -26, 9, 0, 7); ctx.fill(); // helm
+ ctx.fillStyle = color; ctx.fillRect(-2, -34, 4, 8); // plume
+ // lance
+ if (lance) {
+ ctx.strokeStyle = '#d8b26a'; ctx.lineWidth = 4; ctx.beginPath();
+ ctx.moveTo(8, -8); ctx.lineTo(90, -14); ctx.stroke();
+ ctx.fillStyle = '#e8e8ee'; ctx.beginPath(); ctx.moveTo(90, -14); ctx.lineTo(102, -16); ctx.lineTo(90, -10); ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ function castle(x, y, color, damage) { // damage 0..1
+ ctx.save(); ctx.translate(x, y);
+ const stone = '#2b2b36';
+ const parts = [ // [w,h, threshold] gate/walls/towers/keep collapse as damage rises
+ { x: -50, y: -40, w: 100, h: 40, th: 0.0 }, // wall base
+ { x: -60, y: -80, w: 24, h: 44, th: 0.75 }, // left tower
+ { x: 36, y: -80, w: 24, h: 44, th: 0.75 }, // right tower
+ { x: -14, y: -70, w: 28, h: 30, th: 0.25 }, // gate
+ { x: -18, y: -110, w: 36, h: 40, th: 0.9 }, // keep
+ ];
+ for (const p of parts) {
+ if (damage >= p.th && p.th > 0) { // rubble
+ ctx.fillStyle = '#20202a';
+ for (let i = 0; i < 5; i++) ctx.fillRect(p.x + i * (p.w / 5), -2 - Math.random() * 4, p.w / 5 - 2, 4 + Math.random() * 4);
+ continue;
+ }
+ ctx.fillStyle = stone; ctx.fillRect(p.x, p.y, p.w, p.h);
+ ctx.fillStyle = 'rgba(0,0,0,.3)';
+ for (let gx = p.x; gx < p.x + p.w; gx += 12) ctx.fillRect(gx, p.y, 1, p.h);
+ }
+ // banner
+ ctx.fillStyle = color; ctx.fillRect(-2, -150, 4, 40);
+ ctx.beginPath(); ctx.moveTo(2, -150); ctx.lineTo(26, -144); ctx.lineTo(2, -134); ctx.fill();
+ ctx.restore();
+ }
+
+ function dragon(x, y, hp) {
+ ctx.save(); ctx.translate(x, y);
+ const wing = Math.sin(performance.now() * 0.006) * 0.3;
+ ctx.fillStyle = hp > 0 ? '#3a1f4a' : '#241528';
+ // wings
+ ctx.save(); ctx.rotate(-0.4 + wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-70, -40); ctx.lineTo(-50, 20); ctx.fill(); ctx.restore();
+ ctx.save(); ctx.rotate(0.4 - wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(70, -40); ctx.lineTo(50, 20); ctx.fill(); ctx.restore();
+ // body
+ const bg = ctx.createLinearGradient(0, -20, 0, 40); bg.addColorStop(0, '#6b3fa0'); bg.addColorStop(1, '#2a1636');
+ ctx.fillStyle = bg; ctx.beginPath(); ctx.ellipse(0, 6, 30, 40, 0, 0, 7); ctx.fill();
+ // head
+ ctx.beginPath(); ctx.ellipse(0, -34, 18, 15, 0, 0, 7); ctx.fill();
+ // eyes
+ ctx.fillStyle = hp > 0 ? '#ff5a2a' : '#555'; ctx.beginPath(); ctx.arc(-7, -36, 3, 0, 7); ctx.arc(7, -36, 3, 0, 7); ctx.fill();
+ ctx.restore();
+ }
+
+ function healthBars(cfg, aFrac, bFrac) {
+ const pad = 24, bw = W * 0.28, bh = 12;
+ function bar(x, frac, color, name, right) {
+ ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x, 20, bw, bh);
+ ctx.fillStyle = color; const w = bw * clamp(frac, 0, 1);
+ ctx.fillRect(right ? x + bw - w : x, 20, w, bh);
+ ctx.strokeStyle = 'rgba(232,197,106,.4)'; ctx.strokeRect(x, 20, bw, bh);
+ ctx.fillStyle = '#f3ecdc'; ctx.font = '700 12px Cinzel, serif';
+ ctx.textAlign = right ? 'right' : 'left';
+ ctx.fillText(name, right ? x + bw : x, 16);
+ }
+ bar(pad, aFrac, cfg.aColor, cfg.aName, false);
+ bar(W - pad - bw, bFrac, cfg.bColor, cfg.bName, true);
+ }
+
+ // ── animation runner ─────────────────────────────────
+ // A single active run at a time; cancelling one always resolves its promise
+ // so awaiters never hang, and a frame error resolves rather than stalls.
+ let rafId = null, pendingResolve = null;
+ function run(duration, frame) {
+ if (rafId) { cancelAnimationFrame(rafId); if (pendingResolve) pendingResolve(); }
+ return new Promise((resolve) => {
+ pendingResolve = resolve;
+ const t0 = performance.now();
+ (function loop(now) {
+ const t = clamp((now - t0) / duration, 0, 1);
+ try { bg(); frame(t); drawParts(); }
+ catch (err) { console.error('battle frame error', err); rafId = null; pendingResolve = null; resolve(); return; }
+ if (t < 1) rafId = requestAnimationFrame(loop);
+ else { rafId = null; pendingResolve = null; resolve(); }
+ })(t0);
+ });
+ }
+
+ // ── modes ────────────────────────────────────────────
+ const MODES = {
+ async joust(cfg) {
+ banner('⚔ THE JOUST — Champions charge!');
+ const midY = H - 96;
+ const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
+ await run(2600, (t) => {
+ const meet = 0.62;
+ let ax = t < meet ? lerp(70, W / 2 - 60, t / meet) : W / 2 - 60;
+ let bx = t < meet ? lerp(W - 70, W / 2 + 60, t / meet) : W / 2 + 60;
+ // impact
+ let aLean = 0, bLean = 0, ay = midY, by = midY;
+ if (t >= meet) {
+ const k = (t - meet) / (1 - meet);
+ if (draw) { aLean = -0.2 * k; bLean = 0.2 * k; }
+ else if (aWins) { bLean = 0.9 * k; by = midY - 30 * Math.sin(k * 3) + 60 * k * k; bx += 40 * k; }
+ else { aLean = -0.9 * k; ay = midY - 30 * Math.sin(k * 3) + 60 * k * k; ax -= 40 * k; }
+ if (Math.abs(t - meet) < 0.03) burst(W / 2, midY - 14, '#ffd27a', 40, 6, 'spark');
+ }
+ knight(ax, ay, cfg.aColor, 1, true, aLean);
+ knight(bx, by, cfg.bColor, -1, true, bLean);
+ });
+ banner(draw ? '⚔ A draw of honour!' : `⚔ ${aWins ? cfg.aName : cfg.bName} unhorses the foe!`);
+ },
+
+ async archery(cfg) {
+ banner('🏹 ARCHERY — Loose your arrows!');
+ const cxT = W - 120, cyT = H / 2;
+ const aErr = (1 - cfg.aPower), bErr = (1 - cfg.bPower);
+ const arrows = [];
+ await run(2600, (t) => {
+ // target
+ const rings = ['#e6e6ea', '#2b2b36', '#6aa8ff', '#e05a5a', '#e8c56a'];
+ rings.forEach((c, i) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(cxT, cyT, 70 - i * 14, 0, 7); ctx.fill(); });
+ // archers
+ ctx.fillStyle = cfg.aColor; ctx.fillRect(50, cyT - 40, 8, 80);
+ ctx.fillStyle = cfg.bColor; ctx.fillRect(50, cyT + 50, 8, 60);
+ // volley: 3 arrows each, staggered
+ for (let i = 0; i < 3; i++) {
+ const dl = i * 0.12;
+ for (const [who, err, col, yoff] of [['a', aErr, cfg.aColor, -1], ['b', bErr, cfg.bColor, 1]]) {
+ const lt = clamp((t - dl) / 0.7, 0, 1);
+ if (lt <= 0) continue;
+ const spread = err * 46 + 4;
+ const ty = cyT + yoff * (8 + i * 6) + (Math.sin(i * 3 + (who === 'a' ? 0 : 2)) * spread);
+ const x = lerp(60, cxT - 4, ease(lt));
+ const y = lerp(cyT + yoff * 20, ty, ease(lt));
+ ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath();
+ ctx.moveTo(x - 16, y); ctx.lineTo(x, y); ctx.stroke();
+ if (lt >= 1) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 3, 0, 7); ctx.fill(); }
+ }
+ }
+ });
+ const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+ banner(dr ? '🏹 Even marksmanship!' : `🏹 ${aw ? cfg.aName : cfg.bName} strikes nearer the gold!`);
+ },
+
+ async siege(cfg) {
+ banner('🏰 CASTLE SIEGE — Reasoning topples stone!');
+ const stages = cfg.stages || 4;
+ const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
+ // damage each castle takes ∝ opponent power
+ const aDmg = draw ? 0.5 : (aWins ? 0.2 : 0.95);
+ const bDmg = draw ? 0.5 : (aWins ? 0.95 : 0.2);
+ await run(3200, (t) => {
+ const step = Math.floor(t * stages);
+ const aD = clamp((step / stages) * bDmg * (cfg.bPower + .4), 0, 1);
+ const bD = clamp((step / stages) * aDmg * (cfg.aPower + .4), 0, 1);
+ castle(120, H - 40, cfg.aColor, aD);
+ castle(W - 120, H - 40, cfg.bColor, bD);
+ // trebuchet projectiles arcing between, flaming
+ const p = (t * 3) % 1;
+ const dir = Math.floor(t * 3) % 2 === 0; // a->b then b->a
+ const sx = dir ? 160 : W - 160, ex2 = dir ? W - 160 : 160;
+ const x = lerp(sx, ex2, p), y = (H - 90) - Math.sin(p * Math.PI) * 150;
+ ctx.fillStyle = '#ff7a2a'; ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill();
+ burst(x, y, '#ff9a3a', 2, 1.5, 'smoke');
+ if (p > 0.96) burst(ex2, H - 90, '#ffb347', 20, 5, 'spark');
+ });
+ banner(draw ? '🏰 Both keeps stand scarred!' : `🏰 ${aWins ? cfg.aName : cfg.bName} breaches the keep!`);
+ },
+
+ async duel(cfg) {
+ banner('⚔ THE DUEL — Steel meets logic!');
+ let aHP = 1, bHP = 1;
+ const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+ await run(3000, (t) => {
+ const beat = Math.floor(t * 8);
+ // each beat, stronger side lands the blow
+ aHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.25 : 0.9) * t), 0, 1);
+ bHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.9 : 0.25) * t), 0, 1);
+ healthBars(cfg, aHP, bHP);
+ const clash = (t * 8) % 1;
+ const ax = lerp(W / 2 - 90, W / 2 - 30, Math.sin(clash * Math.PI));
+ const bx = lerp(W / 2 + 90, W / 2 + 30, Math.sin(clash * Math.PI));
+ swordsman(ax, H - 80, cfg.aColor, 1);
+ swordsman(bx, H - 80, cfg.bColor, -1);
+ if (clash > 0.45 && clash < 0.55) burst(W / 2, H - 96, '#fff2c0', 10, 4, 'spark');
+ });
+ banner(dr ? '⚔ Blades locked — a draw!' : `⚔ ${aw ? cfg.aName : cfg.bName} lands the final blow!`);
+ },
+
+ async dragon(cfg) {
+ banner('🐉 DRAGON CHALLENGE — Race to slay the beast!');
+ let dhpA = 1, dhpB = 1; // each champion's progress against the SAME dragon
+ const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+ await run(3200, (t) => {
+ dhpA = clamp(1 - (aw || dr ? cfg.aPower : cfg.aPower * 0.7) * t * (aw ? 1.1 : 0.9), 0, 1);
+ dhpB = clamp(1 - (!aw || dr ? cfg.bPower : cfg.bPower * 0.7) * t * (aw ? 0.9 : 1.1), 0, 1);
+ // shared dragon health = min progress remaining shown center; two race bars
+ dragon(W / 2, H / 2 - 6, Math.min(dhpA, dhpB));
+ healthBars({ ...cfg, aName: cfg.aName + ' ⚔', bName: cfg.bName + ' ⚔' }, dhpA, dhpB);
+ // magic bolts toward dragon
+ const p = (t * 4) % 1;
+ const ax = lerp(70, W / 2 - 34, ease(p)), bx = lerp(W - 70, W / 2 + 34, ease(p));
+ ctx.fillStyle = cfg.aColor; ctx.beginPath(); ctx.arc(ax, H / 2 + 30, 4, 0, 7); ctx.fill();
+ ctx.fillStyle = cfg.bColor; ctx.beginPath(); ctx.arc(bx, H / 2 + 30, 4, 0, 7); ctx.fill();
+ if (p > 0.95) burst(W / 2, H / 2, '#c58bff', 12, 4, 'spark');
+ });
+ const winner = dhpA < dhpB ? cfg.aName : cfg.bName;
+ banner(dr ? '🐉 Both fell the dragon as one!' : `🐉 ${aw ? cfg.aName : cfg.bName} slays the dragon first!`);
+ },
+ };
+
+ function swordsman(x, y, color, facing) {
+ ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1);
+ const mg = ctx.createLinearGradient(-6, -40, 8, 0);
+ mg.addColorStop(0, '#eef1f6'); mg.addColorStop(1, '#5a5f6b');
+ ctx.fillStyle = mg; ctx.beginPath(); ctx.roundRect(-7, -36, 16, 34, 5); ctx.fill();
+ ctx.beginPath(); ctx.arc(1, -44, 8, 0, 7); ctx.fill();
+ ctx.fillStyle = color; ctx.fillRect(-3, -52, 4, 8);
+ ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-9, -20, 6, 24); ctx.globalAlpha = 1; // shield-side cloak
+ ctx.strokeStyle = '#dfe3ea'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(6, -30); ctx.lineTo(34, -46); ctx.stroke();
+ ctx.restore();
+ }
+
+ fit();
+ // idle attract animation
+ (function idle() { bg(); banner('Choose your champions and the battle'); })();
+
+ return {
+ fit,
+ async play(mode, cfg) {
+ fit(); parts = [];
+ const fn = MODES[mode] || MODES.joust;
+ await fn(cfg);
+ },
+ };
+})();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..d24ce7c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>MODEL WARS — The AI Tournament</title>
+<meta name="description" content="Leading AI models battle head-to-head in a medieval tournament. Real API battles, AI judges, and a persistent medieval ELO ladder." />
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%E2%9A%94%EF%B8%8F%3C/text%3E%3C/svg%3E" />
+<link rel="preconnect" href="https://fonts.googleapis.com" />
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+<link rel="stylesheet" href="styles.css" />
+</head>
+<body>
+<canvas id="embers"></canvas>
+<canvas id="torch"></canvas>
+<div class="parallax" id="parallax"></div>
+<div class="vignette"></div>
+
+<div class="wrap">
+ <header class="hero">
+ <div class="crest-line">Anno Domini MMXXVI · The Grand Tournament</div>
+ <h1 class="wordmark">MODEL WARS</h1>
+ <div class="subtitle">Where Champions of Silicon Clash for Glory</div>
+ <div id="modeFlag" class="mode-flag mode-exh"><span class="dot"></span><span id="modeText">Checking champions…</span></div>
+ </header>
+
+ <nav class="tabs">
+ <button class="tab active" data-view="arena">⚔ The Arena</button>
+ <button class="tab" data-view="table">👑 The King's Table</button>
+ </nav>
+
+ <!-- ══════════ ARENA ══════════ -->
+ <section class="view active" id="view-arena">
+ <div class="glass" style="padding:20px">
+ <div class="panel-title">Choose Your Champions</div>
+ <div class="combatants">
+ <div class="fighter side-a glass" id="fighterA">
+ <div class="crest">⚔️</div>
+ <div class="cname">—</div>
+ <div class="ctitle">—</div>
+ <select id="selA"></select>
+ <div class="avail off"><span class="d"></span><span class="txt">—</span></div>
+ </div>
+ <div class="vs-emblem">⚜<br />VS</div>
+ <div class="fighter side-b glass" id="fighterB">
+ <div class="crest">🗡️</div>
+ <div class="cname">—</div>
+ <div class="ctitle">—</div>
+ <select id="selB"></select>
+ <div class="avail off"><span class="d"></span><span class="txt">—</span></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="glass" style="padding:20px; margin-top:16px">
+ <div class="panel-title">Choose The Battle</div>
+ <div class="modes" id="modes"></div>
+ </div>
+
+ <div class="glass prompt-row">
+ <label id="promptLabel">The Challenge Scroll (identical prompt sent to both champions)</label>
+ <textarea id="prompt"></textarea>
+ <div class="actions">
+ <button class="btn" id="beginBtn">Begin Battle</button>
+ <span class="hint" id="battleHint"></span>
+ </div>
+ </div>
+
+ <div class="glass stage-wrap" style="margin-top:18px; position:relative">
+ <div class="stage-banner" id="stageBanner"></div>
+ <canvas id="stage"></canvas>
+ </div>
+
+ <div class="results" id="results">
+ <div class="winner-banner glass" id="winnerBanner"></div>
+ <div class="score-grid">
+ <div class="resp-card glass" id="cardA"></div>
+ <div class="resp-card glass" id="cardB"></div>
+ </div>
+ <div class="vote-row">
+ <span class="hint">The People's Choice — who won in your eyes?</span>
+ <button class="btn ghost" id="voteA">🗳 Vote Champion A</button>
+ <button class="btn ghost" id="voteB">🗳 Vote Champion B</button>
+ <button class="btn ghost" id="evidenceBtn">📜 View The Evidence</button>
+ </div>
+ <div class="people" id="peoplesChoice"></div>
+ </div>
+ </section>
+
+ <!-- ══════════ KING'S TABLE ══════════ -->
+ <section class="view" id="view-table">
+ <div class="glass kings-table">
+ <div class="panel-title">👑 The King's Table — Global Tournament Ladder</div>
+ <table class="ladder" id="ladder">
+ <thead><tr>
+ <th>Rank</th><th>Champion</th><th>Provider</th><th>ELO</th>
+ <th>W</th><th>L</th><th>D</th><th>Win %</th><th>Streak</th>
+ <th>Strongest</th><th>Avg ms</th><th>Cost/Battle</th>
+ </tr></thead>
+ <tbody id="ladderBody"></tbody>
+ </table>
+
+ <div class="panel-title" style="margin-top:26px">Summon A New Champion</div>
+ <div class="addrow">
+ <input id="addName" placeholder="Name (e.g. Mistral)" />
+ <input id="addTitle" placeholder="Title (e.g. The Frankish Duelist)" />
+ <input id="addCrest" placeholder="Crest emoji (⚜)" style="width:120px" />
+ <select id="addProvider">
+ <option value="openai">OpenAI-compatible</option>
+ <option value="anthropic">Anthropic</option>
+ <option value="google">Google</option>
+ <option value="xai">xAI</option>
+ <option value="deepseek">DeepSeek</option>
+ <option value="groq">Groq / Llama</option>
+ </select>
+ <button class="btn ghost" id="addBtn">Add To Ladder</button>
+ <button class="btn ghost" id="resetBtn" style="margin-left:auto">Reset Ladder</button>
+ </div>
+ <div class="hint" style="margin-top:8px">New champions enter at ELO 1500. Wire their API key in <code>.env</code> to battle live.</div>
+ </div>
+ </section>
+
+ <footer>
+ <div><b>MODEL WARS</b> — objective measurements (speed, tokens, cost) are <b>measured</b>; the six-dimension scores are <b>AI-judge opinions</b>. People's Choice is tallied separately.</div>
+ <div id="footMode"></div>
+ </footer>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script src="scene.js"></script>
+<script src="battles.js"></script>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/public/scene.js b/public/scene.js
new file mode 100644
index 0000000..ada5405
--- /dev/null
+++ b/public/scene.js
@@ -0,0 +1,84 @@
+// scene.js — ambient cinematic layers: embers, cursor torchlight, parallax castle.
+(function () {
+ 'use strict';
+
+ // ── Parallax castle silhouette (inline SVG data-uri, no asset needed) ──
+ const castle = encodeURIComponent(`
+ <svg xmlns='http://www.w3.org/2000/svg' width='1600' height='520' viewBox='0 0 1600 520'>
+ <defs><linearGradient id='g' x1='0' y1='0' x2='0' y2='1'>
+ <stop offset='0' stop-color='#0a0a12'/><stop offset='1' stop-color='#05050a'/></linearGradient></defs>
+ <g fill='url(#g)'>
+ <rect x='0' y='360' width='1600' height='160'/>
+ <path d='M120 360 V220 h40 v-30 h30 v30 h40 v-30 h30 v30 h40 V360 Z'/>
+ <path d='M1300 360 V200 h50 v-34 h34 v34 h50 v-34 h34 v34 h50 V360 Z'/>
+ <path d='M640 360 V150 l60 -60 l60 60 V360 Z'/>
+ <path d='M760 360 V180 h80 v-40 h30 v40 h80 V360 Z'/>
+ <path d='M300 360 V260 h60 v-20 h20 v20 h60 V360 Z'/>
+ <path d='M980 360 V240 h70 v-26 h22 v26 h70 V360 Z'/>
+ <path d='M540 360 V300 h40 v-16 h16 v16 h40 V360 Z'/>
+ </g>
+ </svg>`);
+ const px = document.getElementById('parallax');
+ if (px) px.style.backgroundImage = `url("data:image/svg+xml,${castle}")`;
+ window.addEventListener('scroll', () => {
+ if (px) px.style.transform = `translateY(${window.scrollY * 0.15}px)`;
+ }, { passive: true });
+
+ // ── Embers ─────────────────────────────────────────────
+ const ec = document.getElementById('embers');
+ const ex = ec.getContext('2d');
+ let W, H, embers = [];
+ function resize() {
+ W = ec.width = window.innerWidth; H = ec.height = window.innerHeight;
+ const t = document.getElementById('torch');
+ t.width = W; t.height = H;
+ }
+ window.addEventListener('resize', resize); resize();
+
+ function spawn() {
+ return {
+ x: Math.random() * W, y: H + Math.random() * 40,
+ r: Math.random() * 2.2 + 0.6, vy: Math.random() * 0.9 + 0.35,
+ vx: (Math.random() - 0.5) * 0.4, life: Math.random() * 0.7 + 0.3,
+ hue: 18 + Math.random() * 22,
+ };
+ }
+ for (let i = 0; i < 90; i++) { const e = spawn(); e.y = Math.random() * H; embers.push(e); }
+
+ function drawEmbers() {
+ ex.clearRect(0, 0, W, H);
+ for (const e of embers) {
+ e.y -= e.vy; e.x += e.vx + Math.sin(e.y * 0.01) * 0.25; e.life -= 0.0016;
+ if (e.y < -10 || e.life <= 0) Object.assign(e, spawn());
+ const a = Math.max(0, Math.min(1, e.life)) * 0.8;
+ ex.beginPath();
+ const grd = ex.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.r * 4);
+ grd.addColorStop(0, `hsla(${e.hue},100%,62%,${a})`);
+ grd.addColorStop(1, 'hsla(20,100%,50%,0)');
+ ex.fillStyle = grd;
+ ex.arc(e.x, e.y, e.r * 4, 0, Math.PI * 2); ex.fill();
+ }
+ requestAnimationFrame(drawEmbers);
+ }
+ drawEmbers();
+
+ // ── Interactive torchlight following cursor ────────────
+ const tc = document.getElementById('torch');
+ const tx = tc.getContext('2d');
+ let mx = -999, my = -999, cx = -999, cy = -999;
+ window.addEventListener('pointermove', (e) => { mx = e.clientX; my = e.clientY; }, { passive: true });
+ function drawTorch() {
+ cx += (mx - cx) * 0.12; cy += (my - cy) * 0.12;
+ tx.clearRect(0, 0, W, H);
+ if (cx > -500) {
+ const flick = 1 + Math.sin(performance.now() * 0.02) * 0.05;
+ const g = tx.createRadialGradient(cx, cy, 0, cx, cy, 240 * flick);
+ g.addColorStop(0, 'rgba(255,180,90,0.16)');
+ g.addColorStop(0.5, 'rgba(255,120,40,0.06)');
+ g.addColorStop(1, 'rgba(0,0,0,0)');
+ tx.fillStyle = g; tx.fillRect(0, 0, W, H);
+ }
+ requestAnimationFrame(drawTorch);
+ }
+ drawTorch();
+})();
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..f8fc757
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,238 @@
+/* MODEL WARS — 2026 liquid-glass × medieval tournament × AAA fantasy UI */
+
+@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Cinzel+Decorative:wght@700;900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap');
+
+:root {
+ --bg:#07070b;
+ --bg2:#0d0e16;
+ --ink:#f3ecdc;
+ --muted:#9a94a8;
+ --gold:#e8c56a;
+ --gold-deep:#a9822f;
+ --silver:#c9ced8;
+ --ember:#ff6a2a;
+ --glass:rgba(22,22,32,.44);
+ --glass-brite:rgba(255,255,255,.06);
+ --stroke:rgba(232,197,106,.22);
+ --stroke-soft:rgba(255,255,255,.09);
+ --emerald:#2ecc71; --azure:#6aa8ff; --crimson:#e05a5a; --violet:#7c5cff;
+ --shadow:0 30px 80px rgba(0,0,0,.55);
+ --radius:20px;
+}
+
+* { box-sizing:border-box; }
+html,body { margin:0; height:100%; }
+body {
+ background:
+ radial-gradient(1200px 700px at 15% -10%, rgba(124,92,255,.10), transparent 60%),
+ radial-gradient(1000px 600px at 100% 0%, rgba(232,197,106,.08), transparent 55%),
+ linear-gradient(180deg,var(--bg2),var(--bg));
+ color:var(--ink);
+ font-family:Inter,system-ui,sans-serif;
+ overflow-x:hidden;
+ -webkit-font-smoothing:antialiased;
+}
+
+/* fixed scene layers */
+#embers, #torch { position:fixed; inset:0; z-index:0; pointer-events:none; }
+#torch { mix-blend-mode:screen; }
+.parallax {
+ position:fixed; left:0; right:0; bottom:0; z-index:0; pointer-events:none;
+ height:52vh; opacity:.5;
+ background:no-repeat bottom center / cover;
+}
+.vignette { position:fixed; inset:0; z-index:1; pointer-events:none;
+ box-shadow:inset 0 0 300px 60px rgba(0,0,0,.75); }
+
+.wrap { position:relative; z-index:2; max-width:1180px; margin:0 auto; padding:26px 22px 90px; }
+
+/* ── header ─────────────────────────────────────────────── */
+header.hero { text-align:center; padding:34px 0 10px; }
+.crest-line { font-family:Cinzel; letter-spacing:.55em; font-size:12px; color:var(--gold);
+ text-transform:uppercase; opacity:.8; }
+h1.wordmark {
+ font-family:'Cinzel Decorative',serif; font-weight:900;
+ font-size:clamp(44px,9vw,104px); line-height:.95; margin:.1em 0 .05em;
+ letter-spacing:.02em;
+ background:linear-gradient(180deg,#fff6dd 0%,var(--gold) 42%,var(--gold-deep) 100%);
+ -webkit-background-clip:text; background-clip:text; color:transparent;
+ text-shadow:0 2px 40px rgba(232,197,106,.25);
+ filter:drop-shadow(0 6px 24px rgba(0,0,0,.6));
+}
+.subtitle { font-family:Cinzel; color:var(--silver); letter-spacing:.28em;
+ text-transform:uppercase; font-size:clamp(10px,1.6vw,13px); opacity:.85; }
+
+.mode-flag {
+ display:inline-flex; gap:8px; align-items:center; margin-top:16px;
+ font-family:'JetBrains Mono',monospace; font-size:12px; padding:7px 14px;
+ border-radius:999px; border:1px solid var(--stroke); background:var(--glass);
+ backdrop-filter:blur(14px);
+}
+.mode-flag .dot { width:8px; height:8px; border-radius:50%; }
+.mode-live .dot { background:var(--emerald); box-shadow:0 0 12px var(--emerald); }
+.mode-exh .dot { background:var(--ember); box-shadow:0 0 12px var(--ember); }
+
+/* ── nav tabs ───────────────────────────────────────────── */
+nav.tabs { display:flex; gap:8px; justify-content:center; margin:26px 0 8px; flex-wrap:wrap; }
+.tab {
+ font-family:Cinzel; font-weight:700; letter-spacing:.12em; text-transform:uppercase;
+ font-size:13px; color:var(--muted); padding:11px 20px; border-radius:14px;
+ border:1px solid transparent; cursor:pointer; background:transparent; transition:.25s;
+}
+.tab:hover { color:var(--ink); }
+.tab.active { color:#1a1305; background:linear-gradient(180deg,#fbe6a6,var(--gold));
+ border-color:var(--gold-deep); box-shadow:0 8px 30px rgba(232,197,106,.28); }
+
+/* ── glass panel base ───────────────────────────────────── */
+.glass {
+ background:linear-gradient(180deg,var(--glass-brite),transparent), var(--glass);
+ border:1px solid var(--stroke-soft);
+ border-radius:var(--radius);
+ backdrop-filter:blur(20px) saturate(1.2);
+ -webkit-backdrop-filter:blur(20px) saturate(1.2);
+ box-shadow:var(--shadow), inset 0 1px 0 rgba(255,255,255,.08);
+ position:relative; overflow:hidden;
+}
+.glass::before { /* refractive sheen */
+ content:''; position:absolute; inset:0; pointer-events:none;
+ background:linear-gradient(115deg,transparent 30%,rgba(255,255,255,.06) 45%,transparent 60%);
+}
+.panel-title { font-family:Cinzel; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
+ font-size:13px; color:var(--gold); margin:0 0 14px; }
+
+/* ── champion selectors ─────────────────────────────────── */
+.combatants { display:grid; grid-template-columns:1fr 64px 1fr; gap:14px; align-items:stretch; margin-top:14px; }
+.vs-emblem { display:grid; place-items:center; font-family:'Cinzel Decorative'; font-size:26px;
+ color:var(--gold); text-shadow:0 0 24px rgba(232,197,106,.4); }
+.fighter { padding:18px; }
+.fighter select {
+ width:100%; margin-top:10px; font-family:Cinzel; font-weight:700; font-size:15px;
+ color:var(--ink); background:rgba(0,0,0,.35); border:1px solid var(--stroke);
+ border-radius:12px; padding:11px 12px; cursor:pointer; appearance:none;
+}
+.fighter .crest { font-size:38px; line-height:1; }
+.fighter .cname { font-family:Cinzel; font-weight:900; font-size:22px; margin-top:8px; }
+.fighter .ctitle { color:var(--muted); font-size:13px; font-style:italic; }
+.avail { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-family:'JetBrains Mono';
+ margin-top:8px; color:var(--muted); }
+.avail .d { width:7px; height:7px; border-radius:50%; }
+.avail.on .d { background:var(--emerald); box-shadow:0 0 8px var(--emerald); }
+.avail.off .d { background:var(--ember); }
+.fighter.side-a { border-left:2px solid color-mix(in srgb,var(--emerald),transparent 40%); }
+.fighter.side-b { border-right:2px solid color-mix(in srgb,var(--crimson),transparent 40%); }
+
+/* ── battle mode cards ──────────────────────────────────── */
+.modes { display:grid; grid-template-columns:repeat(5,1fr); gap:12px; margin-top:16px; }
+.mode-card { padding:16px 12px; text-align:center; cursor:pointer; transition:.22s; border:1px solid var(--stroke-soft); }
+.mode-card:hover { transform:translateY(-3px); border-color:var(--stroke); }
+.mode-card.sel { border-color:var(--gold); box-shadow:0 10px 34px rgba(232,197,106,.22), inset 0 0 0 1px var(--gold); }
+.mode-card .mi { font-size:30px; }
+.mode-card .mn { font-family:Cinzel; font-weight:700; letter-spacing:.08em; font-size:13px; margin-top:8px; text-transform:uppercase; }
+.mode-card .md { font-size:11px; color:var(--muted); margin-top:5px; line-height:1.35; min-height:2.4em; }
+
+/* ── prompt + action ────────────────────────────────────── */
+.prompt-row { margin-top:16px; padding:16px; }
+.prompt-row label { font-family:Cinzel; font-size:12px; letter-spacing:.1em; color:var(--gold); text-transform:uppercase; }
+textarea#prompt {
+ width:100%; margin-top:8px; min-height:74px; resize:vertical; color:var(--ink);
+ background:rgba(0,0,0,.35); border:1px solid var(--stroke); border-radius:12px; padding:12px;
+ font-family:Inter; font-size:14px; line-height:1.5;
+}
+.actions { display:flex; gap:12px; align-items:center; margin-top:14px; flex-wrap:wrap; }
+.btn {
+ font-family:Cinzel; font-weight:700; letter-spacing:.1em; text-transform:uppercase; font-size:14px;
+ padding:14px 30px; border-radius:14px; cursor:pointer; border:1px solid var(--gold-deep);
+ color:#1a1305; background:linear-gradient(180deg,#fce9ad,var(--gold));
+ box-shadow:0 12px 34px rgba(232,197,106,.32); transition:.2s;
+}
+.btn:hover { transform:translateY(-2px); box-shadow:0 16px 44px rgba(232,197,106,.42); }
+.btn:disabled { opacity:.5; cursor:not-allowed; transform:none; }
+.btn.ghost { background:transparent; color:var(--ink); border-color:var(--stroke); box-shadow:none; }
+.hint { color:var(--muted); font-size:12px; }
+
+/* ── battle stage ───────────────────────────────────────── */
+.stage-wrap { margin-top:18px; padding:0; }
+#stage { display:block; width:100%; height:340px; border-radius:var(--radius); }
+.stage-banner { position:absolute; top:12px; left:0; right:0; text-align:center;
+ font-family:Cinzel; letter-spacing:.2em; text-transform:uppercase; font-size:13px; color:var(--gold);
+ text-shadow:0 2px 20px rgba(0,0,0,.8); pointer-events:none; }
+
+/* ── results ────────────────────────────────────────────── */
+.results { margin-top:18px; display:none; }
+.winner-banner { text-align:center; padding:18px; margin-bottom:14px;
+ font-family:'Cinzel Decorative'; font-size:clamp(20px,4vw,34px); font-weight:900; }
+.winner-banner .sub { font-family:Inter; font-size:13px; color:var(--muted); letter-spacing:.04em; margin-top:4px; text-transform:none; }
+.score-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
+.resp-card { padding:18px; }
+.resp-head { display:flex; justify-content:space-between; align-items:center; gap:10px; }
+.resp-head .n { font-family:Cinzel; font-weight:900; font-size:19px; }
+.resp-head .badge { font-family:'JetBrains Mono'; font-size:11px; padding:4px 9px; border-radius:8px;
+ border:1px solid var(--stroke); color:var(--gold); }
+.metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin:14px 0; }
+.metric { background:rgba(0,0,0,.28); border:1px solid var(--stroke-soft); border-radius:10px; padding:9px 10px; }
+.metric .k { font-size:10px; letter-spacing:.08em; color:var(--muted); text-transform:uppercase; }
+.metric .v { font-family:'JetBrains Mono'; font-size:15px; margin-top:3px; }
+.aiscore { display:flex; align-items:baseline; gap:8px; margin-top:6px; }
+.aiscore .big { font-family:'Cinzel Decorative'; font-size:40px; font-weight:900; color:var(--gold); }
+.aiscore .of { color:var(--muted); font-size:14px; }
+.dims { margin-top:10px; }
+.dim { display:grid; grid-template-columns:130px 1fr 40px; gap:8px; align-items:center; margin:5px 0; font-size:12px; }
+.dim .bar { height:7px; border-radius:99px; background:rgba(255,255,255,.08); overflow:hidden; }
+.dim .bar i { display:block; height:100%; border-radius:99px;
+ background:linear-gradient(90deg,var(--gold-deep),var(--gold)); }
+.dim.risk .bar i { background:linear-gradient(90deg,#8a2b2b,var(--crimson)); }
+.dim .num { text-align:right; font-family:'JetBrains Mono'; color:var(--muted); }
+.rationale { font-size:12px; color:var(--muted); font-style:italic; margin-top:10px; line-height:1.5; }
+.objective-note { font-size:11px; color:var(--muted); margin-top:8px; }
+.objective-note b { color:var(--azure); }
+
+.vote-row { display:flex; gap:12px; align-items:center; justify-content:center; margin-top:16px; flex-wrap:wrap; }
+.vote-row .btn { padding:11px 22px; font-size:12px; }
+.people { text-align:center; font-size:12px; color:var(--muted); margin-top:8px; }
+
+.response-full { display:none; white-space:pre-wrap; font-family:'JetBrains Mono'; font-size:12.5px;
+ line-height:1.6; color:var(--ink); background:rgba(0,0,0,.35); border:1px solid var(--stroke-soft);
+ border-radius:10px; padding:14px; margin-top:10px; max-height:340px; overflow:auto; }
+
+/* ── King's Table ───────────────────────────────────────── */
+.kings-table { padding:20px; overflow:auto; }
+table.ladder { width:100%; border-collapse:collapse; font-size:13px; }
+table.ladder th { font-family:Cinzel; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
+ font-size:11px; color:var(--gold); text-align:left; padding:10px 10px; border-bottom:1px solid var(--stroke); white-space:nowrap; }
+table.ladder td { padding:11px 10px; border-bottom:1px solid rgba(255,255,255,.05); white-space:nowrap; }
+table.ladder tr:hover td { background:rgba(255,255,255,.03); }
+.champcell { display:flex; align-items:center; gap:10px; }
+.champcell .cr { font-size:20px; }
+.champcell .nm { font-family:Cinzel; font-weight:700; }
+.champcell .tt { color:var(--muted); font-size:11px; font-style:italic; }
+.elo { font-family:'JetBrains Mono'; font-size:16px; color:var(--gold); }
+.rank1 td { background:linear-gradient(90deg,rgba(232,197,106,.12),transparent); }
+.crown { display:inline-block; animation:crownFloat 3s ease-in-out infinite; filter:drop-shadow(0 0 8px rgba(232,197,106,.6)); }
+@keyframes crownFloat { 0%,100%{ transform:translateY(0) rotate(-3deg);} 50%{ transform:translateY(-4px) rotate(3deg);} }
+.streak.pos { color:var(--emerald); } .streak.neg { color:var(--crimson); }
+.delta.pos { color:var(--emerald); } .delta.neg { color:var(--crimson); }
+
+/* ── add champion ───────────────────────────────────────── */
+.addrow { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; align-items:center; }
+.addrow input, .addrow select { background:rgba(0,0,0,.35); border:1px solid var(--stroke); color:var(--ink);
+ border-radius:10px; padding:9px 11px; font-family:Inter; font-size:13px; }
+
+.view { display:none; }
+.view.active { display:block; animation:fade .4s ease; }
+@keyframes fade { from{ opacity:0; transform:translateY(8px);} to{ opacity:1; transform:none; } }
+
+footer { text-align:center; color:var(--muted); font-size:12px; margin-top:40px; line-height:1.7; }
+footer a { color:var(--gold); text-decoration:none; }
+
+.toast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%) translateY(20px);
+ background:var(--glass); border:1px solid var(--stroke); backdrop-filter:blur(16px);
+ padding:12px 20px; border-radius:12px; font-size:13px; z-index:50; opacity:0; transition:.3s; }
+.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
+
+@media (max-width:820px){
+ .modes { grid-template-columns:repeat(2,1fr); }
+ .combatants { grid-template-columns:1fr; }
+ .vs-emblem { display:none; }
+ .score-grid { grid-template-columns:1fr; }
+ .dim { grid-template-columns:110px 1fr 34px; }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..754e5aa
--- /dev/null
+++ b/server.js
@@ -0,0 +1,199 @@
+// server.js — MODEL WARS backend.
+// Serves the arena, proxies REAL model API calls, runs AI judges, and keeps a
+// persistent medieval ELO ladder. No route ever fabricates a model response.
+
+import express from 'express';
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import {
+ CHAMPIONS, championWithAvailability, callChampion,
+ judgeCall, judgeProvider,
+} from './providers.js';
+
+// Minimal .env loader (no dependency) — reads KEY=value lines into process.env.
+const __dirname = dirname(fileURLToPath(import.meta.url));
+try {
+ const envPath = join(__dirname, '.env');
+ if (existsSync(envPath)) {
+ for (const line of (await readFile(envPath, 'utf8')).split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+ }
+ }
+} catch { /* no .env — exhibition mode */ }
+
+const app = express();
+app.use(express.json({ limit: '256kb' }));
+app.use(express.static(join(__dirname, 'public')));
+
+const DATA_DIR = join(__dirname, 'data');
+const LB_PATH = join(DATA_DIR, 'leaderboard.json');
+
+// ── ELO ladder persistence ────────────────────────────────────────────
+const START_ELO = 1500;
+function freshRecord(champ) {
+ return {
+ id: champ.id, name: champ.name, title: champ.title, provider: champ.provider,
+ color: champ.color, crest: champ.crest,
+ elo: SEED_ELO[champ.id] ?? START_ELO,
+ wins: 0, losses: 0, draws: 0, streak: 0,
+ catWins: {}, // { joust: 3, archery: 1, ... }
+ totalRespMs: 0, battles: 0, totalCost: 0,
+ custom: !!champ.custom,
+ };
+}
+// Flavor seeds so the ladder opens with a believable spread (from the brief).
+const SEED_ELO = { gpt: 1847, claude: 1821, gemini: 1789, grok: 1744, deepseek: 1712, llama: 1688 };
+
+async function loadLadder() {
+ try {
+ const raw = JSON.parse(await readFile(LB_PATH, 'utf8'));
+ return raw;
+ } catch {
+ const seed = {};
+ for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
+ await saveLadder(seed);
+ return seed;
+ }
+}
+async function saveLadder(ladder) {
+ if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
+ await writeFile(LB_PATH, JSON.stringify(ladder, null, 2));
+}
+
+function expectedScore(a, b) { return 1 / (1 + 10 ** ((b - a) / 400)); }
+
+// Apply a result to the ladder. outcome: 1 = A wins, 0 = B wins, 0.5 = draw.
+async function applyResult({ aId, bId, outcome, category, aStats, bStats }) {
+ const ladder = await loadLadder();
+ const A = ladder[aId], B = ladder[bId];
+ if (!A || !B) return null;
+ const K = 32;
+ const eA = expectedScore(A.elo, B.elo);
+ const eB = 1 - eA;
+ const beforeA = A.elo, beforeB = B.elo;
+ A.elo = Math.round(A.elo + K * (outcome - eA));
+ B.elo = Math.round(B.elo + K * ((1 - outcome) - eB));
+
+ const bump = (rec, isWin, isDraw, cat, stats) => {
+ if (isDraw) { rec.draws++; rec.streak = 0; }
+ else if (isWin) { rec.wins++; rec.streak = rec.streak >= 0 ? rec.streak + 1 : 1; if (cat) rec.catWins[cat] = (rec.catWins[cat] || 0) + 1; }
+ else { rec.losses++; rec.streak = rec.streak <= 0 ? rec.streak - 1 : -1; }
+ if (stats) { rec.totalRespMs += stats.total || 0; rec.battles++; rec.totalCost += stats.cost || 0; }
+ };
+ const draw = outcome === 0.5;
+ bump(A, outcome === 1, draw, category, aStats);
+ bump(B, outcome === 0, draw, category, bStats);
+ await saveLadder(ladder);
+ return { ladder, deltas: { [aId]: A.elo - beforeA, [bId]: B.elo - beforeB } };
+}
+
+function strongestCategory(rec) {
+ const e = Object.entries(rec.catWins || {});
+ if (!e.length) return '—';
+ return e.sort((x, y) => y[1] - x[1])[0][0];
+}
+
+function ladderRows(ladder) {
+ const rows = Object.values(ladder).map((r) => {
+ const games = r.wins + r.losses + r.draws;
+ return {
+ ...r,
+ games,
+ winPct: games ? Math.round((r.wins / games) * 100) : 0,
+ avgRespMs: r.battles ? Math.round(r.totalRespMs / r.battles) : null,
+ costPerBattle: r.battles ? r.totalCost / r.battles : null,
+ strongest: strongestCategory(r),
+ };
+ });
+ rows.sort((a, b) => b.elo - a.elo);
+ rows.forEach((r, i) => (r.rank = i + 1));
+ return rows;
+}
+
+// ── Routes ────────────────────────────────────────────────────────────
+
+app.get('/api/champions', (req, res) => {
+ res.json({ champions: CHAMPIONS.map(championWithAvailability), judge: judgeProvider() });
+});
+
+app.get('/api/leaderboard', async (req, res) => {
+ res.json({ rows: ladderRows(await loadLadder()) });
+});
+
+// Register a new champion dynamically (brief: "Allow new models to be added").
+app.post('/api/champions', async (req, res) => {
+ const { id, name, title, provider = 'openai', color = '#c9a961', crest = '⚜' } = req.body || {};
+ if (!id || !name) return res.status(400).json({ error: 'id and name required' });
+ const ladder = await loadLadder();
+ if (!ladder[id]) {
+ ladder[id] = freshRecord({ id, name, title: title || 'The Challenger', provider, color, crest, custom: true });
+ await saveLadder(ladder);
+ }
+ res.json({ ok: true, champion: ladder[id] });
+});
+
+// REAL battle: send the identical prompt to both champions simultaneously.
+app.post('/api/battle', async (req, res) => {
+ const { prompt, a, b } = req.body || {};
+ if (!prompt || !a || !b) return res.status(400).json({ error: 'prompt, a, b required' });
+ const champA = CHAMPIONS.find((c) => c.id === a);
+ const champB = CHAMPIONS.find((c) => c.id === b);
+ if (!champA || !champB) return res.status(400).json({ error: 'unknown champion' });
+
+ const run = async (champ) => {
+ try { return { ok: true, ...(await callChampion(champ, prompt)) }; }
+ catch (e) { return { ok: false, error: String(e.message || e) }; }
+ };
+ const [ra, rb] = await Promise.all([run(champA), run(champB)]);
+ res.json({ a: ra, b: rb });
+});
+
+// AI judge: scores both answers 0–100 across the six dimensions.
+app.post('/api/judge', async (req, res) => {
+ const { prompt, aText, bText, aName = 'A', bName = 'B' } = req.body || {};
+ const provider = judgeProvider();
+ if (!provider) return res.status(503).json({ error: 'no judge available (no API keys configured)' });
+ if (!prompt || aText == null || bText == null) return res.status(400).json({ error: 'prompt, aText, bText required' });
+
+ const rubric = `Judge two answers to the SAME prompt. Score EACH answer 0-100 on: correctness, reasoning, relevance, completeness, instruction_adherence, and hallucination_risk (higher hallucination_risk = MORE hallucination = worse). Also give a one-sentence rationale per answer.
+PROMPT:
+"""${prompt.slice(0, 4000)}"""
+ANSWER_A (${aName}):
+"""${(aText || '').slice(0, 6000)}"""
+ANSWER_B (${bName}):
+"""${(bText || '').slice(0, 6000)}"""
+Return JSON exactly: {"a":{"correctness":0,"reasoning":0,"relevance":0,"completeness":0,"instruction_adherence":0,"hallucination_risk":0,"rationale":""},"b":{...same...}}`;
+ try {
+ const scored = await judgeCall(provider, rubric);
+ res.json({ provider, scored });
+ } catch (e) {
+ res.status(502).json({ error: String(e.message || e) });
+ }
+});
+
+// Record a completed battle onto the ELO ladder.
+app.post('/api/result', async (req, res) => {
+ const { aId, bId, outcome, category, aStats, bStats } = req.body || {};
+ if (!aId || !bId || outcome == null) return res.status(400).json({ error: 'aId, bId, outcome required' });
+ const r = await applyResult({ aId, bId, outcome: Number(outcome), category, aStats, bStats });
+ if (!r) return res.status(400).json({ error: 'unknown champion' });
+ res.json({ ok: true, deltas: r.deltas, rows: ladderRows(r.ladder) });
+});
+
+// Reset the ladder to seeds (admin / demo convenience).
+app.post('/api/leaderboard/reset', async (req, res) => {
+ const seed = {};
+ for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
+ await saveLadder(seed);
+ res.json({ ok: true, rows: ladderRows(seed) });
+});
+
+const PORT = process.env.PORT || 9911;
+app.listen(PORT, () => {
+ const live = CHAMPIONS.map(championWithAvailability).filter((c) => c.available).map((c) => c.name);
+ console.log(`⚔️ MODEL WARS live at http://localhost:${PORT}`);
+ console.log(live.length ? ` Live champions: ${live.join(', ')}` : ' No API keys — EXHIBITION mode (simulated battles, clearly labeled).');
+});
(oldest)
·
back to Model Wars
·
auto-data-snapshot: 2026-08-24T09:04:34 (11 data files) — pu 57498cc →