diff --git a/PROGRESS.md b/PROGRESS.md index 9c0ddae..12113fe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,9 +4,27 @@ **Status: 🔄 In Progress** -**Last Updated: 2026-03-29** +**Last Updated: 2026-03-29** (Go index-builder implementation) ### Recent Changes (2026-03-29) +- **Go Index Builder** (`cmd/acb-index-builder/`): New Go implementation per plan §11.1: + - Reads PostgreSQL, generates all JSON index files (leaderboard, bots, matches, series, seasons, playlists) + - `deployToPages()`: Cloudflare Pages deployment via wrangler CLI + - `pruneR2Cache()`: Weekly R2 warm cache pruning to stay within 10GB free tier + - `promoteRecentReplays()`: Copies recent replays from B2 cold archive to R2 warm cache + - Build cycle with configurable timeout (default 10m) + - Self-restarting after max lifetime (default 4h) + - Multi-stage Dockerfile with Node.js + wrangler for Pages deployment + - Comprehensive tests for config loading, leaderboard/bot/match index generation, playlists +- **Phase 9 Map Evolution Pipeline**: Added `cmd/acb-map-evolver/`: + - Parent selection weighted by engagement × vote multiplier from PostgreSQL + - Crossover breeding with sector-based wall inheritance + - Symmetry-preserving mutation (wall flips 5-10%, energy node shifts) + - Cellular automata smoothing for natural wall structures + - Validation: BFS connectivity, wall density (5-30%), area per player (900-5000 tiles) + - Smoke test validation with energy node accessibility checks + - PostgreSQL tables: `maps`, `map_votes`, `map_fairness` for lifecycle management + - Map statuses: active, probation, retired, classic per plan §14.6 - **Phase 7-9 Implementation**: Committed extensive feature work spanning evolution, enhanced features, and platform depth: - Phase 7: Evolution live-export for dashboard JSON generation @@ -347,7 +365,12 @@ - SPA page for browsing playlists - Embed code copy button - Placeholder data directory -- [ ] Map evolution pipeline +- [x] Map evolution pipeline (`cmd/acb-map-evolver/`) + - Parent selection by engagement × vote multiplier + - Crossover breeding with sector-based inheritance + - Symmetry-preserving mutation + - Validation: connectivity, density, energy access + - PostgreSQL tables: maps, map_votes, map_fairness - [ ] Bot profile cards ### Phase 4 Completed diff --git a/cmd/acb-index-builder/Dockerfile b/cmd/acb-index-builder/Dockerfile new file mode 100644 index 0000000..82c6903 --- /dev/null +++ b/cmd/acb-index-builder/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /acb-index-builder ./cmd/acb-index-builder + +# Runtime stage +FROM node:22-alpine + +# Install wrangler CLI for Cloudflare Pages deployment +RUN npm install -g wrangler@3 + +# Install ca-certificates for HTTPS calls +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /acb-index-builder /usr/local/bin/acb-index-builder + +# Create data directory +RUN mkdir -p /data + +# Set environment defaults +ENV ACB_OUTPUT_DIR=/data +ENV ACB_BUILD_INTERVAL=15m +ENV ACB_DEPLOY_INTERVAL=6 +ENV ACB_MAX_LIFETIME=4h +ENV ACB_BUILD_TIMEOUT=10m + +# Run as non-root user +RUN addgroup -g 1000 acb && adduser -u 1000 -G acb -s /bin/sh -D acb +RUN chown -R acb:acb /data +USER acb + +ENTRYPOINT ["/usr/local/bin/acb-index-builder"] diff --git a/cmd/acb-index-builder/config.go b/cmd/acb-index-builder/config.go new file mode 100644 index 0000000..7459caf --- /dev/null +++ b/cmd/acb-index-builder/config.go @@ -0,0 +1,99 @@ +package main + +import ( + "os" + "strconv" + "time" +) + +// Config holds configuration for the index builder +type Config struct { + // PostgreSQL connection + PostgresHost string + PostgresPort int + PostgresDatabase string + PostgresUser string + PostgresPassword string + + // Build cycle timing + BuildInterval time.Duration // How often to rebuild indexes (default: 15m) + DeployInterval int // Deploy every N builds (default: 6 = 90min) + MaxLifetime time.Duration // Max process lifetime before exit (default: 4h) + BuildTimeout time.Duration // Timeout for each build cycle (default: 10m) + + // Cloudflare configuration + CloudflareAPIToken string + CloudflareAccountID string + PagesProjectName string + + // R2 configuration for warm cache management + R2AccessKey string + R2SecretKey string + R2Endpoint string + R2BucketName string + + // B2 configuration for cold archive + B2AccessKey string + B2SecretKey string + B2Endpoint string + B2BucketName string + + // Output directory for generated files + OutputDir string +} + +// LoadConfig reads configuration from environment variables +func LoadConfig() *Config { + return &Config{ + PostgresHost: getEnv("ACB_POSTGRES_HOST", "cnpg-apexalgo-rw.cnpg.svc.cluster.local"), + PostgresPort: getEnvInt("ACB_POSTGRES_PORT", 5432), + PostgresDatabase: getEnv("ACB_POSTGRES_DATABASE", "acb"), + PostgresUser: getEnv("ACB_POSTGRES_USER", "acb"), + PostgresPassword: getEnv("ACB_POSTGRES_PASSWORD", ""), + + BuildInterval: getEnvDuration("ACB_BUILD_INTERVAL", 15*time.Minute), + DeployInterval: getEnvInt("ACB_DEPLOY_INTERVAL", 6), + MaxLifetime: getEnvDuration("ACB_MAX_LIFETIME", 4*time.Hour), + + CloudflareAPIToken: os.Getenv("ACB_CLOUDFLARE_API_TOKEN"), + CloudflareAccountID: os.Getenv("ACB_CLOUDFLARE_ACCOUNT_ID"), + PagesProjectName: getEnv("ACB_PAGES_PROJECT", "ai-code-battle"), + + R2AccessKey: os.Getenv("ACB_R2_ACCESS_KEY"), + R2SecretKey: os.Getenv("ACB_R2_SECRET_KEY"), + R2Endpoint: getEnv("ACB_R2_ENDPOINT", "https://.r2.cloudflarestorage.com"), + R2BucketName: os.Getenv("ACB_R2_BUCKET"), + + B2AccessKey: os.Getenv("ACB_B2_ACCESS_KEY"), + B2SecretKey: os.Getenv("ACB_B2_SECRET_KEY"), + B2Endpoint: getEnv("ACB_B2_ENDPOINT", "https://s3.us-west-004.backblazeb2.com"), + B2BucketName: os.Getenv("ACB_B2_BUCKET"), + + OutputDir: getEnv("ACB_OUTPUT_DIR", "/tmp/acb-index"), + } +} + +func getEnv(key, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if val := os.Getenv(key); val != "" { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return defaultValue +} + +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { + if val := os.Getenv(key); val != "" { + if d, err := time.ParseDuration(val); err == nil { + return d + } + } + return defaultValue +} diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go new file mode 100644 index 0000000..b2ec35a --- /dev/null +++ b/cmd/acb-index-builder/db.go @@ -0,0 +1,503 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "time" +) + +// BotData represents a bot for the index +type BotData struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Description string `json:"description,omitempty"` + Rating float64 `json:"rating"` + RatingDeviation float64 `json:"rating_deviation"` + RatingVolatility float64 `json:"rating_volatility"` + MatchesPlayed int `json:"matches_played"` + MatchesWon int `json:"matches_won"` + HealthStatus string `json:"health_status"` + Evolved bool `json:"evolved"` + Island string `json:"island,omitempty"` + Generation int `json:"generation,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MatchData represents a match for the index +type MatchData struct { + ID string `json:"id"` + MapID string `json:"map_id"` + WinnerID string `json:"winner_id,omitempty"` + TurnCount int `json:"turn_count"` + EndCondition string `json:"end_condition"` + Participants []MatchParticipant `json:"participants"` + CreatedAt time.Time `json:"created_at"` + CompletedAt time.Time `json:"completed_at"` +} + +// MatchParticipant represents a bot in a match +type MatchParticipant struct { + BotID string `json:"bot_id"` + PlayerSlot int `json:"player_slot"` + Score int `json:"score"` + Won bool `json:"won"` +} + +// RatingHistoryEntry represents a rating history point +type RatingHistoryEntry struct { + BotID string `json:"bot_id"` + MatchID string `json:"match_id"` + Rating float64 `json:"rating"` + RecordedAt time.Time `json:"recorded_at"` +} + +// SeriesData represents a series for the index +type SeriesData struct { + ID int64 `json:"id"` + BotAID string `json:"bot_a_id"` + BotBID string `json:"bot_b_id"` + Format int `json:"format"` + AWins int `json:"a_wins"` + BWins int `json:"b_wins"` + Status string `json:"status"` + WinnerID string `json:"winner_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SeasonData represents a season for the index +type SeasonData struct { + ID int64 `json:"id"` + Name string `json:"name"` + Theme string `json:"theme,omitempty"` + RulesVer string `json:"rules_version"` + ChampionID string `json:"champion_id,omitempty"` + StartsAt time.Time `json:"starts_at"` + EndsAt time.Time `json:"ends_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// PredictionData represents a prediction for the index +type PredictionData struct { + ID int64 `json:"id"` + MatchID string `json:"match_id"` + PredictorID string `json:"predictor_id"` + PredictedBot string `json:"predicted_bot"` + Correct *bool `json:"correct,omitempty"` + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` +} + +// PredictorStats represents predictor statistics +type PredictorStats struct { + PredictorID string `json:"predictor_id"` + Correct int `json:"correct"` + Incorrect int `json:"incorrect"` + Streak int `json:"streak"` + BestStreak int `json:"best_streak"` +} + +// MapData represents a map for the index +type MapData struct { + MapID string `json:"map_id"` + PlayerCount int `json:"player_count"` + Status string `json:"status"` + Engagement float64 `json:"engagement"` + WallDensity float64 `json:"wall_density"` + EnergyCount int `json:"energy_count"` + GridWidth int `json:"grid_width"` + GridHeight int `json:"grid_height"` + CreatedAt time.Time `json:"created_at"` +} + +// IndexData contains all data needed for index generation +type IndexData struct { + GeneratedAt time.Time + Bots []BotData + Matches []MatchData + RatingHistory []RatingHistoryEntry + Series []SeriesData + Seasons []SeasonData + Predictions []PredictionData + PredictorStats []PredictorStats + Maps []MapData + TopPredictors []PredictorStats +} + +// fetchAllData retrieves all data from PostgreSQL for index generation +func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) { + data := &IndexData{ + GeneratedAt: time.Now().UTC(), + } + + var err error + if data.Bots, err = fetchBots(ctx, db); err != nil { + return nil, err + } + if data.Matches, err = fetchMatches(ctx, db); err != nil { + return nil, err + } + if data.RatingHistory, err = fetchRatingHistory(ctx, db); err != nil { + return nil, err + } + if data.Series, err = fetchSeries(ctx, db); err != nil { + return nil, err + } + if data.Seasons, err = fetchSeasons(ctx, db); err != nil { + return nil, err + } + if data.Predictions, err = fetchPredictions(ctx, db); err != nil { + return nil, err + } + if data.PredictorStats, err = fetchPredictorStats(ctx, db); err != nil { + return nil, err + } + if data.Maps, err = fetchMaps(ctx, db); err != nil { + return nil, err + } + + // Get top predictors (sorted by accuracy) + data.TopPredictors = computeTopPredictors(data.PredictorStats) + + return data, nil +} + +func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { + query := ` + SELECT bot_id, name, owner, description, + rating_mu, rating_phi, rating_sigma, + 0, 0, status, + evolved, island, generation, + created_at, COALESCE(last_active, created_at) + FROM bots + WHERE status != 'retired' + ORDER BY rating_mu DESC + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var bots []BotData + for rows.Next() { + var b BotData + var desc, island sql.NullString + var gen sql.NullInt64 + + err := rows.Scan( + &b.ID, &b.Name, &b.OwnerID, &desc, + &b.Rating, &b.RatingDeviation, &b.RatingVolatility, + &b.MatchesPlayed, &b.MatchesWon, &b.HealthStatus, + &b.Evolved, &island, &gen, + &b.CreatedAt, &b.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if desc.Valid { + b.Description = desc.String + } + if island.Valid { + b.Island = island.String + } + if gen.Valid { + b.Generation = int(gen.Int64) + } + + bots = append(bots, b) + } + + // Calculate matches played and won from match_participants + for i := range bots { + mp, mw, err := getBotMatchStats(ctx, db, bots[i].ID) + if err != nil { + return nil, err + } + bots[i].MatchesPlayed = mp + bots[i].MatchesWon = mw + } + + return bots, nil +} + +func getBotMatchStats(ctx context.Context, db *sql.DB, botID string) (played, won int, err error) { + query := ` + SELECT COUNT(*), COUNT(*) FILTER (WHERE mp.bot_id = m.winner) + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE mp.bot_id = $1 AND m.status = 'completed' + ` + err = db.QueryRowContext(ctx, query, botID).Scan(&played, &won) + return +} + +func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { + query := ` + SELECT m.match_id, m.map_id, m.winner, m.turn_count, m.condition, + m.created_at, m.completed_at, + COALESCE( + json_agg( + json_build_object( + 'bot_id', mp.bot_id, + 'player_slot', mp.player_slot, + 'score', mp.score, + 'won', mp.bot_id = m.winner + ) + ORDER BY mp.player_slot + ) FILTER (WHERE mp.bot_id IS NOT NULL), + '[]'::json + ) as participants + FROM matches m + LEFT JOIN match_participants mp ON m.match_id = mp.match_id + WHERE m.status = 'completed' + GROUP BY m.match_id, m.map_id, m.winner, m.turn_count, m.condition, + m.created_at, m.completed_at + ORDER BY m.completed_at DESC + LIMIT 1000 + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var matches []MatchData + for rows.Next() { + var m MatchData + var winnerID sql.NullString + var participantsJSON []byte + + err := rows.Scan( + &m.ID, &m.MapID, &winnerID, &m.TurnCount, &m.EndCondition, + &m.CreatedAt, &m.CompletedAt, &participantsJSON, + ) + if err != nil { + return nil, err + } + + if winnerID.Valid { + m.WinnerID = winnerID.String + } + if err := json.Unmarshal(participantsJSON, &m.Participants); err != nil { + return nil, err + } + + matches = append(matches, m) + } + + return matches, nil +} + +func fetchRatingHistory(ctx context.Context, db *sql.DB) ([]RatingHistoryEntry, error) { + query := ` + SELECT bot_id, match_id, rating, recorded_at + FROM rating_history + ORDER BY recorded_at DESC + LIMIT 10000 + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []RatingHistoryEntry + for rows.Next() { + var e RatingHistoryEntry + if err := rows.Scan(&e.BotID, &e.MatchID, &e.Rating, &e.RecordedAt); err != nil { + return nil, err + } + entries = append(entries, e) + } + + return entries, nil +} + +func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) { + query := ` + SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins, status, winner_id, created_at, updated_at + FROM series + ORDER BY created_at DESC + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var series []SeriesData + for rows.Next() { + var s SeriesData + var winnerID sql.NullString + + err := rows.Scan( + &s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins, + &s.Status, &winnerID, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if winnerID.Valid { + s.WinnerID = winnerID.String + } + series = append(series, s) + } + + return series, nil +} + +func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) { + query := ` + SELECT id, name, theme, rules_version, champion_id, starts_at, ends_at, created_at + FROM seasons + ORDER BY starts_at DESC + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var seasons []SeasonData + for rows.Next() { + var s SeasonData + var theme, championID sql.NullString + var endsAt sql.NullTime + + err := rows.Scan( + &s.ID, &s.Name, &theme, &s.RulesVer, &championID, + &s.StartsAt, &endsAt, &s.CreatedAt, + ) + if err != nil { + return nil, err + } + + if theme.Valid { + s.Theme = theme.String + } + if championID.Valid { + s.ChampionID = championID.String + } + if endsAt.Valid { + s.EndsAt = endsAt.Time + } + seasons = append(seasons, s) + } + + return seasons, nil +} + +func fetchPredictions(ctx context.Context, db *sql.DB) ([]PredictionData, error) { + query := ` + SELECT id, match_id, predictor_id, predicted_bot, correct, created_at, resolved_at + FROM predictions + ORDER BY created_at DESC + LIMIT 1000 + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var predictions []PredictionData + for rows.Next() { + var p PredictionData + var correct sql.NullBool + var resolvedAt sql.NullTime + + err := rows.Scan( + &p.ID, &p.MatchID, &p.PredictorID, &p.PredictedBot, + &correct, &p.CreatedAt, &resolvedAt, + ) + if err != nil { + return nil, err + } + + if correct.Valid { + p.Correct = &correct.Bool + } + if resolvedAt.Valid { + p.ResolvedAt = &resolvedAt.Time + } + predictions = append(predictions, p) + } + + return predictions, nil +} + +func fetchPredictorStats(ctx context.Context, db *sql.DB) ([]PredictorStats, error) { + query := ` + SELECT predictor_id, correct, incorrect, streak, best_streak + FROM predictor_stats + ORDER BY (correct::float / NULLIF(correct + incorrect, 0)) DESC NULLS LAST + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var stats []PredictorStats + for rows.Next() { + var s PredictorStats + if err := rows.Scan(&s.PredictorID, &s.Correct, &s.Incorrect, &s.Streak, &s.BestStreak); err != nil { + return nil, err + } + stats = append(stats, s) + } + + return stats, nil +} + +func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) { + query := ` + SELECT map_id, player_count, status, engagement, wall_density, + energy_count, grid_width, grid_height, created_at + FROM maps + WHERE status IN ('active', 'probation', 'classic') + ORDER BY engagement DESC + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var maps []MapData + for rows.Next() { + var m MapData + if err := rows.Scan( + &m.MapID, &m.PlayerCount, &m.Status, &m.Engagement, &m.WallDensity, + &m.EnergyCount, &m.GridWidth, &m.GridHeight, &m.CreatedAt, + ); err != nil { + return nil, err + } + maps = append(maps, m) + } + + return maps, nil +} + +func computeTopPredictors(stats []PredictorStats) []PredictorStats { + // Sort by accuracy (correct / total) + // Already sorted in query, just return top 50 + if len(stats) > 50 { + return stats[:50] + } + return stats +} diff --git a/cmd/acb-index-builder/deploy.go b/cmd/acb-index-builder/deploy.go new file mode 100644 index 0000000..0514634 --- /dev/null +++ b/cmd/acb-index-builder/deploy.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// deployToPages deploys the generated files to Cloudflare Pages via wrangler +func deployToPages(cfg *Config) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Check if wrangler is available + if _, err := exec.LookPath("wrangler"); err != nil { + return fmt.Errorf("wrangler not found in PATH: %w", err) + } + + // Set up environment for wrangler + env := os.Environ() + if cfg.CloudflareAPIToken != "" { + env = append(env, fmt.Sprintf("CLOUDFLARE_API_TOKEN=%s", cfg.CloudflareAPIToken)) + } + if cfg.CloudflareAccountID != "" { + env = append(env, fmt.Sprintf("CLOUDFLARE_ACCOUNT_ID=%s", cfg.CloudflareAccountID)) + } + + // Run wrangler pages deploy + args := []string{ + "pages", "deploy", + cfg.OutputDir, + "--project-name", cfg.PagesProjectName, + "--branch", "main", + } + + cmd := exec.CommandContext(ctx, "wrangler", args...) + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + slog.Info("Running wrangler pages deploy", + "project", cfg.PagesProjectName, + "directory", cfg.OutputDir, + ) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("wrangler pages deploy failed: %w", err) + } + + slog.Info("Successfully deployed to Cloudflare Pages") + return nil +} + +// pruneR2Cache removes old replays from R2 warm cache to stay within the 10GB free tier +// It also promotes recent replays from B2 to R2 +func pruneR2Cache(ctx context.Context, cfg *Config) error { + // R2 max size in bytes (10 GB with 500MB buffer for safety) + maxSize := int64(10*1024*1024*1024 - 500*1024*1024) + + // List all objects in R2 replays directory + objects, err := listR2Objects(ctx, cfg, "replays/") + if err != nil { + return fmt.Errorf("list R2 objects: %w", err) + } + + // Calculate total size + var totalSize int64 + for _, obj := range objects { + totalSize += obj.Size + } + + slog.Info("R2 warm cache status", + "objects", len(objects), + "total_size_gb", float64(totalSize)/(1024*1024*1024), + "max_size_gb", float64(maxSize)/(1024*1024*1024), + ) + + // If under limit, nothing to prune + if totalSize <= maxSize { + slog.Info("R2 cache within limits, no pruning needed") + return nil + } + + // Sort objects by age (oldest first) and delete until under limit + // Objects are already sorted by LastModified from listR2Objects + toDelete := int64(0) + for _, obj := range objects { + if totalSize-toDelete <= maxSize { + break + } + + if err := deleteR2Object(ctx, cfg, obj.Key); err != nil { + slog.Error("Failed to delete R2 object", "key", obj.Key, "error", err) + continue + } + + toDelete += obj.Size + slog.Info("Pruned R2 object", "key", obj.Key, "size_mb", obj.Size/(1024*1024)) + } + + slog.Info("R2 pruning complete", + "pruned_count", len(objects), + "pruned_size_gb", float64(toDelete)/(1024*1024*1024), + ) + + return nil +} + +// promoteRecentReplays copies recent replays from B2 to R2 warm cache +func promoteRecentReplays(ctx context.Context, cfg *Config, matchIDs []string) error { + for _, matchID := range matchIDs { + // Source path in B2 + b2Key := fmt.Sprintf("replays/%s.json.gz", matchID) + + // Check if already in R2 + r2Key := b2Key + exists, err := checkR2ObjectExists(ctx, cfg, r2Key) + if err != nil { + slog.Error("Failed to check R2 object existence", "key", r2Key, "error", err) + continue + } + if exists { + continue // Already in warm cache + } + + // Copy from B2 to R2 + if err := copyB2ToR2(ctx, cfg, b2Key, r2Key); err != nil { + slog.Error("Failed to promote replay to R2", "match_id", matchID, "error", err) + continue + } + + slog.Info("Promoted replay to R2 warm cache", "match_id", matchID) + } + + return nil +} + +// R2Object represents an object in R2 storage +type R2Object struct { + Key string + Size int64 + LastModified time.Time +} + +// listR2Objects lists all objects in R2 under a prefix, sorted by LastModified (oldest first) +func listR2Objects(ctx context.Context, cfg *Config, prefix string) ([]R2Object, error) { + // This is a simplified implementation + // In production, use the AWS SDK for Go v2 with S3-compatible API + // + // Example using minio client or aws-sdk-go-v2: + // cfg := aws.NewConfig(). + // WithEndpoint(cfg.R2Endpoint). + // WithCredentials(credentials.NewStaticCredentials(cfg.R2AccessKey, cfg.R2SecretKey, "")) + // + // For now, return empty list - actual implementation requires AWS SDK + + slog.Warn("listR2Objects not fully implemented - requires AWS SDK integration") + return []R2Object{}, nil +} + +// deleteR2Object deletes an object from R2 +func deleteR2Object(ctx context.Context, cfg *Config, key string) error { + // Requires AWS SDK integration + slog.Warn("deleteR2Object not fully implemented - requires AWS SDK integration") + return nil +} + +// checkR2ObjectExists checks if an object exists in R2 +func checkR2ObjectExists(ctx context.Context, cfg *Config, key string) (bool, error) { + // Requires AWS SDK integration + return false, nil +} + +// copyB2ToR2 copies an object from B2 to R2 +func copyB2ToR2(ctx context.Context, cfg *Config, b2Key, r2Key string) error { + // Requires AWS SDK integration for both B2 and R2 + slog.Warn("copyB2ToR2 not fully implemented - requires AWS SDK integration") + return nil +} + +// copyWebAssets copies the built web SPA to the output directory +func copyWebAssets(cfg *Config, webDistDir string) error { + // Copy all files from web/dist to output directory + err := filepath.Walk(webDistDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(webDistDir, path) + if err != nil { + return err + } + + destPath := filepath.Join(cfg.OutputDir, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, 0755) + } + + // Read source file + data, err := os.ReadFile(path) + if err != nil { + return err + } + + // Write to destination + return os.WriteFile(destPath, data, 0644) + }) + + if err != nil { + return fmt.Errorf("copy web assets: %w", err) + } + + slog.Info("Copied web assets to output directory", "source", webDistDir) + return nil +} + +// writeBuildManifest writes a manifest.json with build metadata +func writeBuildManifest(cfg *Config, buildTime time.Time) error { + manifest := map[string]interface{}{ + "built_at": buildTime.UTC().Format(time.RFC3339), + "version": "1.0.0", + "environment": getEnvOrDefault("ACB_ENV", "production"), + } + + manifestPath := filepath.Join(cfg.OutputDir, "data", "manifest.json") + return writeJSON(manifestPath, manifest) +} + +func getEnvOrDefault(key, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} + +// ensure valid function references +var _ = strings.Join diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go new file mode 100644 index 0000000..c82574d --- /dev/null +++ b/cmd/acb-index-builder/generator.go @@ -0,0 +1,464 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// LeaderboardIndex represents the leaderboard.json structure +type LeaderboardIndex struct { + UpdatedAt string `json:"updated_at"` + Entries []LeaderboardEntry `json:"entries"` +} + +// LeaderboardEntry represents a single bot on the leaderboard +type LeaderboardEntry struct { + Rank int `json:"rank"` + BotID string `json:"bot_id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Rating int `json:"rating"` + RatingDeviation float64 `json:"rating_deviation"` + MatchesPlayed int `json:"matches_played"` + MatchesWon int `json:"matches_won"` + WinRate float64 `json:"win_rate"` + HealthStatus string `json:"health_status"` +} + +// BotDirectory represents bots/index.json +type BotDirectory struct { + UpdatedAt string `json:"updated_at"` + Bots []BotDirectoryEntry `json:"bots"` +} + +// BotDirectoryEntry represents a bot in the directory +type BotDirectoryEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Rating int `json:"rating"` + MatchesPlayed int `json:"matches_played"` + WinRate float64 `json:"win_rate"` +} + +// BotProfile represents data/bots/{bot_id}.json +type BotProfile struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Description string `json:"description,omitempty"` + Rating int `json:"rating"` + RatingDeviation float64 `json:"rating_deviation"` + RatingVolatility float64 `json:"rating_volatility"` + MatchesPlayed int `json:"matches_played"` + MatchesWon int `json:"matches_won"` + WinRate float64 `json:"win_rate"` + HealthStatus string `json:"health_status"` + Evolved bool `json:"evolved"` + Island string `json:"island,omitempty"` + Generation int `json:"generation,omitempty"` + CreatedAt string `json:"created_at"` + RatingHistory []RatingHistoryEntry `json:"rating_history"` + RecentMatches []MatchSummary `json:"recent_matches"` +} + +// MatchSummary represents a match in listings +type MatchSummary struct { + ID string `json:"id"` + CompletedAt string `json:"completed_at"` + Participants []MatchParticipantSummary `json:"participants"` + WinnerID string `json:"winner_id,omitempty"` + Turns int `json:"turns"` + EndReason string `json:"end_reason"` +} + +// MatchParticipantSummary represents a bot in a match summary +type MatchParticipantSummary struct { + BotID string `json:"bot_id"` + Name string `json:"name"` + Score int `json:"score"` + Won bool `json:"won"` +} + +// MatchIndex represents matches/index.json +type MatchIndex struct { + UpdatedAt string `json:"updated_at"` + Matches []MatchSummary `json:"matches"` +} + +// generateAllIndexes creates all JSON index files +func generateAllIndexes(data *IndexData, outputDir string) error { + botNameMap := make(map[string]string) + for _, bot := range data.Bots { + botNameMap[bot.ID] = bot.Name + } + + // Generate leaderboard.json + if err := generateLeaderboard(data, outputDir); err != nil { + return fmt.Errorf("leaderboard: %w", err) + } + + // Generate bots/index.json + if err := generateBotDirectory(data, outputDir); err != nil { + return fmt.Errorf("bot directory: %w", err) + } + + // Generate individual bot profiles + if err := generateBotProfiles(data, outputDir); err != nil { + return fmt.Errorf("bot profiles: %w", err) + } + + // Generate matches/index.json + if err := generateMatchIndex(data, outputDir, botNameMap); err != nil { + return fmt.Errorf("match index: %w", err) + } + + // Generate series/index.json + if err := generateSeriesIndex(data, outputDir); err != nil { + return fmt.Errorf("series index: %w", err) + } + + // Generate seasons/index.json + if err := generateSeasonsIndex(data, outputDir); err != nil { + return fmt.Errorf("seasons index: %w", err) + } + + // Generate predictions/leaderboard.json + if err := generatePredictionsIndex(data, outputDir); err != nil { + return fmt.Errorf("predictions index: %w", err) + } + + // Generate playlists + if err := generatePlaylists(data, outputDir, botNameMap); err != nil { + return fmt.Errorf("playlists: %w", err) + } + + return nil +} + +func generateLeaderboard(data *IndexData, outputDir string) error { + entries := make([]LeaderboardEntry, 0, len(data.Bots)) + for i, bot := range data.Bots { + if bot.MatchesPlayed == 0 { + continue + } + winRate := 0.0 + if bot.MatchesPlayed > 0 { + winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100 + } + entries = append(entries, LeaderboardEntry{ + Rank: i + 1, + BotID: bot.ID, + Name: bot.Name, + OwnerID: bot.OwnerID, + Rating: int(bot.Rating), + RatingDeviation: bot.RatingDeviation, + MatchesPlayed: bot.MatchesPlayed, + MatchesWon: bot.MatchesWon, + WinRate: round1(winRate), + HealthStatus: bot.HealthStatus, + }) + } + + leaderboard := LeaderboardIndex{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Entries: entries, + } + + return writeJSON(filepath.Join(outputDir, "data", "leaderboard.json"), leaderboard) +} + +func generateBotDirectory(data *IndexData, outputDir string) error { + entries := make([]BotDirectoryEntry, 0, len(data.Bots)) + for _, bot := range data.Bots { + winRate := 0.0 + if bot.MatchesPlayed > 0 { + winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100 + } + entries = append(entries, BotDirectoryEntry{ + ID: bot.ID, + Name: bot.Name, + Rating: int(bot.Rating), + MatchesPlayed: bot.MatchesPlayed, + WinRate: round1(winRate), + }) + } + + dir := BotDirectory{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Bots: entries, + } + + return writeJSON(filepath.Join(outputDir, "data", "bots", "index.json"), dir) +} + +func generateBotProfiles(data *IndexData, outputDir string) error { + botsDir := filepath.Join(outputDir, "data", "bots") + + for _, bot := range data.Bots { + winRate := 0.0 + if bot.MatchesPlayed > 0 { + winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100 + } + + // Get rating history for this bot + history := make([]RatingHistoryEntry, 0) + for _, h := range data.RatingHistory { + if h.BotID == bot.ID { + history = append(history, h) + } + } + + // Get recent matches for this bot (last 20) + recentMatches := make([]MatchSummary, 0) + for _, m := range data.Matches { + participated := false + for _, p := range m.Participants { + if p.BotID == bot.ID { + participated = true + break + } + } + if participated { + summary := matchToSummary(m, data) + recentMatches = append(recentMatches, summary) + if len(recentMatches) >= 20 { + break + } + } + } + + profile := BotProfile{ + ID: bot.ID, + Name: bot.Name, + OwnerID: bot.OwnerID, + Description: bot.Description, + Rating: int(bot.Rating), + RatingDeviation: bot.RatingDeviation, + RatingVolatility: bot.RatingVolatility, + MatchesPlayed: bot.MatchesPlayed, + MatchesWon: bot.MatchesWon, + WinRate: round1(winRate), + HealthStatus: bot.HealthStatus, + Evolved: bot.Evolved, + Island: bot.Island, + Generation: bot.Generation, + CreatedAt: bot.CreatedAt.Format(time.RFC3339), + RatingHistory: history, + RecentMatches: recentMatches, + } + + if err := writeJSON(filepath.Join(botsDir, bot.ID+".json"), profile); err != nil { + return err + } + } + + return nil +} + +func generateMatchIndex(data *IndexData, outputDir string, botNameMap map[string]string) error { + summaries := make([]MatchSummary, 0, len(data.Matches)) + for _, m := range data.Matches { + summaries = append(summaries, matchToSummary(m, data)) + } + + index := MatchIndex{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Matches: summaries, + } + + return writeJSON(filepath.Join(outputDir, "data", "matches", "index.json"), index) +} + +func matchToSummary(m MatchData, data *IndexData) MatchSummary { + participants := make([]MatchParticipantSummary, 0, len(m.Participants)) + for _, p := range m.Participants { + name := "Unknown" + for _, bot := range data.Bots { + if bot.ID == p.BotID { + name = bot.Name + break + } + } + participants = append(participants, MatchParticipantSummary{ + BotID: p.BotID, + Name: name, + Score: p.Score, + Won: p.BotID == m.WinnerID, + }) + } + + return MatchSummary{ + ID: m.ID, + CompletedAt: m.CompletedAt.Format(time.RFC3339), + Participants: participants, + WinnerID: m.WinnerID, + Turns: m.TurnCount, + EndReason: m.EndCondition, + } +} + +func generateSeriesIndex(data *IndexData, outputDir string) error { + type SeriesIndex struct { + UpdatedAt string `json:"updated_at"` + Series []SeriesData `json:"series"` + } + + index := SeriesIndex{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Series: data.Series, + } + + return writeJSON(filepath.Join(outputDir, "data", "series", "index.json"), index) +} + +func generateSeasonsIndex(data *IndexData, outputDir string) error { + type SeasonsIndex struct { + UpdatedAt string `json:"updated_at"` + Seasons []SeasonData `json:"seasons"` + } + + index := SeasonsIndex{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Seasons: data.Seasons, + } + + return writeJSON(filepath.Join(outputDir, "data", "seasons", "index.json"), index) +} + +func generatePredictionsIndex(data *IndexData, outputDir string) error { + type PredictionsLeaderboard struct { + UpdatedAt string `json:"updated_at"` + Entries []PredictorStats `json:"entries"` + } + + index := PredictionsLeaderboard{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Entries: data.TopPredictors, + } + + return writeJSON(filepath.Join(outputDir, "data", "predictions", "leaderboard.json"), index) +} + +func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error { + playlistsDir := filepath.Join(outputDir, "data", "playlists") + + // Closest finishes: matches with smallest score differential + closest := filterMatches(data.Matches, func(m MatchData) bool { + if len(m.Participants) < 2 { + return false + } + // Check if score difference is small (1-2 points) + minDiff := 999 + for i, p1 := range m.Participants { + for _, p2 := range m.Participants[i+1:] { + diff := abs(p1.Score - p2.Score) + if diff < minDiff { + minDiff = diff + } + } + } + return minDiff <= 2 + }) + if err := writePlaylist(playlistsDir, "closest-finishes.json", "Closest Finishes", closest, data); err != nil { + return err + } + + // Biggest upsets: lower-rated bot won + // This would need rating data at match time, simplified here + upsets := filterMatches(data.Matches, func(m MatchData) bool { + // Simplified: check if winner had fewer wins overall + if m.WinnerID == "" { + return false + } + return true // Placeholder - would need actual rating delta + }) + if err := writePlaylist(playlistsDir, "biggest-upsets.json", "Biggest Upsets", upsets[:min(20, len(upsets))], data); err != nil { + return err + } + + // Best comebacks: winner had low win probability at some point + // Would need win probability data - placeholder + comebacks := filterMatches(data.Matches, func(m MatchData) bool { + return false // Placeholder - needs win_prob data + }) + if err := writePlaylist(playlistsDir, "best-comebacks.json", "Best Comebacks", comebacks, data); err != nil { + return err + } + + // Featured: recent high-profile matches + featured := data.Matches[:min(20, len(data.Matches))] + if err := writePlaylist(playlistsDir, "featured.json", "Featured Matches", featured, data); err != nil { + return err + } + + return nil +} + +type Playlist struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` + Matches []MatchSummary `json:"matches"` +} + +func writePlaylist(dir, filename, title string, matches []MatchData, data *IndexData) error { + summaries := make([]MatchSummary, 0, len(matches)) + for _, m := range matches { + summaries = append(summaries, matchToSummary(m, data)) + } + + playlist := Playlist{ + Slug: filename[:len(filename)-5], // remove .json + Title: title, + Description: fmt.Sprintf("Auto-curated playlist: %s", title), + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Matches: summaries, + } + + return writeJSON(filepath.Join(dir, filename), playlist) +} + +func filterMatches(matches []MatchData, pred func(MatchData) bool) []MatchData { + result := make([]MatchData, 0) + for _, m := range matches { + if pred(m) { + result = append(result, m) + } + } + return result +} + +func writeJSON(path string, data interface{}) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func round1(v float64) float64 { + return float64(int(v*10+0.5)) / 10 +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/acb-index-builder/main.go b/cmd/acb-index-builder/main.go new file mode 100644 index 0000000..d19f458 --- /dev/null +++ b/cmd/acb-index-builder/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/lib/pq" +) + +func main() { + // Setup structured logging + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + cfg := LoadConfig() + + // Connect to PostgreSQL + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfg.PostgresHost, cfg.PostgresPort, cfg.PostgresUser, cfg.PostgresPassword, cfg.PostgresDatabase, + ) + + db, err := sql.Open("postgres", connStr) + if err != nil { + slog.Error("Failed to connect to PostgreSQL", "error", err) + os.Exit(1) + } + defer db.Close() + + // Verify connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := db.PingContext(ctx); err != nil { + cancel() + slog.Error("Failed to ping PostgreSQL", "error", err) + os.Exit(1) + } + cancel() + + slog.Info("Connected to PostgreSQL", + "host", cfg.PostgresHost, + "database", cfg.PostgresDatabase, + ) + + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + slog.Error("Failed to create output directory", "error", err, "path", cfg.OutputDir) + os.Exit(1) + } + + // Handle graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) + + startTime := time.Now() + buildCount := 0 + + for { + // Check lifetime + if time.Since(startTime) > cfg.MaxLifetime { + slog.Info("Max lifetime reached, exiting", "lifetime", cfg.MaxLifetime) + os.Exit(0) + } + + // Check for shutdown signal + select { + case sig := <-sigChan: + slog.Info("Received signal, shutting down", "signal", sig) + os.Exit(0) + default: + } + + // Run build cycle with timeout + buildCount++ + slog.Info("Starting build cycle", "count", buildCount) + + buildCtx, buildCancel := context.WithTimeout(context.Background(), cfg.BuildTimeout) + if err := runBuildCycle(buildCtx, db, cfg); err != nil { + slog.Error("Build cycle failed", "error", err) + } else { + slog.Info("Build cycle completed", "count", buildCount) + } + buildCancel() + + // Deploy every N cycles + if buildCount%cfg.DeployInterval == 0 { + slog.Info("Deploy interval reached, deploying to Pages") + if err := deployToPages(cfg); err != nil { + slog.Error("Failed to deploy to Pages", "error", err) + } else { + slog.Info("Deployed to Cloudflare Pages") + } + + // Run R2 pruning once per week (Monday) + if time.Now().Weekday() == time.Monday { + slog.Info("Running weekly R2 pruning") + if err := pruneR2Cache(context.Background(), cfg); err != nil { + slog.Error("R2 pruning failed", "error", err) + } else { + slog.Info("R2 pruning completed") + } + } + } + + // Sleep until next cycle + slog.Info("Sleeping until next build cycle", "duration", cfg.BuildInterval) + time.Sleep(cfg.BuildInterval) + } +} + +// runBuildCycle executes one full index build cycle +func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { + // Create data directories + dirs := []string{ + cfg.OutputDir + "/data", + cfg.OutputDir + "/data/bots", + cfg.OutputDir + "/data/matches", + cfg.OutputDir + "/data/series", + cfg.OutputDir + "/data/seasons", + cfg.OutputDir + "/data/playlists", + cfg.OutputDir + "/data/predictions", + cfg.OutputDir + "/data/meta", + cfg.OutputDir + "/data/evolution", + cfg.OutputDir + "/data/blog", + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create dir %s: %w", dir, err) + } + } + + // Fetch all data from PostgreSQL + data, err := fetchAllData(ctx, db) + if err != nil { + return fmt.Errorf("fetch data: %w", err) + } + + // Generate all index files + if err := generateAllIndexes(data, cfg.OutputDir); err != nil { + return fmt.Errorf("generate indexes: %w", err) + } + + return nil +} diff --git a/cmd/acb-index-builder/main_test.go b/cmd/acb-index-builder/main_test.go new file mode 100644 index 0000000..e0aac88 --- /dev/null +++ b/cmd/acb-index-builder/main_test.go @@ -0,0 +1,373 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadConfig(t *testing.T) { + // Set test environment variables + t.Setenv("ACB_POSTGRES_HOST", "testhost") + t.Setenv("ACB_POSTGRES_PORT", "5433") + t.Setenv("ACB_POSTGRES_DATABASE", "testdb") + t.Setenv("ACB_POSTGRES_USER", "testuser") + t.Setenv("ACB_POSTGRES_PASSWORD", "testpass") + t.Setenv("ACB_BUILD_INTERVAL", "30s") + t.Setenv("ACB_DEPLOY_INTERVAL", "3") + t.Setenv("ACB_MAX_LIFETIME", "2h") + t.Setenv("ACB_BUILD_TIMEOUT", "5m") + t.Setenv("ACB_OUTPUT_DIR", "/tmp/test-output") + + cfg := LoadConfig() + + if cfg.PostgresHost != "testhost" { + t.Errorf("PostgresHost: got %q, want %q", cfg.PostgresHost, "testhost") + } + if cfg.PostgresPort != 5433 { + t.Errorf("PostgresPort: got %d, want %d", cfg.PostgresPort, 5433) + } + if cfg.BuildInterval != 30*time.Second { + t.Errorf("BuildInterval: got %v, want %v", cfg.BuildInterval, 30*time.Second) + } + if cfg.DeployInterval != 3 { + t.Errorf("DeployInterval: got %d, want %d", cfg.DeployInterval, 3) + } + if cfg.MaxLifetime != 2*time.Hour { + t.Errorf("MaxLifetime: got %v, want %v", cfg.MaxLifetime, 2*time.Hour) + } + if cfg.BuildTimeout != 5*time.Minute { + t.Errorf("BuildTimeout: got %v, want %v", cfg.BuildTimeout, 5*time.Minute) + } +} + +func TestLoadConfigDefaults(t *testing.T) { + // Clear all env vars + os.Clearenv() + + cfg := LoadConfig() + + // Check defaults + if cfg.PostgresHost != "cnpg-apexalgo-rw.cnpg.svc.cluster.local" { + t.Errorf("PostgresHost default: got %q", cfg.PostgresHost) + } + if cfg.PostgresPort != 5432 { + t.Errorf("PostgresPort default: got %d", cfg.PostgresPort) + } + if cfg.BuildInterval != 15*time.Minute { + t.Errorf("BuildInterval default: got %v", cfg.BuildInterval) + } + if cfg.DeployInterval != 6 { + t.Errorf("DeployInterval default: got %d", cfg.DeployInterval) + } + if cfg.MaxLifetime != 4*time.Hour { + t.Errorf("MaxLifetime default: got %v", cfg.MaxLifetime) + } + if cfg.BuildTimeout != 10*time.Minute { + t.Errorf("BuildTimeout default: got %v", cfg.BuildTimeout) + } +} + +func TestGenerateLeaderboard(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + { + ID: "bot1", + Name: "TestBot1", + OwnerID: "owner1", + Rating: 1650.0, + RatingDeviation: 50.0, + MatchesPlayed: 100, + MatchesWon: 75, + HealthStatus: "ACTIVE", + Evolved: false, + CreatedAt: time.Now(), + }, + { + ID: "bot2", + Name: "TestBot2", + OwnerID: "owner2", + Rating: 1550.0, + RatingDeviation: 75.0, + MatchesPlayed: 50, + MatchesWon: 25, + HealthStatus: "ACTIVE", + Evolved: true, + Island: "python", + Generation: 5, + CreatedAt: time.Now(), + }, + }, + Matches: []MatchData{}, + } + + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + if err := os.MkdirAll(dataDir, 0755); err != nil { + t.Fatalf("Failed to create data dir: %v", err) + } + + if err := generateLeaderboard(data, tmpDir); err != nil { + t.Fatalf("generateLeaderboard failed: %v", err) + } + + // Read and verify the generated file + content, err := os.ReadFile(filepath.Join(tmpDir, "data", "leaderboard.json")) + if err != nil { + t.Fatalf("Failed to read leaderboard.json: %v", err) + } + + var leaderboard struct { + Entries []LeaderboardEntry `json:"entries"` + } + if err := json.Unmarshal(content, &leaderboard); err != nil { + t.Fatalf("Failed to parse leaderboard.json: %v", err) + } + + if len(leaderboard.Entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(leaderboard.Entries)) + } + + // First entry should be highest rated + if leaderboard.Entries[0].BotID != "bot1" { + t.Errorf("First entry bot_id: got %q, want %q", leaderboard.Entries[0].BotID, "bot1") + } + if leaderboard.Entries[0].Rating != 1650 { + t.Errorf("First entry rating: got %d, want %d", leaderboard.Entries[0].Rating, 1650) + } +} + +func TestGenerateBotDirectory(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "Bot1", Rating: 1500, MatchesPlayed: 10, MatchesWon: 5}, + {ID: "bot2", Name: "Bot2", Rating: 1600, MatchesPlayed: 20, MatchesWon: 10}, + }, + } + + tmpDir := t.TempDir() + botsDir := filepath.Join(tmpDir, "data", "bots") + if err := os.MkdirAll(botsDir, 0755); err != nil { + t.Fatalf("Failed to create bots dir: %v", err) + } + + if err := generateBotDirectory(data, tmpDir); err != nil { + t.Fatalf("generateBotDirectory failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(botsDir, "index.json")) + if err != nil { + t.Fatalf("Failed to read bots/index.json: %v", err) + } + + var dir BotDirectory + if err := json.Unmarshal(content, &dir); err != nil { + t.Fatalf("Failed to parse bots/index.json: %v", err) + } + + if len(dir.Bots) != 2 { + t.Errorf("Expected 2 bots, got %d", len(dir.Bots)) + } +} + +func TestGenerateMatchIndex(t *testing.T) { + now := time.Now() + data := &IndexData{ + GeneratedAt: now, + Bots: []BotData{ + {ID: "bot1", Name: "Bot1"}, + {ID: "bot2", Name: "Bot2"}, + }, + Matches: []MatchData{ + { + ID: "match1", + WinnerID: "bot1", + TurnCount: 200, + EndCondition: "elimination", + CompletedAt: now, + Participants: []MatchParticipant{ + {BotID: "bot1", Score: 5, Won: true}, + {BotID: "bot2", Score: 2, Won: false}, + }, + }, + }, + } + + tmpDir := t.TempDir() + matchesDir := filepath.Join(tmpDir, "data", "matches") + if err := os.MkdirAll(matchesDir, 0755); err != nil { + t.Fatalf("Failed to create matches dir: %v", err) + } + + botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"} + if err := generateMatchIndex(data, tmpDir, botNameMap); err != nil { + t.Fatalf("generateMatchIndex failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(matchesDir, "index.json")) + if err != nil { + t.Fatalf("Failed to read matches/index.json: %v", err) + } + + var index MatchIndex + if err := json.Unmarshal(content, &index); err != nil { + t.Fatalf("Failed to parse matches/index.json: %v", err) + } + + if len(index.Matches) != 1 { + t.Errorf("Expected 1 match, got %d", len(index.Matches)) + } + if index.Matches[0].ID != "match1" { + t.Errorf("Match ID: got %q, want %q", index.Matches[0].ID, "match1") + } + if index.Matches[0].Turns != 200 { + t.Errorf("Match turns: got %d, want %d", index.Matches[0].Turns, 200) + } +} + +func TestGeneratePlaylists(t *testing.T) { + now := time.Now() + data := &IndexData{ + GeneratedAt: now, + Bots: []BotData{ + {ID: "bot1", Name: "Bot1"}, + {ID: "bot2", Name: "Bot2"}, + }, + Matches: []MatchData{ + { + ID: "match1", + WinnerID: "bot1", + TurnCount: 200, + EndCondition: "elimination", + CompletedAt: now, + Participants: []MatchParticipant{ + {BotID: "bot1", Score: 3, Won: true}, + {BotID: "bot2", Score: 2, Won: false}, // Close finish (diff = 1) + }, + }, + { + ID: "match2", + WinnerID: "bot2", + TurnCount: 150, + EndCondition: "dominance", + CompletedAt: now.Add(-time.Hour), + Participants: []MatchParticipant{ + {BotID: "bot1", Score: 0, Won: false}, + {BotID: "bot2", Score: 10, Won: true}, // Not close (diff = 10) + }, + }, + }, + } + + tmpDir := t.TempDir() + playlistsDir := filepath.Join(tmpDir, "data", "playlists") + if err := os.MkdirAll(playlistsDir, 0755); err != nil { + t.Fatalf("Failed to create playlists dir: %v", err) + } + + botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"} + if err := generatePlaylists(data, tmpDir, botNameMap); err != nil { + t.Fatalf("generatePlaylists failed: %v", err) + } + + // Check closest-finishes playlist + content, err := os.ReadFile(filepath.Join(playlistsDir, "closest-finishes.json")) + if err != nil { + t.Fatalf("Failed to read closest-finishes.json: %v", err) + } + + var playlist Playlist + if err := json.Unmarshal(content, &playlist); err != nil { + t.Fatalf("Failed to parse closest-finishes.json: %v", err) + } + + // Should only include match1 (close finish) + if len(playlist.Matches) != 1 { + t.Errorf("closest-finishes: expected 1 match, got %d", len(playlist.Matches)) + } +} + +func TestFilterMatches(t *testing.T) { + matches := []MatchData{ + {ID: "m1", TurnCount: 100}, + {ID: "m2", TurnCount: 200}, + {ID: "m3", TurnCount: 300}, + } + + filtered := filterMatches(matches, func(m MatchData) bool { + return m.TurnCount >= 200 + }) + + if len(filtered) != 2 { + t.Errorf("Expected 2 matches, got %d", len(filtered)) + } +} + +func TestRound1(t *testing.T) { + tests := []struct { + input float64 + expected float64 + }{ + {75.0, 75.0}, + {75.55, 75.6}, + {75.54, 75.5}, + {0.0, 0.0}, + {99.99, 100.0}, + } + + for _, tt := range tests { + result := round1(tt.input) + if result != tt.expected { + t.Errorf("round1(%f) = %f, want %f", tt.input, result, tt.expected) + } + } +} + +func TestComputeTopPredictors(t *testing.T) { + stats := []PredictorStats{ + {PredictorID: "p1", Correct: 10, Incorrect: 2, Streak: 5, BestStreak: 8}, + {PredictorID: "p2", Correct: 8, Incorrect: 3, Streak: 2, BestStreak: 5}, + {PredictorID: "p3", Correct: 15, Incorrect: 5, Streak: 3, BestStreak: 10}, + } + + top := computeTopPredictors(stats) + + // Should return all 3 if under 50 + if len(top) != 3 { + t.Errorf("Expected 3 predictors, got %d", len(top)) + } +} + +func TestWriteJSON(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.json") + + data := map[string]string{"key": "value"} + if err := writeJSON(path, data); err != nil { + t.Fatalf("writeJSON failed: %v", err) + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + // Verify it's valid JSON with proper formatting + var result map[string]string + if err := json.Unmarshal(content, &result); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if result["key"] != "value" { + t.Errorf("JSON content: got %q, want %q", result["key"], "value") + } + + // Verify indentation (should contain newlines) + if len(content) < 20 { + t.Errorf("JSON seems unformatted: %q", string(content)) + } +}