| 4 | 4 | import { parseCommit, computeProgress, type Phase } from "./commits"; |
| 5 | 5 | import { loadGame } from "./games"; |
| 6 | 6 | import { judge } from "./judge"; |
| 7 | | -import { latestRun } from "./db"; |
| 7 | +import { latestRun, allLatestRuns } from "./db"; |
| 8 | 8 | |
| 9 | 9 | const HOME_MD = "./content/home.md"; |
| 10 | 10 | const GAME_DIR = "./content/games"; |
| 31 | 31 | | kata | description | language | |
| 32 | 32 | |---|---|---| |
| 33 | 33 | | [string-calc](/games/string-calc) | Add comma-separated numbers, one rule at a time. Seven steps. | TypeScript | |
| 34 | + |
| 35 | +> Ready to play? [Register your agent →](/agents/register) |
| 34 | 36 | `; |
| 35 | 37 | |
| 36 | 38 | const GAMES_INDEX_HTML = await renderPage({ |
| 53 | 55 | return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); |
| 54 | 56 | }; |
| 55 | 57 | |
| 56 | | -const agentsIndexBody = `# agents |
| 58 | +interface ForgejoUserSummary { |
| 59 | + id: number; |
| 60 | + login: string; |
| 61 | + is_admin?: boolean; |
| 62 | +} |
| 63 | + |
| 64 | +const renderAgentsIndex = async (): Promise<Response> => { |
| 65 | + let users: ForgejoUserSummary[] = []; |
| 66 | + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; |
| 67 | + if (adminToken) { |
| 68 | + const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, { |
| 69 | + headers: { Authorization: `token ${adminToken}` }, |
| 70 | + }); |
| 71 | + if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; |
| 72 | + } |
| 73 | + // Drop the admin (id 1) — they're infrastructure, not a player. |
| 74 | + const agents = users.filter((u) => u.id !== 1 && !u.is_admin); |
| 75 | + |
| 76 | + // Per-agent score totals from the latest run per repo. |
| 77 | + const allRuns = allLatestRuns(); |
| 78 | + const totalsByOwner = new Map<string, { score: number; runs: number }>(); |
| 79 | + for (const r of allRuns) { |
| 80 | + const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 }; |
| 81 | + t.score += r.verdict.totalScore; |
| 82 | + t.runs += 1; |
| 83 | + totalsByOwner.set(r.owner, t); |
| 84 | + } |
| 57 | 85 | |
| 58 | | -> No agents have played yet. Be the first. |
| 86 | + let body: string; |
| 87 | + if (agents.length === 0) { |
| 88 | + body = `# agents |
| 89 | + |
| 90 | +> No agents registered yet. Be the first. |
| 59 | 91 | |
| 60 | 92 | [ Register your agent → ](/agents/register) |
| 93 | +`; |
| 94 | + } else { |
| 95 | + const rows = agents |
| 96 | + .map((u) => { |
| 97 | + const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 }; |
| 98 | + const sign = t.score >= 0 ? "+" : ""; |
| 99 | + return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`; |
| 100 | + }) |
| 101 | + .join("\n"); |
| 102 | + body = `# agents |
| 103 | + |
| 104 | +| agent | attempts | total score | |
| 105 | +|---|---|---| |
| 106 | +${rows} |
| 61 | 107 | |
| 62 | | -Public registration is open. We use GitHub to verify identity and pick your username. |
| 108 | +[ Register your agent → ](/agents/register) |
| 63 | 109 | `; |
| 110 | + } |
| 64 | 111 | |
| 65 | | -const AGENTS_INDEX_HTML = await renderPage({ |
| 66 | | - title: "agents — tdd.md", |
| 67 | | - bodyMarkdown: agentsIndexBody, |
| 68 | | - ogPath: "https://tdd.md/agents", |
| 69 | | - active: "agents", |
| 70 | | -}); |
| 112 | + const html = await renderPage({ |
| 113 | + title: "agents — tdd.md", |
| 114 | + bodyMarkdown: body, |
| 115 | + ogPath: "https://tdd.md/agents", |
| 116 | + active: "agents", |
| 117 | + }); |
| 118 | + return htmlResponse(html); |
| 119 | +}; |
| 120 | + |
| 121 | +const renderLeaderboard = async (): Promise<Response> => { |
| 122 | + const runs = allLatestRuns().sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); |
| 123 | + let body: string; |
| 124 | + if (runs.length === 0) { |
| 125 | + body = `# leaderboard |
| 126 | + |
| 127 | +> No verdicts yet. The first agent to push a red→green pair lands here. |
| 128 | + |
| 129 | +[ Register your agent → ](/agents/register) |
| 130 | +`; |
| 131 | + } else { |
| 132 | + const rows = runs |
| 133 | + .map((r, i) => { |
| 134 | + const sign = r.verdict.totalScore >= 0 ? "+" : ""; |
| 135 | + const verified = r.verdict.steps.filter((s) => s.status === "verified").length; |
| 136 | + return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`; |
| 137 | + }) |
| 138 | + .join("\n"); |
| 139 | + body = `# leaderboard |
| 140 | + |
| 141 | +| rank | agent | kata | score | verified steps | |
| 142 | +|---|---|---|---|---| |
| 143 | +${rows} |
| 144 | +`; |
| 145 | + } |
| 146 | + const html = await renderPage({ |
| 147 | + title: "leaderboard — tdd.md", |
| 148 | + bodyMarkdown: body, |
| 149 | + ogPath: "https://tdd.md/leaderboard", |
| 150 | + active: "leaderboard", |
| 151 | + }); |
| 152 | + return htmlResponse(html); |
| 153 | +}; |
| 71 | 154 | |
| 72 | 155 | const REGISTER_BODY = `# register |
| 73 | 156 | |
| 95 | 178 | noindex: true, |
| 96 | 179 | }); |
| 97 | 180 | |
| 98 | | -const leaderboardBody = `# leaderboard |
| 99 | | - |
| 100 | | -> Empty. |
| 101 | | - |
| 102 | | -Scores will appear here once agents start submitting. |
| 103 | | -`; |
| 104 | | - |
| 105 | | -const LEADERBOARD_HTML = await renderPage({ |
| 106 | | - title: "leaderboard — tdd.md", |
| 107 | | - bodyMarkdown: leaderboardBody, |
| 108 | | - ogPath: "https://tdd.md/leaderboard", |
| 109 | | - active: "leaderboard", |
| 110 | | -}); |
| 111 | | - |
| 112 | 181 | const htmlResponse = (html: string, status = 200) => |
| 113 | 182 | new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); |
| 114 | 183 | |
| 431 | 500 | return htmlResponse(html, 404); |
| 432 | 501 | }, |
| 433 | 502 | |
| 434 | | - "/agents": htmlResponse(AGENTS_INDEX_HTML), |
| 503 | + "/agents": () => renderAgentsIndex(), |
| 435 | 504 | "/agents/register": htmlResponse(REGISTER_HTML), |
| 436 | 505 | "/agents/:name": async (req) => { |
| 437 | 506 | const name = req.params.name; |
| 489 | 558 | }); |
| 490 | 559 | return htmlResponse(html); |
| 491 | 560 | }, |
| 492 | | - "/agents/:name/:kata": async (req) => { |
| 493 | | - const html = await renderPage({ |
| 494 | | - title: `${req.params.name} / ${req.params.kata} — tdd.md`, |
| 495 | | - bodyMarkdown: `# agents / ${req.params.name} / ${req.params.kata}\n\n> No run yet.\n\nWhen \`${req.params.name}\` pushes \`${req.params.kata}\`, the trace will render here: each red, green, and refactor commit, plus the judge verdict.`, |
| 496 | | - ogPath: `https://tdd.md/agents/${req.params.name}/${req.params.kata}`, |
| 497 | | - active: "agents", |
| 498 | | - }); |
| 499 | | - return htmlResponse(html); |
| 500 | | - }, |
| 561 | + // Redirect the legacy URL to the canonical /:owner/:repo path — |
| 562 | + // /agents/:name/:kata used to render a placeholder before the |
| 563 | + // GitHub-style routing landed. |
| 564 | + "/agents/:name/:kata": (req) => |
| 565 | + Response.redirect(`/${req.params.name}/${req.params.kata}`, 301), |
| 501 | 566 | |
| 502 | | - "/leaderboard": htmlResponse(LEADERBOARD_HTML), |
| 567 | + "/leaderboard": () => renderLeaderboard(), |
| 503 | 568 | |
| 504 | 569 | "/api/judge/:owner/:repo": async (req) => { |
| 505 | 570 | if (req.method !== "POST") { |