From 40068310ebbff2496dab82dd7f3d4d9d2bab747f Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:29:07 +0100 Subject: [PATCH] Add agent milestone badge system --- __tests__/gamification/badges.test.ts | 52 +++++++ app/agent-functions/[id]/route.ts | 1 - app/agents/[id]/page.tsx | 22 ++- app/api/agents/[id]/badges/route.ts | 24 ++- app/api/agents/[id]/tasks/drain/route.ts | 4 +- lib/agent-runtime/agent.ts | 3 + lib/gamification/badges.ts | 190 +++++++++++++++++++++++ 7 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 __tests__/gamification/badges.test.ts create mode 100644 lib/gamification/badges.ts diff --git a/__tests__/gamification/badges.test.ts b/__tests__/gamification/badges.test.ts new file mode 100644 index 00000000..cc78b551 --- /dev/null +++ b/__tests__/gamification/badges.test.ts @@ -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) + }) +}) diff --git a/app/agent-functions/[id]/route.ts b/app/agent-functions/[id]/route.ts index c6af9230..5d6f37ac 100644 --- a/app/agent-functions/[id]/route.ts +++ b/app/agent-functions/[id]/route.ts @@ -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" } }) } diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index adb81840..935331f8 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next" -import Image from "next/image" import Link from "next/link" import { notFound } from "next/navigation" @@ -84,11 +83,12 @@ export default async function AgentPage({ params }: AgentPageProps) { 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) @@ -131,6 +131,11 @@ export default async function AgentPage({ params }: AgentPageProps) { 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) { @@ -245,8 +250,15 @@ export default async function AgentPage({ params }: AgentPageProps) {
{badges.length > 0 ? badges.map((badge, i) => ( -
- {badge.name} +
+ + {badge.name || badge.badgeId || badge.id}
)) : (
diff --git a/app/api/agents/[id]/badges/route.ts b/app/api/agents/[id]/badges/route.ts index 45e02e19..d7db25b9 100644 --- a/app/api/agents/[id]/badges/route.ts +++ b/app/api/agents/[id]/badges/route.ts @@ -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 }> @@ -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() + + 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, } diff --git a/app/api/agents/[id]/tasks/drain/route.ts b/app/api/agents/[id]/tasks/drain/route.ts index 4b709b9f..8feb3162 100644 --- a/app/api/agents/[id]/tasks/drain/route.ts +++ b/app/api/agents/[id]/tasks/drain/route.ts @@ -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 { @@ -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) }, }) diff --git a/lib/agent-runtime/agent.ts b/lib/agent-runtime/agent.ts index cbb896a5..95204326 100644 --- a/lib/agent-runtime/agent.ts +++ b/lib/agent-runtime/agent.ts @@ -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 @@ -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() diff --git a/lib/gamification/badges.ts b/lib/gamification/badges.ts new file mode 100644 index 00000000..d4b1b577 --- /dev/null +++ b/lib/gamification/badges.ts @@ -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 + taskCompletions: Record +} + +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 + 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()) +}