- 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>
477 lines
13 KiB
Go
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
|
|
}
|