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
52 changes: 52 additions & 0 deletions __tests__/gamification/badges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, beforeEach } from "vitest"
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { BADGE_DEFINITIONS, checkAndAwardBadges, recordTaskCompletion, resetAgentBadgesForTests } from "@/lib/gamification/badges"

const badgePath = join(process.cwd(), ".data", "agent-badges.json")

describe("gamification badges", () => {
beforeEach(() => {
resetAgentBadgesForTests()
})

it("defines all milestone badges with display metadata and conditions", () => {
expect(BADGE_DEFINITIONS.map((badge) => badge.id)).toEqual([
"first_task",
"speed_runner",
"top_earner",
"quest_master",
"early_adopter",
])

for (const badge of BADGE_DEFINITIONS) {
expect(badge.name).toBeTruthy()
expect(badge.description).toBeTruthy()
expect(badge.iconName).toBeTruthy()
expect(badge.unlockCondition).toBeTruthy()
}
})

it("awards badges idempotently and persists the badge file", () => {
recordTaskCompletion("badge-agent", "2026-06-30T00:00:00.000Z")

const first = checkAndAwardBadges("badge-agent", new Date("2026-06-30T00:01:00.000Z"))
const second = checkAndAwardBadges("badge-agent", new Date("2026-06-30T00:02:00.000Z"))

expect(first.some((badge) => badge.badgeId === "first_task")).toBe(true)
expect(second.some((badge) => badge.badgeId === "first_task")).toBe(false)
expect(existsSync(badgePath)).toBe(true)

const persisted = JSON.parse(readFileSync(badgePath, "utf8"))
expect(persisted.agentBadges["badge-agent"].filter((badge: { badgeId: string }) => badge.badgeId === "first_task")).toHaveLength(1)
})

it("awards speed runner for 10 tasks inside one hour", () => {
for (let i = 0; i < 10; i += 1) {
recordTaskCompletion("fast-agent", new Date(Date.UTC(2026, 5, 30, 12, i, 0)).toISOString())
}

const awarded = checkAndAwardBadges("fast-agent", new Date("2026-06-30T13:00:00.000Z"))
expect(awarded.some((badge) => badge.badgeId === "speed_runner")).toBe(true)
})
})
1 change: 0 additions & 1 deletion app/agent-functions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export async function POST(req: Request, context: RouteContext) {
updateCloudAgentResult(config.id, summary)
recordAgentHeartbeat(config.id, { status: "active", cpu: 8, memory: 24, currentTask: summary, autoRestart: true })
publishSystemEvent({ type: "task.completed", agentId: config.id, taskId, result: { summary, durationMs: Date.now() - started } })

return NextResponse.json({ ok: true, agentId: config.id, taskId, result: { summary } }, { headers: { "Cache-Control": "no-store" } })
}

Expand Down
22 changes: 17 additions & 5 deletions app/agents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { notFound } from "next/navigation"

Expand Down Expand Up @@ -84,11 +83,12 @@
const { id } = await params

