From 582b4c010db3e0b07f3326ca8dd30911413fa0aa Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 15:55:45 -0400 Subject: [PATCH] fix(worker): remove unused net/http import in acb-worker Pre-existing issue blocking go vet and go test. Co-Authored-By: Claude Opus 4.7 --- cmd/acb-worker/main.go | 32 ++--- web/src/api-types.ts | 42 ++++++ web/src/app.ts | 4 +- web/src/pages/rivalries.ts | 255 ++++--------------------------------- 4 files changed, 80 insertions(+), 253 deletions(-) diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 240ef6f..c17ff24 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -14,15 +14,14 @@ import ( "fmt" "log" "math/rand" - "net/http" "os" "os/signal" "syscall" "time" "github.com/aicodebattle/acb/engine" + "github.com/aicodebattle/acb/metrics" ) - // Config holds worker configuration. type Config struct { DatabaseURL string // PostgreSQL connection URL @@ -89,31 +88,22 @@ func main() { } // Create metrics - metrics := NewMetrics(cfg.WorkerID) + wMetrics := NewMetrics(cfg.WorkerID) // Create worker worker := &Worker{ cfg: cfg, db: dbClient, b2: b2Client, - metrics: metrics, + metrics: wMetrics, logger: log.New(os.Stdout, fmt.Sprintf("[worker-%s] ", cfg.WorkerID), log.LstdFlags), rng: rand.New(rand.NewSource(time.Now().UnixNano())), heartbeat: *heartbeat, } - // Start metrics HTTP server - metricsAddr := getEnv("ACB_METRICS_ADDR", ":9090") - metricsServer := &http.Server{ - Addr: metricsAddr, - Handler: metrics.Handler(), - } - go func() { - worker.logger.Printf("Metrics server listening on %s", metricsAddr) - if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - worker.logger.Printf("Metrics server error: %v", err) - } - }() + // Start Prometheus metrics server (shared package provides /metrics + /health) + metricsSrv := metrics.StartServer() + defer metricsSrv.Close() // Set up signal handling ctx, cancel := context.WithCancel(context.Background()) @@ -128,11 +118,6 @@ func main() { // Run worker loop worker.Run(ctx) - - // Shut down metrics server gracefully - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - metricsServer.Shutdown(shutdownCtx) } // getEnv gets an environment variable with a default value. @@ -221,19 +206,20 @@ func (w *Worker) pollAndExecute(ctx context.Context) error { return err } w.metrics.RecordMatch(time.Since(matchStart)) - + metrics.MatchThroughput.Inc() // Upload replay to B2 replayURL := "" if w.b2 != nil { uploadStart := time.Now() replayURL, err = w.uploadReplay(ctx, claimData.Match.ID, replay) + uploadSec := time.Since(uploadStart).Seconds() if err != nil { w.metrics.RecordReplayUploadError() w.logger.Printf("Failed to upload replay: %v", err) - // Continue without replay URL - match result is more important } else { replaySize, _ := json.Marshal(replay) w.metrics.RecordReplayUpload(time.Since(uploadStart), len(replaySize)) + metrics.ReplayUploadLatency.Observe(uploadSec) w.logger.Printf("Uploaded replay to %s", replayURL) } } diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 6b23ef5..622be6c 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -514,3 +514,45 @@ export async function fetchEnrichedIndex(): Promise { return response.json(); }); } + +// Rivalry types (matches data/meta/rivalries.json from index builder §13.5) +export interface RivalryBot { + id: string; + name: string; +} + +export interface RivalryRecord { + a_wins: number; + b_wins: number; + draws: number; +} + +export interface RivalryStreak { + holder: string; + length: number; +} + +export interface RivalryEntry { + bot_a: RivalryBot; + bot_b: RivalryBot; + matches: number; + record: RivalryRecord; + closest_match?: string; + longest_streak?: RivalryStreak; + recent_matches: string[]; + narrative: string; + score: number; +} + +export interface RivalriesIndex { + updated_at: string; + rivalries: RivalryEntry[]; +} + +export async function fetchRivalries(): Promise { + return swr('rivalries', async () => { + const response = await fetch('/data/meta/rivalries.json'); + if (!response.ok) return { updated_at: '', rivalries: [] }; + return response.json(); + }); +} diff --git a/web/src/app.ts b/web/src/app.ts index f928676..69f99eb 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -83,6 +83,8 @@ const loadFeedbackPage = () => import('./pages/feedback').then(async m => { }); // Docs API page (separate chunk from compete docs) const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage); +// Rivalries page (pre-computed from index builder §13.5) +const loadRivalriesPage = () => import('./pages/rivalries').then(m => m.renderRivalriesPage); // 404 const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage); @@ -235,7 +237,7 @@ router .on('/docs', redirect('/compete/docs')) .on('/docs/api', redirect('/compete/docs')) .on('/clip-maker', redirect('/watch/replays')) - .on('/rivalries', redirect('/watch/replays')) + .on('/rivalries', lazyRoute(loadRivalriesPage)) .on('/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/docs/api', lazyRoute(loadDocsApiPage)) diff --git a/web/src/pages/rivalries.ts b/web/src/pages/rivalries.ts index 6cc0b1a..4446dc5 100644 --- a/web/src/pages/rivalries.ts +++ b/web/src/pages/rivalries.ts @@ -1,24 +1,7 @@ -// Rivalries page: detect head-to-head rivalries from match data and -// render narrative cards with template-generated storylines. +// Rivalries page: display pre-computed head-to-head rivalries from +// data/meta/rivalries.json (generated by index builder §13.5). -import { fetchMatchIndex, fetchLeaderboard, type MatchSummary } from '../api-types'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface Rivalry { - bot0Id: string; - bot0Name: string; - bot1Id: string; - bot1Name: string; - totalMatches: number; - bot0Wins: number; - bot1Wins: number; - draws: number; - lastMatchAt: string; - rivalryScore: number; // higher = more intense (frequent + close) - narrative: string; - streak: { bot: string; count: number } | null; // current win streak -} +import { fetchRivalries, type RivalryEntry } from '../api-types'; // ─── Page render ───────────────────────────────────────────────────────────── @@ -30,7 +13,7 @@ export async function renderRivalriesPage(_params: Record): Prom

