Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions app/districts/[id]/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { CountdownTimer } from "@/components/districts/countdown-timer"
import { DISTRICTS, createAgents } from "@/lib/data"
import { getDistrictLeaderboard, getPreviousDistrictLeaderboardSnapshot } from "@/lib/gamification/district-leaderboard"
import { getActiveDistrictEvent } from "@/lib/gamification/events"
import type { DistrictId } from "@/lib/types"

type DistrictLeaderboardPageProps = {
params: Promise<{ id: string }>
}

export const dynamic = "force-dynamic"

export async function generateMetadata({ params }: DistrictLeaderboardPageProps) {
const { id } = await params
const district = DISTRICTS.find((entry) => entry.id === id)
return {
title: district ? `${district.name} Leaderboard | Open Stellar` : "District Leaderboard | Open Stellar",
description: "Current-week Open Stellar district competition rankings.",
}
}

export default async function DistrictLeaderboardPage({ params }: DistrictLeaderboardPageProps) {

Check warning on line 24 in app/districts/[id]/leaderboard/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZNkAl5Nj1gAX0bIp4&open=AZ8ZNkAl5Nj1gAX0bIp4&pullRequest=423
const { id } = await params
const district = DISTRICTS.find((entry) => entry.id === id)
if (!district) notFound()

const districtId = district.id as DistrictId
const agents = createAgents()
const event = getActiveDistrictEvent()
const entries = getDistrictLeaderboard(agents, districtId)
const previousSnapshot = getPreviousDistrictLeaderboardSnapshot(districtId)

return (
<main className="min-h-screen bg-[#030712] px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-6">
<nav className="flex flex-wrap gap-2">
<Link href="/" className="rounded-md border border-cyan-400/30 bg-cyan-400/10 px-3 py-2 font-mono text-xs uppercase tracking-[0.2em] text-cyan-200">
Back to city
</Link>
<Link href="/leaderboard" className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 font-mono text-xs uppercase tracking-[0.2em] text-slate-300">
Global leaderboard
</Link>
</nav>

<header className="overflow-hidden rounded-2xl border bg-slate-950/80 p-5" style={{ borderColor: `${district.color}66` }}>
<p className="font-mono text-xs uppercase tracking-[0.28em]" style={{ color: district.color }}>District competition</p>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="font-pixel text-3xl uppercase text-slate-100 sm:text-4xl">{district.name} Leaderboard</h1>
<p className="mt-3 max-w-3xl font-mono text-sm leading-7 text-slate-400">
Top 10 agents ranked by this week&apos;s {event.challenge.name.toLowerCase()} score. Competition weeks reset every Sunday at 00:00 UTC.
</p>
</div>
<div className="rounded-xl border border-cyan-300/20 bg-cyan-300/10 p-4">
<p className="mb-2 font-mono text-xs uppercase tracking-[0.2em] text-cyan-300">Ends at {new Date(event.endsAt).toUTCString()}</p>
<CountdownTimer endsAt={event.endsAt} />
</div>
</div>
</header>

<section className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-pixel text-2xl uppercase text-slate-100">Current week rankings</h2>
<p className="font-mono text-xs text-slate-400">Metric: {event.challenge.metric}</p>
</div>
<span className="w-fit rounded-full border border-slate-700 px-3 py-1 font-mono text-xs text-slate-300">{event.challenge.scoreLabel}</span>
</div>

<div className="overflow-x-auto rounded-xl border border-slate-800">
<table className="min-w-[760px] w-full border-collapse text-left font-mono text-sm">
<thead className="bg-slate-900 text-xs uppercase tracking-[0.18em] text-slate-400">
<tr>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Agent name</th>
<th className="px-4 py-3">Score</th>
<th className="px-4 py-3">Tasks completed this week</th>
<th className="px-4 py-3">District</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{entries.map((entry) => (
<tr key={entry.agentId} className="bg-slate-950/60">
<td className="px-4 py-3 text-cyan-200">#{entry.rank}</td>
<td className="px-4 py-3 font-semibold text-slate-100">{entry.agentName}</td>
<td className="px-4 py-3">{entry.formattedScore}</td>
<td className="px-4 py-3">{entry.tasksCompletedThisWeek}</td>
<td className="px-4 py-3" style={{ color: district.color }}>{entry.districtName}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>

{previousSnapshot && (
<section className="rounded-2xl border border-amber-300/20 bg-amber-300/10 p-4">
<h2 className="font-pixel text-2xl uppercase text-amber-100">Previous week results</h2>
<p className="mt-1 font-mono text-xs text-amber-100/70">
Week {previousSnapshot.weekIndex} · {previousSnapshot.challengeName} · ended {new Date(previousSnapshot.endedAt).toUTCString()}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{previousSnapshot.entries.slice(0, 3).map((entry) => (
<div key={entry.agentId} className="rounded-xl border border-amber-200/20 bg-slate-950/70 p-4">
<div className="font-mono text-xs uppercase tracking-[0.18em] text-amber-200">#{entry.rank}</div>
<div className="mt-2 font-pixel text-xl uppercase text-slate-100">{entry.agentName}</div>
<div className="mt-2 font-mono text-sm text-slate-300">Score: {entry.formattedScore}</div>
<div className="font-mono text-xs text-slate-500">Tasks: {entry.tasksCompletedThisWeek}</div>
</div>
))}
</div>
</section>
)}
</div>
</main>
)
}
30 changes: 30 additions & 0 deletions components/districts/countdown-timer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client"

import { useEffect, useMemo, useState } from "react"

function formatRemaining(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${days}d ${String(hours).padStart(2, "0")}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`
}

export function CountdownTimer({ endsAt }: { endsAt: string }) {

Check warning on line 14 in components/districts/countdown-timer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZNkCe5Nj1gAX0bIp5&open=AZ8ZNkCe5Nj1gAX0bIp5&pullRequest=423
const endMs = useMemo(() => new Date(endsAt).getTime(), [endsAt])
const [remainingMs, setRemainingMs] = useState(() => endMs - Date.now())

useEffect(() => {
const update = () => setRemainingMs(endMs - Date.now())
update()
const id = window.setInterval(update, 1000)

Check warning on line 21 in components/districts/countdown-timer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZNkCe5Nj1gAX0bIp6&open=AZ8ZNkCe5Nj1gAX0bIp6&pullRequest=423
return () => window.clearInterval(id)

Check warning on line 22 in components/districts/countdown-timer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZNkCe5Nj1gAX0bIp7&open=AZ8ZNkCe5Nj1gAX0bIp7&pullRequest=423
}, [endMs])

return (
<time dateTime={endsAt} className="font-mono text-2xl text-cyan-100 sm:text-3xl">
{formatRemaining(remainingMs)}
</time>
)
}
5 changes: 3 additions & 2 deletions components/open-stellar/district-event-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import Link from "next/link"
import type { ActiveDistrictEvent, DistrictStanding } from "@/lib/gamification/events"

interface DistrictEventOverlayProps {
Expand Down Expand Up @@ -49,7 +50,7 @@ export function DistrictEventOverlay({ event, standings }: DistrictEventOverlayP
</div>
<div style={{ display: "grid", gap: 6 }}>
{standings.slice(0, 5).map((standing) => (
<div key={standing.districtId} style={{ display: "grid", gridTemplateColumns: "22px 1fr auto", alignItems: "center", gap: 6 }}>
<Link key={standing.districtId} href={`/districts/${standing.districtId}/leaderboard`} style={{ display: "grid", gridTemplateColumns: "22px 1fr auto", alignItems: "center", gap: 6, textDecoration: "none" }} aria-label={`${standing.districtName} leaderboard`}>
<div style={{ color: standing.color, fontSize: 11, fontWeight: 800 }}>#{standing.rank}</div>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#cbd5e1", fontSize: 10, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
Expand All @@ -68,7 +69,7 @@ export function DistrictEventOverlay({ event, standings }: DistrictEventOverlayP
<div style={{ color: standing.rank <= 2 ? "#fbbf24" : "#64748b", fontSize: 10, fontWeight: 700 }}>
{standing.formattedScore} {standing.multiplier > 1 ? `${standing.multiplier}×` : ""}
</div>
</div>
</Link>
))}
</div>
</section>
Expand Down
26 changes: 26 additions & 0 deletions data/district-leaderboard-snapshots.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[
{
"weekIndex": 24,
"districtId": "data-center",
"districtName": "Data Center",
"endedAt": "2026-06-28T00:00:00.000Z",
"challengeName": "Skill Mastery",
"entries": [
{ "rank": 1, "agentId": "bot-0", "agentName": "Nexus-7", "score": 4, "formattedScore": "4.00", "tasksCompletedThisWeek": 43 },
{ "rank": 2, "agentId": "bot-10", "agentName": "Vertex-11", "score": 2.67, "formattedScore": "2.67", "tasksCompletedThisWeek": 31 },
{ "rank": 3, "agentId": "bot-5", "agentName": "Stratos-2", "score": 2.5, "formattedScore": "2.50", "tasksCompletedThisWeek": 24 }
]
},
{
"weekIndex": 24,
"districtId": "comm-hub",
"districtName": "Comm Hub",
"endedAt": "2026-06-28T00:00:00.000Z",
"challengeName": "Skill Mastery",
"entries": [
{ "rank": 1, "agentId": "bot-1", "agentName": "Cipher-3", "score": 3.5, "formattedScore": "3.50", "tasksCompletedThisWeek": 38 },
{ "rank": 2, "agentId": "bot-6", "agentName": "Bolt-8", "score": 2.25, "formattedScore": "2.25", "tasksCompletedThisWeek": 27 },
{ "rank": 3, "agentId": "bot-11", "agentName": "Echo-12", "score": 2, "formattedScore": "2.00", "tasksCompletedThisWeek": 20 }
]
}
]
80 changes: 80 additions & 0 deletions lib/gamification/district-leaderboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { DISTRICTS } from "@/lib/data"
import { formatDistrictScore, getActiveDistrictEvent, getAgentDistrictCompetitionScore, getWeekBounds, getWeeklyTasksCompleted } from "@/lib/gamification/events"
import type { DistrictId, MoltbotAgent } from "@/lib/types"

export interface DistrictLeaderboardEntry {
rank: number
agentId: string
agentName: string
score: number
formattedScore: string
tasksCompletedThisWeek: number
districtId: DistrictId
districtName: string
}

export interface DistrictLeaderboardSnapshot {
weekIndex: number
districtId: DistrictId
districtName: string
endedAt: string
challengeName: string
entries: Omit<DistrictLeaderboardEntry, "districtId" | "districtName">[]
}

const SNAPSHOT_PATH = path.join(process.cwd(), "data", "district-leaderboard-snapshots.json")

export function getDistrictLeaderboard(agents: MoltbotAgent[], districtId: DistrictId, now: Date = new Date()): DistrictLeaderboardEntry[] {
const event = getActiveDistrictEvent(now)
const district = DISTRICTS.find((entry) => entry.id === districtId)
const districtName = district?.name ?? districtId

return agents
.filter((agent) => agent.district === districtId)
.map((agent) => {
const score = getAgentDistrictCompetitionScore(agent, event.challenge, event.weekIndex)
return {
rank: 0,
agentId: agent.id,
agentName: agent.name,
score: Number(score.toFixed(3)),
formattedScore: formatDistrictScore(event.challenge, score),
tasksCompletedThisWeek: getWeeklyTasksCompleted(agent, event.weekIndex),
districtId,
districtName,
}
})
.sort((a, b) => event.challenge.better === "lower" ? a.score - b.score : b.score - a.score)
.slice(0, 10)
.map((entry, index) => ({ ...entry, rank: index + 1 }))
}

function isSnapshot(value: unknown): value is DistrictLeaderboardSnapshot {
if (!value || typeof value !== "object") return false
const candidate = value as Partial<DistrictLeaderboardSnapshot>
return typeof candidate.weekIndex === "number"
&& typeof candidate.districtId === "string"
&& typeof candidate.districtName === "string"
&& typeof candidate.endedAt === "string"
&& typeof candidate.challengeName === "string"
&& Array.isArray(candidate.entries)
}

export function readDistrictLeaderboardSnapshots(): DistrictLeaderboardSnapshot[] {
if (!existsSync(SNAPSHOT_PATH)) return []
try {
const parsed: unknown = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8"))
return Array.isArray(parsed) ? parsed.filter(isSnapshot) : []
} catch {
return []
}
}

export function getPreviousDistrictLeaderboardSnapshot(districtId: DistrictId, now: Date = new Date()): DistrictLeaderboardSnapshot | null {
const previousWeekIndex = Math.max(0, getWeekBounds(now).weekIndex - 1)
return readDistrictLeaderboardSnapshots()
.filter((snapshot) => snapshot.districtId === districtId && snapshot.weekIndex <= previousWeekIndex)
.sort((a, b) => b.weekIndex - a.weekIndex)[0] ?? null
}
19 changes: 12 additions & 7 deletions lib/gamification/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export const DISTRICT_CHALLENGES: DistrictChallengeDefinition[] = [

const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const DAY_MS = 24 * 60 * 60 * 1000
const EPOCH_START_MS = Date.UTC(2026, 0, 5, 0, 0, 0, 0)
const EPOCH_START_MS = Date.UTC(2026, 0, 4, 0, 0, 0, 0)

function getWeekBounds(now: Date = new Date()): { weekIndex: number; startedAt: Date; endsAt: Date } {
export function getWeekBounds(now: Date = new Date()): { weekIndex: number; startedAt: Date; endsAt: Date } {
const elapsed = Math.max(0, now.getTime() - EPOCH_START_MS)
const weekIndex = Math.floor(elapsed / WEEK_MS)
const startedAt = new Date(EPOCH_START_MS + weekIndex * WEEK_MS)
Expand All @@ -84,15 +84,15 @@ function seededNoise(seed: string): number {
return (hash >>> 0) / 4294967295
}

function formatScore(challenge: DistrictChallengeDefinition, score: number): string {
export function formatDistrictScore(challenge: DistrictChallengeDefinition, score: number): string {
if (challenge.type === "revenue") return `$${score.toFixed(2)}`
if (challenge.type === "uptime") return `${score.toFixed(1)}%`
if (challenge.type === "speed") return `${score.toFixed(1)}m`
if (challenge.type === "skill") return score.toFixed(2)
return String(Math.round(score))
}

function getAgentScore(agent: MoltbotAgent, challenge: DistrictChallengeDefinition, weekIndex: number): number {
export function getAgentDistrictCompetitionScore(agent: MoltbotAgent, challenge: DistrictChallengeDefinition, weekIndex: number): number {
const noise = seededNoise(`${weekIndex}:${challenge.type}:${agent.id}`)
switch (challenge.type) {
case "throughput":
Expand All @@ -108,6 +108,11 @@ function getAgentScore(agent: MoltbotAgent, challenge: DistrictChallengeDefiniti
}
}

export function getWeeklyTasksCompleted(agent: MoltbotAgent, weekIndex: number): number {
const completionRatio = 0.12 + seededNoise(`${weekIndex}:weekly-tasks:${agent.id}`) * 0.28
return Math.max(0, Math.round(agent.tasksCompleted * completionRatio))
}

export function getActiveDistrictEvent(now: Date = new Date()): ActiveDistrictEvent {
const bounds = getWeekBounds(now)
const challenge = DISTRICT_CHALLENGES[bounds.weekIndex % DISTRICT_CHALLENGES.length]
Expand All @@ -129,7 +134,7 @@ export function getDistrictStandings(agents: MoltbotAgent[], now: Date = new Dat
const event = getActiveDistrictEvent(now)
const standings = DISTRICTS.map((district) => {
const districtAgents = agents.filter((agent) => agent.district === district.id)
const scoredAgents = districtAgents.map((agent) => ({ agent, score: getAgentScore(agent, event.challenge, event.weekIndex) }))
const scoredAgents = districtAgents.map((agent) => ({ agent, score: getAgentDistrictCompetitionScore(agent, event.challenge, event.weekIndex) }))
const score = event.challenge.type === "speed"
? (scoredAgents.reduce((sum, entry) => sum + entry.score, 0) / Math.max(1, scoredAgents.length))
: scoredAgents.reduce((sum, entry) => sum + entry.score, 0) / (event.challenge.type === "uptime" || event.challenge.type === "skill" ? Math.max(1, scoredAgents.length) : 1)
Expand All @@ -143,14 +148,14 @@ export function getDistrictStandings(agents: MoltbotAgent[], now: Date = new Dat
districtName: entry.district.name,
color: entry.district.color,
score: Number(entry.score.toFixed(3)),
formattedScore: formatScore(event.challenge, entry.score),
formattedScore: formatDistrictScore(event.challenge, entry.score),
rank: index + 1,
multiplier: index === 0 ? 2 : index === 1 ? 1.5 : 1,
topAgent: entry.top ? {
id: entry.top.agent.id,
name: entry.top.agent.name,
score: Number(entry.top.score.toFixed(3)),
formattedScore: formatScore(event.challenge, entry.top.score),
formattedScore: formatDistrictScore(event.challenge, entry.top.score),
} : null,
}))
}
Expand Down
Loading