ai-code-battle/cmd/acb-index-builder/cards.go
jedarden 21308dce05 Implement S3 functions for R2/B2 integration in acb-index-builder
- Add s3.go with AWS SDK v2 S3Client wrapper for R2/B2 operations
- Implement listObjects, deleteObject, objectExists, uploadFile, copyObject, downloadObject
- Add s3_test.go with MockS3Client and comprehensive tests
- Wire promoteRecentReplaysForCycle() into build cycle in main.go
- Add fetchRecentMatchIDs() to query recent matches from PostgreSQL
- Add fetchExemptMatchIDs() to protect series/season/playlist matches from pruning
- Implement pruneR2CacheWithDB() for 10GB cap enforcement with exemptions
- Update go.mod with AWS SDK v2 dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 09:09:29 -04:00

477 lines
13 KiB
Go

package main
import (
"context"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog"
"os"
"path/filepath"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// CardConfig holds configuration for card generation
type CardConfig struct {
Width int
Height int
}
// DefaultCardConfig is the default card size (1200x630 for Open Graph)
var DefaultCardConfig = CardConfig{
Width: 1200,
Height: 630,
}
// BotCard represents the data needed to render a bot profile card
type BotCard struct {
BotID string
Name string
Rating int
WinRate float64
MatchesPlayed int
Wins int
Losses int
Rank int
Evolved bool
Island string
Generation int
HealthStatus string
}
// generateBotCard creates a PNG profile card for a bot
func generateBotCard(bot BotCard, cfg CardConfig) (*image.RGBA, error) {
// Create image with dark background
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
// Fill with dark background
bgColor := color.RGBA{R: 18, G: 18, B: 24, A: 255} // #121218
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
// Add gradient overlay at top
gradientColor := color.RGBA{R: 30, G: 30, B: 45, A: 255}
for y := 0; y < 200; y++ {
alpha := byte(255 - (y * 255 / 200))
overlay := color.RGBA{R: gradientColor.R, G: gradientColor.G, B: gradientColor.B, A: alpha}
for x := 0; x < cfg.Width; x++ {
img.Set(x, y, blendColors(img.At(x, y), overlay))
}
}
// Draw accent bar at top
accentColor := getAccentColor(bot.Evolved, bot.HealthStatus)
for x := 0; x < cfg.Width; x++ {
for y := 0; y < 8; y++ {
img.Set(x, y, accentColor)
}
}
// Draw bot name (large text)
nameY := 100
drawText(img, bot.Name, 60, nameY, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 3.0)
// Draw bot ID (smaller, muted)
idY := nameY + 70
drawText(img, "ID: "+bot.BotID, 60, idY, color.RGBA{R: 128, G: 128, B: 128, A: 255}, 1.5)
// Draw stats in a row
statsY := 280
statColor := color.RGBA{R: 200, G: 200, B: 200, A: 255}
labelColor := color.RGBA{R: 128, G: 128, B: 128, A: 255}
// Rating
drawText(img, fmt.Sprintf("%d", bot.Rating), 60, statsY, getColorForRating(bot.Rating), 2.5)
drawText(img, "RATING", 60, statsY+45, labelColor, 1.0)
// Win Rate
winRateStr := fmt.Sprintf("%.1f%%", bot.WinRate)
drawText(img, winRateStr, 300, statsY, getWinRateColor(bot.WinRate), 2.5)
drawText(img, "WIN RATE", 300, statsY+45, labelColor, 1.0)
// Matches
drawText(img, fmt.Sprintf("%d", bot.MatchesPlayed), 540, statsY, statColor, 2.5)
drawText(img, "MATCHES", 540, statsY+45, labelColor, 1.0)
// W/L Record
drawText(img, fmt.Sprintf("%dW / %dL", bot.Wins, bot.Losses), 780, statsY, statColor, 2.5)
drawText(img, "RECORD", 780, statsY+45, labelColor, 1.0)
// Draw rank badge if in top 100
if bot.Rank > 0 && bot.Rank <= 100 {
badgeX := 1000
badgeY := 100
badgeColor := getRankBadgeColor(bot.Rank)
drawCircle(img, badgeX, badgeY, 50, badgeColor)
drawText(img, fmt.Sprintf("#%d", bot.Rank), badgeX-30, badgeY+10, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 1.5)
}
// Draw evolved badge if applicable
if bot.Evolved {
badgeY := 380
evolvedColor := color.RGBA{R: 138, G: 43, B: 226, A: 255} // purple
drawRoundedRect(img, 60, badgeY, 200, 40, 8, evolvedColor)
evolvedText := "EVOLVED"
if bot.Island != "" {
evolvedText = fmt.Sprintf("EVOLVED · %s", bot.Island)
}
drawText(img, evolvedText, 70, badgeY+28, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 1.0)
}
// Draw footer with branding
footerY := cfg.Height - 50
drawText(img, "AI Code Battle", 60, footerY, color.RGBA{R: 80, G: 80, B: 80, A: 255}, 1.2)
return img, nil
}
// generateAllBotCards generates PNG cards for all bots and saves them to the output directory
func generateAllBotCards(data *IndexData, outputDir string) error {
cardsDir := filepath.Join(outputDir, "cards")
if err := os.MkdirAll(cardsDir, 0755); err != nil {
return fmt.Errorf("create cards directory: %w", err)
}
cfg := DefaultCardConfig
for i, bot := range data.Bots {
winRate := 0.0
losses := 0
if bot.MatchesPlayed > 0 {
winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100
losses = bot.MatchesPlayed - bot.MatchesWon
}
card := BotCard{
BotID: bot.ID,
Name: bot.Name,
Rating: int(bot.Rating),
WinRate: winRate,
MatchesPlayed: bot.MatchesPlayed,
Wins: bot.MatchesWon,
Losses: losses,
Rank: i + 1, // Rank is position in sorted list
Evolved: bot.Evolved,
Island: bot.Island,
Generation: bot.Generation,
HealthStatus: bot.HealthStatus,
}
img, err := generateBotCard(card, cfg)
if err != nil {
slog.Error("Failed to generate bot card", "bot_id", bot.ID, "error", err)
continue
}
// Save to file
cardPath := filepath.Join(cardsDir, bot.ID+".png")
if err := savePNG(cardPath, img); err != nil {
slog.Error("Failed to save bot card", "bot_id", bot.ID, "error", err)
continue
}
slog.Debug("Generated bot card", "bot_id", bot.ID, "path", cardPath)
}
slog.Info("Generated bot profile cards", "count", len(data.Bots))
return nil
}
// uploadCardsToR2 uploads generated card images to R2 warm cache
func uploadCardsToR2(ctx context.Context, cfg *Config, outputDir string) error {
cardsDir := filepath.Join(outputDir, "cards")
// Check if cards directory exists
if _, err := os.Stat(cardsDir); os.IsNotExist(err) {
return nil // No cards to upload
}
// Read all card files
entries, err := os.ReadDir(cardsDir)
if err != nil {
return fmt.Errorf("read cards directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".png" {
continue
}
cardPath := filepath.Join(cardsDir, entry.Name())
r2Key := "cards/" + entry.Name()
// Upload to R2
if err := uploadFileToR2(ctx, cfg, cardPath, r2Key); err != nil {
slog.Error("Failed to upload card to R2", "file", entry.Name(), "error", err)
continue
}
slog.Debug("Uploaded card to R2", "key", r2Key)
}
slog.Info("Uploaded bot cards to R2", "count", len(entries))
return nil
}
// uploadCardsToB2 uploads generated card images to B2 cold archive
func uploadCardsToB2(ctx context.Context, cfg *Config, outputDir string) error {
cardsDir := filepath.Join(outputDir, "cards")
// Check if cards directory exists
if _, err := os.Stat(cardsDir); os.IsNotExist(err) {
return nil // No cards to upload
}
// Read all card files
entries, err := os.ReadDir(cardsDir)
if err != nil {
return fmt.Errorf("read cards directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".png" {
continue
}
cardPath := filepath.Join(cardsDir, entry.Name())
b2Key := "cards/" + entry.Name()
// Upload to B2
if err := uploadFileToB2(ctx, cfg, cardPath, b2Key); err != nil {
slog.Error("Failed to upload card to B2", "file", entry.Name(), "error", err)
continue
}
slog.Debug("Uploaded card to B2", "key", b2Key)
}
slog.Info("Uploaded bot cards to B2", "count", len(entries))
return nil
}
// savePNG saves an image as PNG to the specified path
func savePNG(path string, img image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
// drawText draws text at the specified position using basic font
func drawText(img *image.RGBA, text string, x, y int, col color.RGBA, scale float64) {
drawer := &font.Drawer{
Dst: img,
Src: &image.Uniform{col},
Face: basicfont.Face7x13,
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
}
// For larger text, we draw multiple times with offset
if scale > 1.0 {
// Simple scaling by drawing at multiple offsets
steps := int(scale * 2)
for i := 0; i < steps; i++ {
offset := i * 6 / steps
drawer.Dot.Y = fixed.I(y + offset)
drawer.DrawString(text)
}
} else {
drawer.DrawString(text)
}
}
// drawCircle draws a filled circle
func drawCircle(img *image.RGBA, cx, cy, r int, col color.Color) {
for y := cy - r; y <= cy+r; y++ {
for x := cx - r; x <= cx+r; x++ {
dx := x - cx
dy := y - cy
if dx*dx+dy*dy <= r*r {
if x >= 0 && x < img.Bounds().Dx() && y >= 0 && y < img.Bounds().Dy() {
img.Set(x, y, col)
}
}
}
}
}
// drawRoundedRect draws a filled rounded rectangle
func drawRoundedRect(img *image.RGBA, x, y, w, h, r int, col color.Color) {
// Draw main rectangle
for dy := r; dy < h-r; dy++ {
for dx := 0; dx < w; dx++ {
px := x + dx
py := y + dy
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
img.Set(px, py, col)
}
}
}
// Draw top and bottom with rounded corners
for dx := r; dx < w-r; dx++ {
for _, row := range []int{0, h - 1} {
px := x + dx
py := y + row
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
img.Set(px, py, col)
}
}
}
// Draw corner circles
corners := []struct{ cx, cy int }{
{x + r, y + r},
{x + w - r, y + r},
{x + r, y + h - r},
{x + w - r, y + h - r},
}
for _, c := range corners {
for dy := -r; dy <= r; dy++ {
for dx := -r; dx <= r; dx++ {
if dx*dx+dy*dy <= r*r {
px := c.cx + dx
py := c.cy + dy
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
img.Set(px, py, col)
}
}
}
}
}
}
// getAccentColor returns the accent color based on bot status
func getAccentColor(evolved bool, healthStatus string) color.RGBA {
if evolved {
return color.RGBA{R: 138, G: 43, B: 226, A: 255} // Purple for evolved
}
if healthStatus == "INACTIVE" {
return color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray for inactive
}
return color.RGBA{R: 59, G: 130, B: 246, A: 255} // Blue for active
}
// getColorForRating returns a color based on rating value
func getColorForRating(rating int) color.RGBA {
switch {
case rating >= 2000:
return color.RGBA{R: 255, G: 215, B: 0, A: 255} // Gold
case rating >= 1800:
return color.RGBA{R: 192, G: 192, B: 192, A: 255} // Silver
case rating >= 1600:
return color.RGBA{R: 205, G: 127, B: 50, A: 255} // Bronze
case rating >= 1400:
return color.RGBA{R: 100, G: 200, B: 100, A: 255} // Green
default:
return color.RGBA{R: 200, G: 200, B: 200, A: 255} // Light gray
}
}
// getWinRateColor returns a color based on win rate
func getWinRateColor(winRate float64) color.RGBA {
switch {
case winRate >= 70:
return color.RGBA{R: 34, G: 197, B: 94, A: 255} // Green
case winRate >= 50:
return color.RGBA{R: 59, G: 130, B: 246, A: 255} // Blue
case winRate >= 30:
return color.RGBA{R: 234, G: 179, B: 8, A: 255} // Yellow
default:
return color.RGBA{R: 239, G: 68, B: 68, A: 255} // Red
}
}
// getRankBadgeColor returns a color based on rank
func getRankBadgeColor(rank int) color.RGBA {
switch {
case rank == 1:
return color.RGBA{R: 255, G: 215, B: 0, A: 255} // Gold
case rank == 2:
return color.RGBA{R: 192, G: 192, B: 192, A: 255} // Silver
case rank == 3:
return color.RGBA{R: 205, G: 127, B: 50, A: 255} // Bronze
case rank <= 10:
return color.RGBA{R: 59, G: 130, B: 246, A: 255} // Blue
default:
return color.RGBA{R: 100, G: 100, B: 100, A: 255} // Gray
}
}
// blendColors blends two colors
func blendColors(bg, fg color.Color) color.RGBA {
br, bg2, bb, ba := bg.RGBA()
fr, fg3, fb, fa := fg.RGBA()
// Convert from premultiplied alpha
if ba == 0 {
return color.RGBA{R: uint8(fr), G: uint8(fg3), B: uint8(fb), A: uint8(fa >> 8)}
}
if fa == 0 {
return color.RGBA{R: uint8(br >> 8), G: uint8(bg2 >> 8), B: uint8(bb >> 8), A: uint8(ba >> 8)}
}
alpha := float64(fa) / 65535.0
r := float64(br>>8)*(1-alpha) + float64(fr>>8)*alpha
g := float64(bg2>>8)*(1-alpha) + float64(fg3>>8)*alpha
b := float64(bb>>8)*(1-alpha) + float64(fb>>8)*alpha
a := float64(ba>>8)*(1-alpha) + float64(fa>>8)*alpha
return color.RGBA{
R: uint8(r),
G: uint8(g),
B: uint8(b),
A: uint8(a),
}
}
// uploadFileToR2 uploads a file to R2
func uploadFileToR2(ctx context.Context, cfg *Config, filePath, key string) error {
client, err := getR2Client(cfg)
if err != nil {
return fmt.Errorf("create R2 client: %w", err)
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer file.Close()
contentType := getS3ContentType(key)
if err := client.uploadFile(ctx, key, file, contentType); err != nil {
return fmt.Errorf("upload to R2: %w", err)
}
slog.Debug("Uploaded file to R2", "file", filePath, "key", key)
return nil
}
// uploadFileToB2 uploads a file to B2
func uploadFileToB2(ctx context.Context, cfg *Config, filePath, key string) error {
client, err := getB2Client(cfg)
if err != nil {
return fmt.Errorf("create B2 client: %w", err)
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer file.Close()
contentType := getS3ContentType(key)
if err := client.uploadFile(ctx, key, file, contentType); err != nil {
return fmt.Errorf("upload to B2: %w", err)
}
slog.Debug("Uploaded file to B2", "file", filePath, "key", key)
return nil
}