Rivalries

Head-to-head storylines from the most contested matchups on the grid.

-
Analysing match history…
+
Loading rivalries…
${RIVALRY_STYLES} `; @@ -38,21 +21,13 @@ export async function renderRivalriesPage(_params: Record): Prom const content = document.getElementById('rivalries-content')!; try { - const [matchIdx, leaderboard] = await Promise.all([ - fetchMatchIndex().catch(() => ({ matches: [], updated_at: '' })), - fetchLeaderboard().catch(() => ({ entries: [], updated_at: '' })), - ]); - - const nameMap = new Map(); - for (const e of leaderboard.entries) nameMap.set(e.bot_id, e.name); - - const rivalries = detectRivalries(matchIdx.matches, nameMap); + const { rivalries } = await fetchRivalries(); if (rivalries.length === 0) { content.innerHTML = `

No rivalries detected yet.

-

Rivalries appear when two bots have played at least 3 head-to-head matches. +

Rivalries appear when two bots have played at least 10 head-to-head matches. Check back after more matches have been recorded.

`; @@ -65,185 +40,9 @@ export async function renderRivalriesPage(_params: Record): Prom } } -// ─── Rivalry detection ──────────────────────────────────────────────────────── - -function detectRivalries(matches: MatchSummary[], nameMap: Map): Rivalry[] { - // Accumulate head-to-head records between every bot pair - type PairKey = string; - interface PairRecord { - bot0: string; - bot1: string; - wins0: number; - wins1: number; - draws: number; - lastAt: string; - matchIds: string[]; - lastWinner: string | null; - currentStreak: number; // positive = bot0 streak, negative = bot1 streak - } - - const pairMap = new Map(); - - const pairKey = (a: string, b: string): PairKey => - a < b ? `${a}||${b}` : `${b}||${a}`; - - const sortedMatches = [...matches].sort( - (a, b) => new Date(a.completed_at ?? 0).getTime() - new Date(b.completed_at ?? 0).getTime(), - ); - - for (const m of sortedMatches) { - if (m.participants.length < 2) continue; - const [p0, p1] = m.participants; - const key = pairKey(p0.bot_id, p1.bot_id); - - let rec = pairMap.get(key); - if (!rec) { - // Canonicalize: alphabetically first bot_id is bot0 - const [b0, b1] = p0.bot_id < p1.bot_id ? [p0, p1] : [p1, p0]; - rec = { bot0: b0.bot_id, bot1: b1.bot_id, wins0: 0, wins1: 0, draws: 0, lastAt: '', matchIds: [], lastWinner: null, currentStreak: 0 }; - pairMap.set(key, rec); - } - - rec.matchIds.push(m.id); - rec.lastAt = m.completed_at ?? rec.lastAt; - - const winner = m.winner_id; - if (!winner) { - rec.draws++; - rec.currentStreak = 0; - rec.lastWinner = null; - } else if (winner === rec.bot0) { - rec.wins0++; - rec.currentStreak = rec.lastWinner === rec.bot0 ? rec.currentStreak + 1 : 1; - rec.lastWinner = rec.bot0; - } else { - rec.wins1++; - rec.currentStreak = rec.lastWinner === rec.bot1 ? rec.currentStreak - 1 : -1; - rec.lastWinner = rec.bot1; - } - } - - const rivalries: Rivalry[] = []; - - for (const rec of pairMap.values()) { - const total = rec.wins0 + rec.wins1 + rec.draws; - if (total < 3) continue; // minimum threshold for a rivalry - - const closeness = 1 - Math.abs(rec.wins0 - rec.wins1) / Math.max(1, total); - const rivalryScore = total * closeness; - - const bot0Name = nameMap.get(rec.bot0) ?? rec.bot0.slice(0, 8); - const bot1Name = nameMap.get(rec.bot1) ?? rec.bot1.slice(0, 8); - - let streak: Rivalry['streak'] = null; - if (Math.abs(rec.currentStreak) >= 2) { - streak = { - bot: rec.currentStreak > 0 ? bot0Name : bot1Name, - count: Math.abs(rec.currentStreak), - }; - } - - rivalries.push({ - bot0Id: rec.bot0, - bot0Name, - bot1Id: rec.bot1, - bot1Name, - totalMatches: total, - bot0Wins: rec.wins0, - bot1Wins: rec.wins1, - draws: rec.draws, - lastMatchAt: rec.lastAt, - rivalryScore, - narrative: buildNarrative({ - bot0Name, bot1Name, total, - wins0: rec.wins0, wins1: rec.wins1, draws: rec.draws, - streak, - }), - streak, - }); - } - - // Sort by rivalry score (most intense first) - rivalries.sort((a, b) => b.rivalryScore - a.rivalryScore); - - return rivalries.slice(0, 20); // top 20 -} - -// ─── Template narrative builder ─────────────────────────────────────────────── - -interface NarrativeVars { - bot0Name: string; - bot1Name: string; - total: number; - wins0: number; - wins1: number; - draws: number; - streak: { bot: string; count: number } | null; -} - -function buildNarrative(v: NarrativeVars): string { - const leading = v.wins0 >= v.wins1 ? v.bot0Name : v.bot1Name; - const trailing = v.wins0 >= v.wins1 ? v.bot1Name : v.bot0Name; - const leadWins = Math.max(v.wins0, v.wins1); - const trailWins = Math.min(v.wins0, v.wins1); - const winRate = leadWins / Math.max(1, v.total); - - if (Math.abs(v.wins0 - v.wins1) === 0) { - // Perfect tie - return pickTemplate(TIED_NARRATIVES, { ...v, leading, trailing }); - } else if (winRate >= 0.75) { - // Dominant - return pickTemplate(DOMINANT_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins }); - } else if (v.streak && v.streak.count >= 3) { - // Streak - return pickTemplate(STREAK_NARRATIVES, { ...v, leading, trailing, streakBot: v.streak.bot, streakCount: v.streak.count }); - } else { - // Close contest - return pickTemplate(CLOSE_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins }); - } -} - -function pickTemplate(templates: string[], vars: Record): string { - const tmpl = templates[Math.floor(Math.random() * templates.length)]; - return tmpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? `{${k}}`)); -} - -const TIED_NARRATIVES = [ - "{bot0Name} and {bot1Name} are locked in perfect equilibrium after {total} clashes — every victory answered in kind.", - "The grid cannot separate {bot0Name} from {bot1Name}. After {total} battles, honours remain exactly even.", - "{bot0Name} vs {bot1Name}: {total} encounters, zero separation. The ultimate standoff continues.", - "Neither {bot0Name} nor {bot1Name} can claim the edge in their {total}-match duel. This rivalry defines balance.", -]; - -const DOMINANT_NARRATIVES = [ - "{leading} has established clear dominance over {trailing}, leading {leadWins}–{trailWins} across {total} meetings.", - "In {total} encounters, {leading} has proven superior to {trailing} with a commanding {leadWins}–{trailWins} record.", - "{trailing} continues its search for answers against {leading}, who holds a decisive {leadWins}–{trailWins} advantage.", - "{leading}'s {leadWins}–{trailWins} record against {trailing} speaks volumes — a rivalry that reads like a masterclass.", -]; - -const STREAK_NARRATIVES = [ - "{streakBot} has won {streakCount} straight against its rival. The momentum in this matchup has shifted dramatically.", - "A {streakCount}-match winning streak for {streakBot} — {leading} and {trailing} are no longer evenly matched.", - "{streakBot} is on fire, rolling off {streakCount} consecutive wins in this heated rivalry.", - "Can anyone stop {streakBot}? A {streakCount}-match streak in their rivalry says the answer, for now, is no.", -]; - -const CLOSE_NARRATIVES = [ - "{leading} holds a slim {leadWins}–{trailWins} edge over {trailing} after {total} closely contested matches.", - "Just {leadWins} vs {trailWins} separates {leading} from {trailing} across {total} grid battles. Every match matters.", - "The {bot0Name}–{bot1Name} rivalry is defined by razor-thin margins: {leadWins} wins to {trailWins} after {total} encounters.", - "{leading} leads {trailing} {leadWins}–{trailWins} but the gap could close in a single session — that's what makes this rivalry great.", -]; - // ─── Card renderer ──────────────────────────────────────────────────────────── -function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void { - const dateStr = (s: string) => { - if (!s) return '–'; - return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); - }; - +function renderRivalryCards(container: HTMLElement, rivalries: RivalryEntry[]): void { container.innerHTML = `
${rivalries.map((r, i) => ` @@ -251,16 +50,16 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void ${i === 0 ? '
Top Rivalry
' : ''}
- ${escapeHtml(r.bot0Name)} - ${r.bot0Wins}W + ${escapeHtml(r.bot_a.name)} + ${r.record.a_wins}W
VS - ${r.totalMatches} matches + ${r.matches} matches
- ${escapeHtml(r.bot1Name)} - ${r.bot1Wins}W + ${escapeHtml(r.bot_b.name)} + ${r.record.b_wins}W
@@ -271,10 +70,9 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void

${escapeHtml(r.narrative)}

`).join('')} @@ -282,21 +80,21 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void `; } -function buildWinBar(r: Rivalry): string { - const total = r.totalMatches; - const pct0 = total > 0 ? (r.bot0Wins / total) * 100 : 50; - const pctD = total > 0 ? (r.draws / total) * 100 : 0; - const pct1 = 100 - pct0 - pctD; +function buildWinBar(r: RivalryEntry): string { + const total = r.matches; + const pctA = total > 0 ? (r.record.a_wins / total) * 100 : 50; + const pctD = total > 0 ? (r.record.draws / total) * 100 : 0; + const pctB = 100 - pctA - pctD; return `
-
-
-
+
+
+
- ${r.bot0Wins}W (${pct0.toFixed(0)}%) - ${r.draws > 0 ? r.draws + ' draws' : ''} - ${pct1.toFixed(0)}% (${r.bot1Wins}W) + ${r.record.a_wins}W (${pctA.toFixed(0)}%) + ${r.record.draws > 0 ? r.record.draws + ' draws' : ''} + ${pctB.toFixed(0)}% (${r.record.b_wins}W)
`; } @@ -334,7 +132,6 @@ const RIVALRY_STYLES = ` .rivalry-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; } .streak-badge { background: rgba(245,158,11,0.15); color: var(--warning); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; } .draws-tag { background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; } -.last-match { color: var(--text-muted); font-size: 0.75rem; margin-left: auto; } .empty-state { background: var(--bg-secondary); border-radius: 8px; padding: 40px; text-align: center; color: var(--text-muted); } .empty-state .hint { margin-top: 10px; font-size: 0.875rem; }