// Data loading as required by acceptance criteria
const [metaRes, healthRes, repRes, questRes] = await Promise.all([
const [metaRes, healthRes, repRes, questRes, badgeRes] = await Promise.all([
fetch(absoluteUrl(`/api/agents/${id}`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/agents/${id}/health`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/protocol/reputation?actorId=${id}`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' })
fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/agents/${id}/badges`), { cache: 'no-store' })
])

const localAgent = findAgentByLookup(id)
Expand Down Expand Up @@ -131,6 +131,11 @@
infractions = data.reputation?.history?.filter((h: any) => h.delta < 0).length || 0
}

if (badgeRes.ok) {
const data = await badgeRes.json()
badges = data.badges || []
}

// Parse Quests
let quests: any[] = []
if (questRes.ok) {
Expand Down Expand Up @@ -244,9 +249,16 @@
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{badges.length > 0 ? badges.map((badge, i) => (

Check failure on line 252 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0q&open=AZ8ZLdcQYWu_9P1nUO0q&pullRequest=416
<div key={i} className={`flex flex-col items-center justify-center p-3 rounded-lg border ${badge.rarity === 'legendary' ? 'border-purple-500/50 bg-purple-500/10 text-purple-300' : badge.rarity === 'rare' ? 'border-blue-500/50 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-800/50 text-slate-300'}`}>
<span className="font-pixel text-xs text-center leading-tight">{badge.name}</span>
<div
key={badge.badgeId || badge.id || i}
title={`${badge.name || badge.badgeId || badge.id}: ${badge.description || "Badge earned"}`}
className={`group flex flex-col items-center justify-center gap-2 rounded-lg border p-3 transition hover:-translate-y-0.5 ${badge.rarity === 'legendary' ? 'border-purple-500/50 bg-purple-500/10 text-purple-300' : badge.rarity === 'epic' ? 'border-fuchsia-500/50 bg-fuchsia-500/10 text-fuchsia-300' : badge.rarity === 'rare' ? 'border-blue-500/50 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-800/50 text-slate-300'}`}

Check warning on line 256 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0s&open=AZ8ZLdcQYWu_9P1nUO0s&pullRequest=416

Check warning on line 256 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0r&open=AZ8ZLdcQYWu_9P1nUO0r&pullRequest=416
>
<span aria-hidden="true" className="flex h-10 w-10 items-center justify-center rounded-full border border-current/30 bg-black/20 text-xl shadow-inner">
{badge.iconName === 'zap' ? '⚡' : badge.iconName === 'trophy' ? '🏆' : badge.iconName === 'scroll-text' ? '📜' : badge.iconName === 'sparkles' ? '✨' : '✅'}

Check warning on line 259 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0t&open=AZ8ZLdcQYWu_9P1nUO0t&pullRequest=416

Check warning on line 259 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0v&open=AZ8ZLdcQYWu_9P1nUO0v&pullRequest=416

Check warning on line 259 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZLdcQYWu_9P1nUO0u&open=AZ8ZLdcQYWu_9P1nUO0u&pullRequest=416
</span>
<span className="font-pixel text-xs text-center leading-tight">{badge.name || badge.badgeId || badge.id}</span>
</div>
)) : (
<div className="col-span-2 sm:col-span-3 text-center p-4">
Expand Down
24 changes: 20 additions & 4 deletions app/api/agents/[id]/badges/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { getRegisteredAgent } from "@/lib/agent-registry"
import { getReputation } from "@/lib/reputation/reputation-store"
import { getBadgeCatalogEntry, BADGE_RARITY_VALUES, type BadgeRarity } from "@/lib/gamification/badge-catalog"
import { getBadgeDefinition, listEarnedBadges } from "@/lib/gamification/badges"

interface RouteContext {
params: Promise<{ id: string }>
Expand All @@ -25,14 +26,29 @@ export async function GET(req: Request, context: RouteContext) {
}

const { metrics } = getReputation(agentId)
let badges = (metrics.badges ?? [])
const storedBadges = listEarnedBadges(agentId).map((b) => ({ id: b.badgeId, awardedAt: b.earnedAt }))
const merged = new Map<string, { id: string; awardedAt: string; rarity?: BadgeRarity }>()

for (const b of metrics.badges ?? []) {
merged.set(b.id, { id: b.id, awardedAt: b.awardedAt, rarity: b.rarity as BadgeRarity })
}
for (const b of storedBadges) {
const existing = merged.get(b.id)
if (!existing || new Date(b.awardedAt).getTime() > new Date(existing.awardedAt).getTime()) {
merged.set(b.id, b)
}
}

let badges = Array.from(merged.values())
.map((b) => {
const definition = getBadgeDefinition(b.id)
const catalog = getBadgeCatalogEntry(b.id)
return {
badgeId: b.id,
name: catalog?.name ?? b.id,
description: catalog?.description ?? "",
rarity: b.rarity as BadgeRarity,
name: definition?.name ?? catalog?.name ?? b.id,
description: definition?.description ?? catalog?.description ?? "",
iconName: definition?.iconName ?? "award",
rarity: b.rarity ?? definition?.rarity ?? "common",
earnedAt: b.awardedAt,
xpValue: catalog?.xpValue ?? 0,
}
Expand Down
4 changes: 3 additions & 1 deletion app/api/agents/[id]/tasks/drain/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import { drainAgentTasks } from "@/lib/agents/task-queue"
import { publishSystemEvent } from "@/lib/events/system-events"
import { checkAndAwardBadges, recordTaskCompletion } from "@/lib/gamification/badges"
import { createApiRouteLogger } from "@/lib/api-logging"

interface RouteContext {
Expand All @@ -27,6 +27,8 @@ export async function POST(req: Request, context: RouteContext) {
taskId: task.id,
result: { summary: `Completed task of type ${task.type}` },
})
recordTaskCompletion(task.agentId)
checkAndAwardBadges(task.agentId)
},
})

Expand Down
3 changes: 3 additions & 0 deletions lib/agent-runtime/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { recordAgentHeartbeat } from "@/lib/agents/agent-health-store"
import { sendAgentMessage } from "@/lib/agent-runtime/messaging"
import type { AgentConfig, AgentMetrics, AgentRuntimeContext, AgentMessage, MessageHandler, Task, TaskHandler, TaskResult } from "@/lib/agent-runtime/types"
import { publishSystemEvent } from "@/lib/events/system-events"
import { checkAndAwardBadges, recordTaskCompletion } from "@/lib/gamification/badges"
import type { AgentStatus } from "@/lib/types"

const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
Expand Down Expand Up @@ -140,6 +141,8 @@ export class Agent implements AgentRuntimeContext {
this.recordHeartbeat()
writeTaskRecord(this.id, { task, result, status: "completed", updatedAt: completedAt })
publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.summary, durationMs } })
recordTaskCompletion(this.id, completedAt)
checkAndAwardBadges(this.id)
return result
} catch (error) {
const completedAt = isoNow()
Expand Down
190 changes: 190 additions & 0 deletions lib/gamification/badges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { listAgentHealth } from "@/lib/agents/agent-health-store"
import { listRegisteredAgents } from "@/lib/agent-registry"
import { getAgentXP } from "@/lib/gamification/xp"
import { getAgentQuestStats } from "@/lib/gamification/quest-leaderboard"
import { getReputation, upsertReputationMetrics } from "@/lib/reputation/reputation-store"
import { publishSystemEvent } from "@/lib/events/system-events"
import type { BadgeRarity } from "@/lib/gamification/badge-catalog"

export type BadgeId = "first_task" | "speed_runner" | "top_earner" | "quest_master" | "early_adopter"

export interface BadgeDefinition {
id: BadgeId
name: string
description: string
iconName: string
rarity: BadgeRarity
unlockCondition: string
}

export interface EarnedBadge {
badgeId: BadgeId
earnedAt: string
}

interface BadgeStoreState {
agentBadges: Record<string, EarnedBadge[]>
taskCompletions: Record<string, string[]>
}

const BADGES_PATH = join(process.cwd(), ".data", "agent-badges.json")
const ONE_HOUR_MS = 60 * 60 * 1000

export const BADGE_DEFINITIONS: BadgeDefinition[] = [
{
id: "first_task",
name: "First Task",
description: "Complete your first task.",
iconName: "check-circle",
rarity: "common",
unlockCondition: "Complete at least one task.",
},
{
id: "speed_runner",
name: "Speed Runner",
description: "Complete 10 tasks in under 1 hour.",
iconName: "zap",
rarity: "rare",
unlockCondition: "Complete 10 recorded tasks within any rolling 60 minute window.",
},
{
id: "top_earner",
name: "Top Earner",
description: "Reach top 10 on the leaderboard.",
iconName: "trophy",
rarity: "epic",
unlockCondition: "Place in the top 10 agents by XP leaderboard rank.",
},
{
id: "quest_master",
name: "Quest Master",
description: "Complete 5 quests in a single week.",
iconName: "scroll-text",
rarity: "rare",
unlockCondition: "Complete at least 5 quests in the current weekly quest window.",
},
{
id: "early_adopter",
name: "Early Adopter",
description: "One of the first 50 registered agents.",
iconName: "sparkles",
rarity: "legendary",
unlockCondition: "Be among the first 50 agents in registry registration order.",
},
]

const definitionIndex = new Map(BADGE_DEFINITIONS.map((badge) => [badge.id, badge]))

function emptyState(): BadgeStoreState {
return { agentBadges: {}, taskCompletions: {} }
}

function readState(): BadgeStoreState {
if (!existsSync(BADGES_PATH)) return emptyState()
try {
const parsed = JSON.parse(readFileSync(BADGES_PATH, "utf8")) as Partial<BadgeStoreState>
return {
agentBadges: parsed.agentBadges && typeof parsed.agentBadges === "object" ? parsed.agentBadges : {},
taskCompletions: parsed.taskCompletions && typeof parsed.taskCompletions === "object" ? parsed.taskCompletions : {},
}
} catch {
return emptyState()
}
}

function writeState(state: BadgeStoreState): void {
mkdirSync(dirname(BADGES_PATH), { recursive: true })
const tmp = `${BADGES_PATH}.tmp`
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8")
renameSync(tmp, BADGES_PATH)
}

function rollingWindowHasTenTasks(completedAtValues: string[]): boolean {
const times = completedAtValues.map((value) => new Date(value).getTime()).filter(Number.isFinite).sort((a, b) => a - b)
for (let i = 0; i < times.length; i += 1) {
const tenth = times[i + 9]
if (tenth !== undefined && tenth - times[i] <= ONE_HOUR_MS) return true
}
return false
}

function getXpLeaderboardRank(agentId: string): number | null {
const agents = listAgentHealth()
.map((health) => ({ agentId: health.agentId, xp: getAgentXP(health.agentId).xp }))
.sort((a, b) => b.xp - a.xp || a.agentId.localeCompare(b.agentId))

const index = agents.findIndex((agent) => agent.agentId === agentId)
return index === -1 ? null : index + 1
}

function isEarlyAdopter(agentId: string): boolean {
return listRegisteredAgents()
.sort((a, b) => a.registeredAt.localeCompare(b.registeredAt) || a.agentId.localeCompare(b.agentId))
.slice(0, 50)
.some((agent) => agent.agentId === agentId)
}

function shouldAwardBadge(agentId: string, badgeId: BadgeId, state: BadgeStoreState): boolean {
if (badgeId === "first_task") return (state.taskCompletions[agentId]?.length ?? 0) >= 1
if (badgeId === "speed_runner") return rollingWindowHasTenTasks(state.taskCompletions[agentId] ?? [])
if (badgeId === "top_earner") return (getXpLeaderboardRank(agentId) ?? Number.POSITIVE_INFINITY) <= 10
if (badgeId === "quest_master") return getAgentQuestStats(agentId, "weekly").questsCompleted >= 5
if (badgeId === "early_adopter") return isEarlyAdopter(agentId)
return false
}

function mirrorBadgeToReputation(agentId: string, badge: EarnedBadge): void {
const definition = definitionIndex.get(badge.badgeId)
const current = getReputation(agentId)
if (current.metrics.badges.some((entry) => entry.id === badge.badgeId)) return
upsertReputationMetrics(agentId, {
...current.metrics,
badges: [...current.metrics.badges, { id: badge.badgeId, rarity: definition?.rarity ?? "common", awardedAt: badge.earnedAt }],
})
}

export function getBadgeDefinition(badgeId: string): BadgeDefinition | undefined {
return definitionIndex.get(badgeId as BadgeId)
}

export function listEarnedBadges(agentId: string): EarnedBadge[] {
return [...(readState().agentBadges[agentId] ?? [])].sort(
(a, b) => new Date(b.earnedAt).getTime() - new Date(a.earnedAt).getTime(),
)
}

export function recordTaskCompletion(agentId: string, completedAt = new Date().toISOString()): void {
const state = readState()
const taskCompletions = state.taskCompletions[agentId] ?? []
state.taskCompletions[agentId] = [...taskCompletions, completedAt]
writeState(state)
}

export function checkAndAwardBadges(agentId: string, now = new Date()): EarnedBadge[] {
const state = readState()
const existing = new Set((state.agentBadges[agentId] ?? []).map((badge) => badge.badgeId))
const newlyAwarded: EarnedBadge[] = []

for (const definition of BADGE_DEFINITIONS) {
if (existing.has(definition.id) || !shouldAwardBadge(agentId, definition.id, state)) continue
const earned: EarnedBadge = { badgeId: definition.id, earnedAt: now.toISOString() }
state.agentBadges[agentId] = [...(state.agentBadges[agentId] ?? []), earned]
existing.add(definition.id)
newlyAwarded.push(earned)
mirrorBadgeToReputation(agentId, earned)
publishSystemEvent({
type: "badge.unlocked",
agentId,
badge: { id: definition.id, name: definition.name, rarity: definition.rarity },
})
}

if (newlyAwarded.length > 0) writeState(state)
return newlyAwarded
}

export function resetAgentBadgesForTests(): void {
writeState(emptyState())
}
Loading