diff --git a/__tests__/api/leaderboard.test.ts b/__tests__/api/leaderboard.test.ts new file mode 100644 index 00000000..1dca26c1 --- /dev/null +++ b/__tests__/api/leaderboard.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { GET } from "@/app/api/leaderboard/route" +import { registerAgent, resetAgentRegistryForTests } from "@/lib/agent-registry" +import { awardXP, resetAgentXpDb } from "@/lib/gamification/xp" +import { resetReputationStoreForTests, upsertReputationMetrics } from "@/lib/reputation/reputation-store" + +function register(agentId: string, district: "data-center" | "comm-hub" = "data-center") { + registerAgent({ + agentId, + model: "claude-haiku-4-5", + district, + capabilities: ["analytics"], + x402: { accepts: true }, + status: "active", + endpoint: `https://example.com/${agentId}`, + }) +} + +beforeEach(() => { + resetAgentRegistryForTests() + resetAgentXpDb() + resetReputationStoreForTests() +}) + +describe("GET /api/leaderboard", () => { + it("returns live registry agents sorted by XP with public 30s cache", async () => { + register("low-xp") + register("high-xp") + awardXP("low-xp", 100, "task.completed") + awardXP("high-xp", 300, "task.completed") + + const res = await GET(new Request("http://localhost/api/leaderboard")) + const body = await res.json() + + expect(res.headers.get("Cache-Control")).toBe("public, max-age=30") + expect(body.agents.map((agent: { id: string }) => agent.id)).toEqual(["high-xp", "low-xp"]) + expect(body.agents[0].xp).toBe(300) + }) + + it("filters district view and sorts that district by task completions", async () => { + register("data-slower", "data-center") + register("comm-busy", "comm-hub") + register("data-busy", "data-center") + awardXP("data-slower", 500, "task.completed") + awardXP("data-busy", 10, "task.completed") + upsertReputationMetrics("data-slower", { tasksCompleted: 2 }) + upsertReputationMetrics("data-busy", { tasksCompleted: 20 }) + upsertReputationMetrics("comm-busy", { tasksCompleted: 100 }) + + const res = await GET(new Request("http://localhost/api/leaderboard?view=district&district=data-center")) + const body = await res.json() + + expect(body.agents.map((agent: { id: string }) => agent.id)).toEqual(["data-busy", "data-slower"]) + expect(body.agents.every((agent: { district: string }) => agent.district === "data-center")).toBe(true) + }) + + it("returns an empty state payload when no agents are registered", async () => { + const res = await GET(new Request("http://localhost/api/leaderboard")) + const body = await res.json() + + expect(body.agents).toEqual([]) + }) +}) diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts index aaaa2819..82b52d0f 100644 --- a/app/api/leaderboard/route.ts +++ b/app/api/leaderboard/route.ts @@ -21,6 +21,6 @@ export function GET(req: Request) { refreshedAt: new Date().toISOString(), nextResetAt: "Sunday 00:00 UTC", }, - { headers: { "Cache-Control": "no-store" } }, + { headers: { "Cache-Control": "public, max-age=30" } }, ) } diff --git a/components/leaderboard/leaderboard-table.tsx b/components/leaderboard/leaderboard-table.tsx index 8137ff01..8abcd436 100644 --- a/components/leaderboard/leaderboard-table.tsx +++ b/components/leaderboard/leaderboard-table.tsx @@ -21,7 +21,7 @@ export function LeaderboardTable({ initialAgents, view, district }: LeaderboardT let cancelled = false async function refresh() { - const response = await fetch(`/api/leaderboard?${params.toString()}`, { cache: "no-store" }) + const response = await fetch(`/api/leaderboard?${params.toString()}`) if (!response.ok || cancelled) return const payload = await response.json() as { agents: LeaderboardAgent[]; refreshedAt: string } setAgents(payload.agents) @@ -44,6 +44,11 @@ export function LeaderboardTable({ initialAgents, view, district }: LeaderboardT {refreshedAt ? `Updated ${new Date(refreshedAt).toLocaleTimeString()}` : "Live polling ready"}
+ {agents.length === 0 && ( +
+ No agents are registered yet. Registered agents will appear here within 30 seconds. +
+ )} {agents.map((agent) => { const delta = agent.previousRank - agent.rank return ( diff --git a/lib/agent-registry.ts b/lib/agent-registry.ts index 71ce95b2..3421e267 100644 --- a/lib/agent-registry.ts +++ b/lib/agent-registry.ts @@ -175,6 +175,10 @@ function emitRegistryChange(action: AgentRegistryChangeAction, agent: AgentCapab }) } +export function getAgentRegistry(): AgentCapabilityManifest[] { + return Array.from(registry.agents.values()) +} + export function listRegisteredAgents(filters: AgentRegistryFilters = {}): AgentCapabilityManifest[] { return Array.from(registry.agents.values()).filter((agent) => { if (filters.district && agent.district !== filters.district) return false diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts index 040f0bdb..bdd44dc6 100644 --- a/lib/leaderboard.ts +++ b/lib/leaderboard.ts @@ -1,4 +1,7 @@ +import { getAgentRegistry } from "@/lib/agent-registry" import { DISTRICTS } from "@/lib/data" +import { getAgentXP } from "@/lib/gamification/xp" +import { getReputation } from "@/lib/reputation/reputation-store" import type { DistrictId } from "@/lib/types" export type LeaderboardView = "global" | "district" | "week" @@ -22,61 +25,63 @@ export interface LeaderboardAgent { globalRank: number } -const BASE_AGENTS = [ - ["bot-0", "Nexus-7", "data-center", 1234, 184, 42, 88400, 812.44, 3, ["๐Ÿ†", "โšก", "๐Ÿ’พ"]], - ["bot-1", "Cipher-3", "comm-hub", 987, 211, 38, 73120, 663.1, 1, ["๐Ÿ“ก", "๐Ÿ”"]], - ["bot-2", "Pulse-9", "processing", 934, 176, 36, 69450, 590.73, 4, ["โš™๏ธ", "๐Ÿ”ฅ"]], - ["bot-3", "Vector-1", "defense", 876, 148, 34, 63100, 551.92, 5, ["๐Ÿ›ก๏ธ", "๐ŸŽฏ"]], - ["bot-4", "Halo-5", "research", 822, 193, 33, 60540, 522.18, 2, ["๐Ÿงช", "๐Ÿ’ก"]], - ["bot-5", "Stratos-2", "data-center", 760, 121, 31, 54790, 487.35, 0, ["๐Ÿ’พ"]], - ["bot-6", "Bolt-8", "comm-hub", 715, 117, 29, 51410, 439.27, 6, ["โšก"]], - ["bot-7", "Prism-4", "processing", 681, 99, 28, 49620, 418.04, 2, ["โš™๏ธ"]], - ["bot-8", "Flux-6", "defense", 642, 136, 27, 47180, 392.66, 4, ["๐Ÿ›ก๏ธ"]], - ["bot-9", "Nova-0", "research", 604, 104, 25, 44980, 361.89, 3, ["๐Ÿงช"]], - ["bot-10", "Vertex-11", "data-center", 571, 88, 24, 41320, 337.2, 1, ["๐Ÿ“ˆ"]], - ["bot-11", "Echo-12", "comm-hub", 548, 82, 23, 39870, 321.74, 0, ["๐Ÿ“ก"]], -] as const +function spriteIdForAgent(agentId: string): number { + return [...agentId].reduce((sum, char) => sum + char.charCodeAt(0), 0) % 7 +} + +function badgesForAgent(capabilities: string[]): string[] { + return capabilities.slice(0, 3).map((capability) => capability.slice(0, 2).toUpperCase()) +} + +function toLeaderboardAgent(agent: ReturnType[number]): LeaderboardAgent { + const districtMeta = DISTRICTS.find((item) => item.id === agent.district)! + const xp = getAgentXP(agent.agentId) + const reputation = getReputation(agent.agentId) + + return { + id: agent.agentId, + name: agent.agentId, + district: agent.district, + districtName: districtMeta.name, + districtColor: districtMeta.color, + tasksCompleted: reputation.metrics.tasksCompleted, + weeklyTasks: reputation.metrics.tasksCompleted, + level: xp.level, + xp: xp.xp, + x402Revenue: reputation.metrics.x402RevenueXlm, + spriteId: spriteIdForAgent(agent.agentId), + badges: badgesForAgent(agent.capabilities), + rank: 0, + previousRank: 0, + districtRank: 0, + globalRank: 0, + } +} + +function compareByXp(a: LeaderboardAgent, b: LeaderboardAgent): number { + return b.xp - a.xp || a.id.localeCompare(b.id) +} -function jitter(seed: number, modulo: number): number { - return Math.floor(Date.now() / 30000 + seed * 13) % modulo +function compareByTasks(a: LeaderboardAgent, b: LeaderboardAgent): number { + return b.tasksCompleted - a.tasksCompleted || a.id.localeCompare(b.id) } export function listLeaderboardAgents(view: LeaderboardView = "global", district?: DistrictId): LeaderboardAgent[] { - const rows = BASE_AGENTS.map((agent, index) => { - const districtMeta = DISTRICTS.find((item) => item.id === agent[2])! - return { - id: agent[0], - name: agent[1], - district: agent[2], - districtName: districtMeta.name, - districtColor: districtMeta.color, - tasksCompleted: agent[3] + jitter(index, 7), - weeklyTasks: agent[4] + jitter(index, 11), - level: agent[5], - xp: agent[6], - x402Revenue: agent[7], - spriteId: agent[8], - badges: [...agent[9]], - rank: 0, - previousRank: 0, - districtRank: 0, - globalRank: 0, - } - }) + const rows = getAgentRegistry().map(toLeaderboardAgent) - const global = [...rows].sort((a, b) => b.tasksCompleted - a.tasksCompleted) + const global = [...rows].sort(compareByXp) global.forEach((row, index) => { row.globalRank = index + 1 }) for (const districtMeta of DISTRICTS) { rows .filter((row) => row.district === districtMeta.id) - .sort((a, b) => b.tasksCompleted - a.tasksCompleted) + .sort(compareByTasks) .forEach((row, index) => { row.districtRank = index + 1 }) } const filtered = district ? rows.filter((row) => row.district === district) : rows - const sorted = [...filtered].sort((a, b) => (view === "week" ? b.weeklyTasks - a.weeklyTasks : b.tasksCompleted - a.tasksCompleted)) - return sorted.map((row, index) => ({ ...row, rank: index + 1, previousRank: Math.max(1, index + 1 + ((index % 3) - 1)) })) + const sorted = [...filtered].sort(view === "global" ? compareByXp : compareByTasks) + return sorted.map((row, index) => ({ ...row, rank: index + 1, previousRank: index + 1 })) } export function getLeaderboardAgent(agentId: string): LeaderboardAgent | undefined {