Index builder: - Add slog import for structured logging - Improve fetchEvolutionMeta to return empty meta instead of error when programs table is empty - Add logging to show evolution system status (running vs not initialized) - Add logging in generateEvolutionMeta to show when evolution data is written Evolver: - Add automatic schema initialization and population seeding in RunEvolutionLoop - Programs table is now automatically seeded with 6 initial strategy bots on startup - Log seeding status to indicate whether programs table was already initialized These changes ensure the evolution system properly initializes when deployed and provides better visibility into its status via structured logging. Closes: bf-4zde
2326 lines
67 KiB
Go
2326 lines
67 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"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"`
|
||
DebugPublic bool `json:"debug_public"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_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"`
|
||
MapID string `json:"map_id,omitempty"`
|
||
Turns int `json:"turns"`
|
||
EndReason string `json:"end_reason"`
|
||
Enriched bool `json:"enriched"`
|
||
CombatTurns int `json:"combat_turns"` // turns with ≥1 enemy-kill combat death
|
||
}
|
||
|
||
// 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, db *sql.DB, cfg *Config) 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, cfg); err != nil {
|
||
return fmt.Errorf("bot profiles: %w", err)
|
||
}
|
||
|
||
// Generate matches/index.json
|
||
if err := generateMatchIndex(data, outputDir, botNameMap, cfg); 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 predictions/open.json
|
||
if err := generatePredictionsOpen(data, outputDir); err != nil {
|
||
return fmt.Errorf("predictions open: %w", err)
|
||
}
|
||
|
||
// Generate rivalries (data/meta/rivalries.json)
|
||
rivalries := computeRivalries(data, botNameMap)
|
||
if err := generateRivalriesIndex(rivalries, outputDir); err != nil {
|
||
return fmt.Errorf("rivalries index: %w", err)
|
||
}
|
||
|
||
// Generate playlists
|
||
if err := generatePlaylists(data, outputDir, botNameMap); err != nil {
|
||
return fmt.Errorf("playlists: %w", err)
|
||
}
|
||
|
||
// Persist playlists to DB for incremental queries and R2 pruning exemptions
|
||
if db != nil {
|
||
if err := persistGeneratedPlaylists(context.Background(), db, outputDir); err != nil {
|
||
// Non-fatal: playlists are still written as JSON files
|
||
fmt.Fprintf(os.Stderr, "persist playlists to DB: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// Generate maps/index.json and maps/{map_id}.json
|
||
if err := generateMapsIndex(data, outputDir); err != nil {
|
||
return fmt.Errorf("maps index: %w", err)
|
||
}
|
||
|
||
// Generate archetypes (data/meta/archetypes.json)
|
||
if err := generateArchetypes(data, outputDir); err != nil {
|
||
return fmt.Errorf("archetypes: %w", err)
|
||
}
|
||
|
||
// Generate meta directory index (data/meta/index.json)
|
||
if err := generateMetaIndex(outputDir); err != nil {
|
||
return fmt.Errorf("meta index: %w", err)
|
||
}
|
||
|
||
// Generate community hints (data/evolution/community_hints.json)
|
||
if err := generateCommunityHints(data, outputDir); err != nil {
|
||
return fmt.Errorf("community hints: %w", err)
|
||
}
|
||
|
||
// Generate evolution meta (data/evolution/meta.json)
|
||
if err := generateEvolutionMeta(data, outputDir); err != nil {
|
||
return fmt.Errorf("evolution meta: %w", err)
|
||
}
|
||
|
||
// Generate lineage (data/evolution/lineage.json)
|
||
if err := generateLineage(data, outputDir); err != nil {
|
||
return fmt.Errorf("lineage: %w", err)
|
||
}
|
||
|
||
// Generate per-match feedback (data/matches/{id}/feedback.json)
|
||
if err := generateMatchFeedback(data, outputDir); err != nil {
|
||
return fmt.Errorf("match feedback: %w", err)
|
||
}
|
||
|
||
// Generate sitemap.xml (final pass, written alongside leaderboard.json)
|
||
siteURL := cfg.SiteURL
|
||
if siteURL == "" {
|
||
siteURL = "https://aicodebattle.com"
|
||
}
|
||
if err := generateSitemap(data, outputDir, siteURL); err != nil {
|
||
return fmt.Errorf("sitemap: %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, cfg *Config) 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, cfg)
|
||
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,
|
||
DebugPublic: bot.DebugPublic,
|
||
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, cfg *Config) error {
|
||
summaries := make([]MatchSummary, 0, len(data.Matches))
|
||
for _, m := range data.Matches {
|
||
summaries = append(summaries, matchToSummary(m, data, cfg))
|
||
}
|
||
|
||
// Sort matches by combat_turns descending so the most combat-heavy
|
||
// matches surface first in the UI.
|
||
sort.Slice(summaries, func(i, j int) bool {
|
||
return summaries[i].CombatTurns > summaries[j].CombatTurns
|
||
})
|
||
|
||
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, cfg *Config) 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.Won,
|
||
})
|
||
}
|
||
|
||
enriched := isMatchEnriched(m.ID, cfg)
|
||
|
||
return MatchSummary{
|
||
ID: m.ID,
|
||
CompletedAt: m.CompletedAt.Format(time.RFC3339),
|
||
Participants: participants,
|
||
WinnerID: m.WinnerID,
|
||
MapID: m.MapID,
|
||
Turns: m.TurnCount,
|
||
EndReason: m.EndCondition,
|
||
Enriched: enriched,
|
||
CombatTurns: m.CombatTurns,
|
||
}
|
||
}
|
||
|
||
// isMatchEnriched checks if a match has AI commentary available on R2.
|
||
// Returns true if the commentary file exists in R2.
|
||
func isMatchEnriched(matchID string, cfg *Config) bool {
|
||
if cfg == nil || cfg.R2Endpoint == "" || cfg.R2BucketName == "" {
|
||
return false
|
||
}
|
||
|
||
r2Client, err := getR2Client(cfg)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
commentaryKey := fmt.Sprintf("commentary/%s.json", matchID)
|
||
exists, err := r2Client.objectExists(context.Background(), commentaryKey)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return exists
|
||
}
|
||
|
||
func generateSeriesIndex(data *IndexData, outputDir string) error {
|
||
seriesDir := filepath.Join(outputDir, "data", "series")
|
||
|
||
for _, s := range data.Series {
|
||
if err := writeJSON(filepath.Join(seriesDir, fmt.Sprintf("%d.json", s.ID)), s); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
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(seriesDir, "index.json"), index)
|
||
}
|
||
|
||
func generateSeasonsIndex(data *IndexData, outputDir string) error {
|
||
seasonsDir := filepath.Join(outputDir, "data", "seasons")
|
||
|
||
for _, s := range data.Seasons {
|
||
if err := writeJSON(filepath.Join(seasonsDir, fmt.Sprintf("%d.json", s.ID)), s); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
var activeSeason *SeasonData
|
||
for i := range data.Seasons {
|
||
if data.Seasons[i].Status == "active" {
|
||
activeSeason = &data.Seasons[i]
|
||
break
|
||
}
|
||
}
|
||
|
||
type SeasonsIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
ActiveSeason *SeasonData `json:"active_season"`
|
||
Seasons []SeasonData `json:"seasons"`
|
||
}
|
||
|
||
index := SeasonsIndex{
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
ActiveSeason: activeSeason,
|
||
Seasons: data.Seasons,
|
||
}
|
||
|
||
return writeJSON(filepath.Join(seasonsDir, "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)
|
||
}
|
||
|
||
// generatePredictionsOpen creates data/predictions/open.json with upcoming
|
||
// predictable matches (top-20 vs top-20, rivalry matches, series games,
|
||
// evolved bot vs top-10).
|
||
func generatePredictionsOpen(data *IndexData, outputDir string) error {
|
||
type OpenMatchEntry struct {
|
||
MatchID string `json:"match_id"`
|
||
BotA string `json:"bot_a"`
|
||
BotB string `json:"bot_b"`
|
||
ARating int `json:"a_rating"`
|
||
BRating int `json:"b_rating"`
|
||
OpenUntil string `json:"open_until"`
|
||
HeadToHeadRecord *string `json:"head_to_head_record,omitempty"`
|
||
}
|
||
|
||
type OpenPredictionsIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Matches []OpenMatchEntry `json:"matches"`
|
||
}
|
||
|
||
entries := make([]OpenMatchEntry, 0, len(data.OpenPredictionMatches))
|
||
for _, m := range data.OpenPredictionMatches {
|
||
// Open until 5 minutes after creation (typical execution time)
|
||
openUntil := m.CreatedAt.Add(5 * time.Minute).Format(time.RFC3339)
|
||
|
||
entries = append(entries, OpenMatchEntry{
|
||
MatchID: m.MatchID,
|
||
BotA: m.BotAName,
|
||
BotB: m.BotBName,
|
||
ARating: int(m.ARating),
|
||
BRating: int(m.BRating),
|
||
OpenUntil: openUntil,
|
||
HeadToHeadRecord: m.HeadToHeadRecord,
|
||
})
|
||
}
|
||
|
||
index := OpenPredictionsIndex{
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Matches: entries,
|
||
}
|
||
|
||
return writeJSON(filepath.Join(outputDir, "data", "predictions", "open.json"), index)
|
||
}
|
||
|
||
func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error {
|
||
playlistsDir := filepath.Join(outputDir, "data", "playlists")
|
||
|
||
// Pre-build lookup maps for O(1) playlist curation instead of O(n^2) per match.
|
||
firstMatchPerBot := buildFirstMatchPerBot(data.Matches)
|
||
pairFrequency := buildPairFrequency(data.Matches)
|
||
|
||
type playlistDef struct {
|
||
slug string
|
||
title string
|
||
description string
|
||
category string
|
||
filter func(MatchData) bool
|
||
sort func([]MatchData)
|
||
}
|
||
|
||
defs := []playlistDef{
|
||
{
|
||
slug: "closest-finishes",
|
||
title: "Closest Finishes",
|
||
description: "Matches decided by the thinnest margins — nail-biters to the very end",
|
||
category: "close_games",
|
||
filter: func(m MatchData) bool {
|
||
if len(m.Participants) < 2 || m.WinnerID == "" {
|
||
return false
|
||
}
|
||
return minScoreDiff(m) <= 2
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByScoreDiff(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "biggest-upsets",
|
||
title: "Biggest Upsets",
|
||
description: "Lower-rated bots triumph against higher-rated opponents",
|
||
category: "upsets",
|
||
filter: func(m MatchData) bool {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return false
|
||
}
|
||
return ratingUpsetMagnitude(m) >= 100
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByUpsetMagnitude(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "best-comebacks",
|
||
title: "Best Comebacks",
|
||
description: "Bots that were down but never out — dramatic turnarounds and improbable victories",
|
||
category: "comebacks",
|
||
filter: func(m MatchData) bool {
|
||
return isComeback(m)
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return turnaroundMagnitude(matches[i]) > turnaroundMagnitude(matches[j])
|
||
})
|
||
},
|
||
},
|
||
{
|
||
slug: "marathon-matches",
|
||
title: "Marathon Matches",
|
||
description: "The longest, most grueling matches — endurance-tested battles",
|
||
category: "long_games",
|
||
filter: func(m MatchData) bool {
|
||
return m.TurnCount >= 300
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByTurnCount(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "highest-rated",
|
||
title: "Clash of Titans",
|
||
description: "Matches between the highest-rated opponents on the ladder",
|
||
category: "featured",
|
||
filter: func(m MatchData) bool {
|
||
if len(m.Participants) < 2 {
|
||
return false
|
||
}
|
||
return combinedRating(m) >= 3200
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByCombinedRating(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "evolution-breakthroughs",
|
||
title: "Evolution Breakthroughs",
|
||
description: "Evolved bots defeating top-rated opponents — AI strategy milestones",
|
||
category: "featured",
|
||
filter: func(m MatchData) bool {
|
||
return isEvolutionBreakthrough(m, data)
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByUpsetMagnitude(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "rivalry-classics",
|
||
title: "Rivalry Classics",
|
||
description: "The most closely contested matchups between frequent opponents",
|
||
category: "rivalry",
|
||
filter: func(m MatchData) bool {
|
||
return isRivalryMatchFast(m, pairFrequency)
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return minScoreDiff(matches[i]) < minScoreDiff(matches[j])
|
||
})
|
||
},
|
||
},
|
||
{
|
||
slug: "domination",
|
||
title: "Total Domination",
|
||
description: "One-sided victories where the winner crushed all opposition",
|
||
category: "domination",
|
||
filter: func(m MatchData) bool {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return false
|
||
}
|
||
return maxScoreDiff(m) >= 5
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return maxScoreDiff(matches[i]) > maxScoreDiff(matches[j])
|
||
})
|
||
},
|
||
},
|
||
{
|
||
slug: "new-bot-debuts",
|
||
title: "New Bot Debuts",
|
||
description: "First matches of newly registered bots — watch their opening games",
|
||
category: "tutorial",
|
||
filter: func(m MatchData) bool {
|
||
return isNewBotDebutFast(m, firstMatchPerBot)
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
// Newest debuts first
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return matches[i].CompletedAt.After(matches[j].CompletedAt)
|
||
})
|
||
},
|
||
},
|
||
{
|
||
slug: "season-highlights",
|
||
title: "Season Highlights",
|
||
description: "Top matches from the current season ranked by excitement",
|
||
category: "season",
|
||
filter: func(m MatchData) bool {
|
||
return isCurrentSeasonMatch(m, data)
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByInterestScore(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "most-combat",
|
||
title: "Most Combat",
|
||
description: "Matches with the most turns featuring enemy kills — the bloodiest battles on the grid",
|
||
category: "featured",
|
||
filter: func(m MatchData) bool {
|
||
return m.WinnerID != "" && m.CombatTurns > 0
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByCombatTurns(matches)
|
||
},
|
||
},
|
||
{
|
||
slug: "featured",
|
||
title: "Featured Matches",
|
||
description: "Recent highlights from the ladder",
|
||
category: "featured",
|
||
filter: func(m MatchData) bool {
|
||
return m.WinnerID != ""
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
// Most recent first (already sorted by completed_at DESC from DB)
|
||
},
|
||
},
|
||
{
|
||
slug: "best-of-week",
|
||
title: "Best of the Week",
|
||
description: "This week's top matches ranked by excitement: close finishes, upsets, marathon battles, and elite clashes",
|
||
category: "weekly",
|
||
filter: func(m MatchData) bool {
|
||
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
|
||
return m.CompletedAt.After(weekAgo) && m.WinnerID != ""
|
||
},
|
||
sort: func(matches []MatchData) {
|
||
sortByInterestScore(matches)
|
||
},
|
||
},
|
||
}
|
||
|
||
var summaries []PlaylistSummary
|
||
|
||
for _, def := range defs {
|
||
// Special handling for best-of-week: use curated selection with tags
|
||
if def.slug == "best-of-week" {
|
||
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
|
||
curated := curateWeeklyHighlights(data.Matches, weekAgo)
|
||
curatedMatches := make([]MatchData, 0, len(curated))
|
||
tags := make(map[string]string, len(curated))
|
||
for _, c := range curated {
|
||
curatedMatches = append(curatedMatches, c.Match)
|
||
tags[c.Match.ID] = c.Tag
|
||
}
|
||
|
||
if err := writePlaylistWithTags(playlistsDir, def.slug+".json", def.title, def.description, def.category, curatedMatches, tags, data); err != nil {
|
||
return err
|
||
}
|
||
|
||
var thumbMatchID string
|
||
if len(curatedMatches) > 0 {
|
||
thumbMatchID = curatedMatches[0].ID
|
||
}
|
||
summaries = append(summaries, PlaylistSummary{
|
||
Slug: def.slug,
|
||
Title: def.title,
|
||
Description: def.description,
|
||
Category: def.category,
|
||
MatchCount: len(curatedMatches),
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
ThumbnailMatchID: thumbMatchID,
|
||
})
|
||
continue
|
||
}
|
||
|
||
filtered := filterMatches(data.Matches, def.filter)
|
||
if def.sort != nil {
|
||
def.sort(filtered)
|
||
}
|
||
filtered = filtered[:min(20, len(filtered))]
|
||
|
||
if err := writePlaylist(playlistsDir, def.slug+".json", def.title, def.description, def.category, filtered, data); err != nil {
|
||
return err
|
||
}
|
||
|
||
var thumbMatchID string
|
||
if len(filtered) > 0 {
|
||
thumbMatchID = filtered[0].ID
|
||
}
|
||
summaries = append(summaries, PlaylistSummary{
|
||
Slug: def.slug,
|
||
Title: def.title,
|
||
Description: def.description,
|
||
Category: def.category,
|
||
MatchCount: len(filtered),
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
ThumbnailMatchID: thumbMatchID,
|
||
})
|
||
}
|
||
|
||
index := PlaylistIndex{
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Playlists: summaries,
|
||
}
|
||
return writeJSON(filepath.Join(playlistsDir, "index.json"), index)
|
||
}
|
||
|
||
type PlaylistIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Playlists []PlaylistSummary `json:"playlists"`
|
||
}
|
||
|
||
type PlaylistSummary struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Category string `json:"category"`
|
||
MatchCount int `json:"match_count"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
ThumbnailMatchID string `json:"thumbnail_match_id,omitempty"`
|
||
}
|
||
|
||
type Playlist struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Category string `json:"category"`
|
||
MatchCount int `json:"match_count"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
Matches []PlaylistMatch `json:"matches"`
|
||
}
|
||
|
||
type PlaylistMatch struct {
|
||
MatchID string `json:"match_id"`
|
||
Order int `json:"order"`
|
||
Title string `json:"title,omitempty"`
|
||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||
CurationTag string `json:"curation_tag,omitempty"`
|
||
Participants []MatchParticipantSummary `json:"participants,omitempty"`
|
||
Score string `json:"score,omitempty"`
|
||
Turns int `json:"turns,omitempty"`
|
||
EndReason string `json:"end_reason,omitempty"`
|
||
CompletedAt string `json:"completed_at,omitempty"`
|
||
}
|
||
|
||
// curatedWeeklyMatch is a match selected by the weekly curation algorithm
|
||
// with a tag explaining why it was selected.
|
||
type curatedWeeklyMatch struct {
|
||
Match MatchData
|
||
Tag string
|
||
}
|
||
|
||
// curateWeeklyHighlights selects the best matches from the past 7 days
|
||
// using explicit criteria: upsets, elite clashes, marathon battles, closest finishes.
|
||
// It processes specific criteria first so distinctive matches aren't consumed
|
||
// by generic tags. It returns deduplicated matches tagged with their selection reason.
|
||
func curateWeeklyHighlights(matches []MatchData, cutoff time.Time) []curatedWeeklyMatch {
|
||
seen := make(map[string]string) // match_id -> tag (first selection reason)
|
||
maxPerCriterion := 7
|
||
|
||
recent := filterMatches(matches, func(m MatchData) bool {
|
||
return m.CompletedAt.After(cutoff) && m.WinnerID != "" && len(m.Participants) >= 2
|
||
})
|
||
|
||
// 1. Biggest upsets first (most distinctive — underdog victories)
|
||
upsetMatches := make([]MatchData, len(recent))
|
||
copy(upsetMatches, recent)
|
||
sortByUpsetMagnitude(upsetMatches)
|
||
for i, m := range upsetMatches {
|
||
if i >= maxPerCriterion {
|
||
break
|
||
}
|
||
mag := ratingUpsetMagnitude(m)
|
||
if mag < 50 {
|
||
continue
|
||
}
|
||
if _, exists := seen[m.ID]; !exists {
|
||
seen[m.ID] = fmt.Sprintf("Upset victory (underdog by %d rating)", mag)
|
||
}
|
||
}
|
||
|
||
// 2. Highest-rated opponents (elite clashes)
|
||
ratedMatches := make([]MatchData, len(recent))
|
||
copy(ratedMatches, recent)
|
||
sortByCombinedRating(ratedMatches)
|
||
for i, m := range ratedMatches {
|
||
if i >= maxPerCriterion {
|
||
break
|
||
}
|
||
cr := int(combinedRating(m))
|
||
if cr < 3000 {
|
||
continue
|
||
}
|
||
if _, exists := seen[m.ID]; !exists {
|
||
seen[m.ID] = fmt.Sprintf("Elite clash (combined rating: %d)", cr)
|
||
}
|
||
}
|
||
|
||
// 3. Most turns (longest endurance battles)
|
||
longMatches := make([]MatchData, len(recent))
|
||
copy(longMatches, recent)
|
||
sortByTurnCount(longMatches)
|
||
for i, m := range longMatches {
|
||
if i >= maxPerCriterion {
|
||
break
|
||
}
|
||
if m.TurnCount < 300 {
|
||
continue
|
||
}
|
||
if _, exists := seen[m.ID]; !exists {
|
||
seen[m.ID] = fmt.Sprintf("Marathon battle (%d turns)", m.TurnCount)
|
||
}
|
||
}
|
||
|
||
// 4. Closest results last (most generic — fills remaining slots)
|
||
closeMatches := make([]MatchData, len(recent))
|
||
copy(closeMatches, recent)
|
||
sortByScoreDiff(closeMatches)
|
||
for i, m := range closeMatches {
|
||
if i >= maxPerCriterion {
|
||
break
|
||
}
|
||
diff := minScoreDiff(m)
|
||
if _, exists := seen[m.ID]; !exists {
|
||
seen[m.ID] = fmt.Sprintf("Closest finish (score diff: %d)", diff)
|
||
}
|
||
}
|
||
|
||
// Build result in criterion order: upsets, elite, marathon, closest
|
||
var result []curatedWeeklyMatch
|
||
for _, m := range recent {
|
||
if tag, ok := seen[m.ID]; ok {
|
||
result = append(result, curatedWeeklyMatch{Match: m, Tag: tag})
|
||
}
|
||
}
|
||
|
||
if len(result) > 20 {
|
||
result = result[:20]
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
func writePlaylist(dir, filename, title, description, category string, matches []MatchData, data *IndexData) error {
|
||
slug := filename[:len(filename)-5]
|
||
pm := make([]PlaylistMatch, 0, len(matches))
|
||
for i, m := range matches {
|
||
pm = append(pm, buildPlaylistMatch(m, i, data, ""))
|
||
}
|
||
|
||
playlist := Playlist{
|
||
Slug: slug,
|
||
Title: title,
|
||
Description: description,
|
||
Category: category,
|
||
MatchCount: len(pm),
|
||
CreatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Matches: pm,
|
||
}
|
||
|
||
return writeJSON(filepath.Join(dir, filename), playlist)
|
||
}
|
||
|
||
func writePlaylistWithTags(dir, filename, title, description, category string, matches []MatchData, tags map[string]string, data *IndexData) error {
|
||
slug := filename[:len(filename)-5]
|
||
pm := make([]PlaylistMatch, 0, len(matches))
|
||
for i, m := range matches {
|
||
pm = append(pm, buildPlaylistMatch(m, i, data, tags[m.ID]))
|
||
}
|
||
|
||
playlist := Playlist{
|
||
Slug: slug,
|
||
Title: title,
|
||
Description: description,
|
||
Category: category,
|
||
MatchCount: len(pm),
|
||
CreatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Matches: pm,
|
||
}
|
||
|
||
return writeJSON(filepath.Join(dir, filename), playlist)
|
||
}
|
||
|
||
func formatMatchTitle(m MatchData, data *IndexData) string {
|
||
names := make([]string, 0, len(m.Participants))
|
||
scores := make([]int, 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
|
||
}
|
||
}
|
||
names = append(names, name)
|
||
scores = append(scores, p.Score)
|
||
}
|
||
if len(names) == 2 {
|
||
return fmt.Sprintf("%s %d – %d %s", names[0], scores[0], scores[1], names[1])
|
||
}
|
||
return fmt.Sprintf("%s (%d players)", m.ID[:min(8, len(m.ID))], len(names))
|
||
}
|
||
|
||
func buildPlaylistMatch(m MatchData, order int, data *IndexData, curationTag string) PlaylistMatch {
|
||
participants := make([]MatchParticipantSummary, 0, len(m.Participants))
|
||
scoreParts := make([]string, 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.Won,
|
||
})
|
||
scoreParts = append(scoreParts, fmt.Sprintf("%d", p.Score))
|
||
}
|
||
title := formatMatchTitle(m, data)
|
||
completedAt := ""
|
||
if !m.CompletedAt.IsZero() {
|
||
completedAt = m.CompletedAt.Format(time.RFC3339)
|
||
}
|
||
thumbnailURL := fmt.Sprintf("https://r2.aicodebattle.com/thumbnails/%s.png", m.ID)
|
||
return PlaylistMatch{
|
||
MatchID: m.ID,
|
||
Order: order,
|
||
Title: title,
|
||
ThumbnailURL: thumbnailURL,
|
||
CurationTag: curationTag,
|
||
Participants: participants,
|
||
Score: strings.Join(scoreParts, "-"),
|
||
Turns: m.TurnCount,
|
||
EndReason: m.EndCondition,
|
||
CompletedAt: completedAt,
|
||
}
|
||
}
|
||
|
||
func ratingUpsetMagnitude(m MatchData) int {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return 0
|
||
}
|
||
var winnerRating, bestLoserRating float64
|
||
found := false
|
||
for _, p := range m.Participants {
|
||
if p.Won {
|
||
winnerRating = p.PreMatchRating
|
||
found = true
|
||
}
|
||
}
|
||
if !found || winnerRating == 0 {
|
||
return 0
|
||
}
|
||
for _, p := range m.Participants {
|
||
if !p.Won && p.PreMatchRating > bestLoserRating {
|
||
bestLoserRating = p.PreMatchRating
|
||
}
|
||
}
|
||
if bestLoserRating == 0 {
|
||
return 0
|
||
}
|
||
return int(bestLoserRating - winnerRating)
|
||
}
|
||
|
||
func combinedRating(m MatchData) float64 {
|
||
total := 0.0
|
||
for _, p := range m.Participants {
|
||
total += p.PreMatchRating
|
||
}
|
||
return total
|
||
}
|
||
|
||
func interestScore(m MatchData) float64 {
|
||
score := 0.0
|
||
// Close finishes are interesting
|
||
if len(m.Participants) >= 2 {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
if minDiff <= 1 {
|
||
score += 3.0
|
||
} else if minDiff <= 2 {
|
||
score += 2.0
|
||
}
|
||
}
|
||
// Upsets are interesting
|
||
upset := ratingUpsetMagnitude(m)
|
||
if upset >= 200 {
|
||
score += 4.0
|
||
} else if upset >= 100 {
|
||
score += 2.0
|
||
}
|
||
// Long matches are interesting
|
||
if m.TurnCount >= 400 {
|
||
score += 2.0
|
||
} else if m.TurnCount >= 300 {
|
||
score += 1.0
|
||
}
|
||
// High-rated opponents
|
||
cr := combinedRating(m)
|
||
if cr >= 3400 {
|
||
score += 2.0
|
||
} else if cr >= 3200 {
|
||
score += 1.0
|
||
}
|
||
// Combat-heavy matches are exciting
|
||
if m.CombatTurns >= 30 {
|
||
score += 2.0
|
||
} else if m.CombatTurns >= 15 {
|
||
score += 1.0
|
||
}
|
||
return score
|
||
}
|
||
|
||
func sortByScoreDiff(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return minScoreDiff(matches[i]) < minScoreDiff(matches[j])
|
||
})
|
||
}
|
||
|
||
func sortByUpsetMagnitude(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return ratingUpsetMagnitude(matches[i]) > ratingUpsetMagnitude(matches[j])
|
||
})
|
||
}
|
||
|
||
func sortByTurnCount(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return matches[i].TurnCount > matches[j].TurnCount
|
||
})
|
||
}
|
||
|
||
func sortByCombinedRating(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return combinedRating(matches[i]) > combinedRating(matches[j])
|
||
})
|
||
}
|
||
|
||
func sortByCombatTurns(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return matches[i].CombatTurns > matches[j].CombatTurns
|
||
})
|
||
}
|
||
|
||
func sortByInterestScore(matches []MatchData) {
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return interestScore(matches[i]) > interestScore(matches[j])
|
||
})
|
||
}
|
||
|
||
func minScoreDiff(m MatchData) int {
|
||
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
|
||
}
|
||
|
||
func sortSlice[T any](s []T, less func(i, j int) bool) {
|
||
for i := 0; i < len(s)-1; i++ {
|
||
for j := i + 1; j < len(s); j++ {
|
||
if less(j, i) {
|
||
s[i], s[j] = s[j], s[i]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// maxScoreDiff returns the maximum score difference between winner and any loser
|
||
func maxScoreDiff(m MatchData) int {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return 0
|
||
}
|
||
var winnerScore int
|
||
for _, p := range m.Participants {
|
||
if p.Won {
|
||
winnerScore = p.Score
|
||
break
|
||
}
|
||
}
|
||
maxDiff := 0
|
||
for _, p := range m.Participants {
|
||
if !p.Won {
|
||
diff := winnerScore - p.Score
|
||
if diff > maxDiff {
|
||
maxDiff = diff
|
||
}
|
||
}
|
||
}
|
||
return maxDiff
|
||
}
|
||
|
||
// isNewBotDebut detects the first match of each bot by finding the earliest
|
||
// completed match for each bot.
|
||
func isNewBotDebut(m MatchData, data *IndexData) bool {
|
||
if m.WinnerID == "" {
|
||
return false
|
||
}
|
||
for _, p := range m.Participants {
|
||
earliest := true
|
||
for _, other := range data.Matches {
|
||
if other.ID == m.ID || other.CompletedAt.IsZero() {
|
||
continue
|
||
}
|
||
for _, op := range other.Participants {
|
||
if op.BotID == p.BotID {
|
||
if other.CompletedAt.Before(m.CompletedAt) {
|
||
earliest = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if earliest {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isCurrentSeasonMatch checks if a match belongs to the current active season.
|
||
func isCurrentSeasonMatch(m MatchData, data *IndexData) bool {
|
||
for _, s := range data.Seasons {
|
||
if s.Status != "active" {
|
||
continue
|
||
}
|
||
// Check if match falls within season date range
|
||
if m.CompletedAt.After(s.StartsAt) || m.CompletedAt.Equal(s.StartsAt) {
|
||
if s.EndsAt.IsZero() || m.CompletedAt.Before(s.EndsAt) {
|
||
return m.WinnerID != ""
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isComeback detects matches where the winner was behind on score at some point
|
||
// but rallied to win. Uses a heuristic: winner scored more than loser despite
|
||
// having a lower pre-match rating (unlikely comeback) or the match had many turns
|
||
// (late-game rally) with a close final score.
|
||
func isComeback(m MatchData) bool {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return false
|
||
}
|
||
// An upset with a close score is a comeback
|
||
upset := ratingUpsetMagnitude(m)
|
||
scoreDiff := minScoreDiff(m)
|
||
return upset >= 80 && scoreDiff <= 3
|
||
}
|
||
|
||
// turnaroundMagnitude measures how dramatic a comeback was.
|
||
// Higher = more surprising turnaround.
|
||
func turnaroundMagnitude(m MatchData) float64 {
|
||
upset := float64(ratingUpsetMagnitude(m))
|
||
closeFactor := 1.0 / float64(max(minScoreDiff(m), 1))
|
||
turnFactor := float64(m.TurnCount) / 500.0
|
||
return upset*closeFactor + turnFactor*50
|
||
}
|
||
|
||
// isEvolutionBreakthrough detects matches where an evolved bot beat a high-rated opponent.
|
||
func isEvolutionBreakthrough(m MatchData, data *IndexData) bool {
|
||
if m.WinnerID == "" || len(m.Participants) < 2 {
|
||
return false
|
||
}
|
||
var winnerBotID string
|
||
for _, p := range m.Participants {
|
||
if p.Won {
|
||
winnerBotID = p.BotID
|
||
break
|
||
}
|
||
}
|
||
if winnerBotID == "" {
|
||
return false
|
||
}
|
||
winnerEvolved := false
|
||
for _, bot := range data.Bots {
|
||
if bot.ID == winnerBotID && bot.Evolved {
|
||
winnerEvolved = true
|
||
break
|
||
}
|
||
}
|
||
if !winnerEvolved {
|
||
return false
|
||
}
|
||
// Winner must have beaten someone rated >= 1600
|
||
for _, p := range m.Participants {
|
||
if !p.Won && p.PreMatchRating >= 1600 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// buildFirstMatchPerBot returns a map from botID to the matchID of their earliest
|
||
// completed match. O(n*p) where n=matches, p=avg participants.
|
||
func buildFirstMatchPerBot(matches []MatchData) map[string]string {
|
||
first := make(map[string]string)
|
||
firstTime := make(map[string]time.Time)
|
||
for _, m := range matches {
|
||
if m.CompletedAt.IsZero() || m.WinnerID == "" {
|
||
continue
|
||
}
|
||
for _, p := range m.Participants {
|
||
if t, ok := firstTime[p.BotID]; !ok || m.CompletedAt.Before(t) {
|
||
firstTime[p.BotID] = m.CompletedAt
|
||
first[p.BotID] = m.ID
|
||
}
|
||
}
|
||
}
|
||
return first
|
||
}
|
||
|
||
// isNewBotDebutFast checks if any participant's earliest completed match is this one,
|
||
// using a pre-built lookup map.
|
||
func isNewBotDebutFast(m MatchData, firstMatchPerBot map[string]string) bool {
|
||
if m.WinnerID == "" {
|
||
return false
|
||
}
|
||
for _, p := range m.Participants {
|
||
if firstMatchPerBot[p.BotID] == m.ID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// buildPairFrequency returns a map from "botA:botB" (sorted) to the count of
|
||
// 2-player matches between them. O(n) where n=matches.
|
||
func buildPairFrequency(matches []MatchData) map[string]int {
|
||
freq := make(map[string]int)
|
||
for _, m := range matches {
|
||
if len(m.Participants) != 2 {
|
||
continue
|
||
}
|
||
a, b := m.Participants[0].BotID, m.Participants[1].BotID
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
freq[a+":"+b]++
|
||
}
|
||
return freq
|
||
}
|
||
|
||
// isRivalryMatchFast checks if a 2-player match is between frequent opponents,
|
||
// using a pre-built pair frequency map.
|
||
func isRivalryMatchFast(m MatchData, pairFrequency map[string]int) bool {
|
||
if len(m.Participants) != 2 || m.WinnerID == "" {
|
||
return false
|
||
}
|
||
a, b := m.Participants[0].BotID, m.Participants[1].BotID
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
return pairFrequency[a+":"+b] >= 3
|
||
}
|
||
|
||
// isRivalryMatch detects matches between bots that have played each other frequently.
|
||
// Builds a frequency map from all matches and checks if this pair qualifies.
|
||
func isRivalryMatch(m MatchData, data *IndexData) bool {
|
||
if len(m.Participants) != 2 || m.WinnerID == "" {
|
||
return false
|
||
}
|
||
a, b := m.Participants[0].BotID, m.Participants[1].BotID
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
pairKey := a + ":" + b
|
||
|
||
// Count occurrences of this pair across all matches
|
||
count := 0
|
||
for _, other := range data.Matches {
|
||
if len(other.Participants) != 2 {
|
||
continue
|
||
}
|
||
oa, ob := other.Participants[0].BotID, other.Participants[1].BotID
|
||
if oa > ob {
|
||
oa, ob = ob, oa
|
||
}
|
||
if oa+":"+ob == pairKey {
|
||
count++
|
||
}
|
||
}
|
||
return count >= 3
|
||
}
|
||
|
||
// ─── Rivalry Detection (§13.5) ─────────────────────────────────────────────────
|
||
|
||
const (
|
||
rivalryMinMatches = 10 // minimum h2h matches to qualify
|
||
rivalryTopK = 20 // max rivalries to emit
|
||
rivalryRecencyDecay = 0.95 // per-day decay for recency weighting
|
||
)
|
||
|
||
// RivalryEntry represents a detected rivalry pair for data/meta/rivalries.json.
|
||
type RivalryEntry struct {
|
||
BotA RivalryBot `json:"bot_a"`
|
||
BotB RivalryBot `json:"bot_b"`
|
||
TotalMatches int `json:"matches"`
|
||
Record RivalryRecord `json:"record"`
|
||
ClosestMatch string `json:"closest_match,omitempty"`
|
||
LongestStreak *RivalryStreak `json:"longest_streak,omitempty"`
|
||
RecentMatches []string `json:"recent_matches"`
|
||
Narrative string `json:"narrative"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
|
||
type RivalryBot struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type RivalryRecord struct {
|
||
AWins int `json:"a_wins"`
|
||
BWins int `json:"b_wins"`
|
||
Draws int `json:"draws"`
|
||
}
|
||
|
||
type RivalryStreak struct {
|
||
Holder string `json:"holder"`
|
||
Length int `json:"length"`
|
||
}
|
||
|
||
// RivalriesIndex is the top-level structure for data/meta/rivalries.json.
|
||
type RivalriesIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Rivalries []RivalryEntry `json:"rivalries"`
|
||
}
|
||
|
||
// pairKey returns a canonical key for a bot pair (alphabetically ordered).
|
||
func pairKey(a, b string) string {
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
return a + ":" + b
|
||
}
|
||
|
||
type h2hRecord struct {
|
||
botAID, botBID string
|
||
aWins, bWins int
|
||
draws int
|
||
matchDates []time.Time
|
||
matchIDs []string
|
||
scoreDiffs []int
|
||
winnerSeq []string // bot_id of winner per match ("draw" for draws)
|
||
}
|
||
|
||
// computeRivalries builds the h2h matrix from all matches, scores each pair
|
||
// by win-rate balance × recency × total matches, and returns the top K.
|
||
func computeRivalries(data *IndexData, botNameMap map[string]string) []RivalryEntry {
|
||
// Accumulate head-to-head records (only 2-player matches).
|
||
pairs := make(map[string]*h2hRecord)
|
||
|
||
for _, m := range data.Matches {
|
||
if len(m.Participants) != 2 {
|
||
continue
|
||
}
|
||
a, b := m.Participants[0].BotID, m.Participants[1].BotID
|
||
key := pairKey(a, b)
|
||
|
||
rec, ok := pairs[key]
|
||
if !ok {
|
||
// Canonical order: alphabetically first is bot A.
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
rec = &h2hRecord{botAID: a, botBID: b}
|
||
pairs[key] = rec
|
||
}
|
||
|
||
rec.matchIDs = append(rec.matchIDs, m.ID)
|
||
rec.matchDates = append(rec.matchDates, m.PlayedAt)
|
||
|
||
// Score diff for closest match detection.
|
||
if len(m.Participants) == 2 {
|
||
rec.scoreDiffs = append(rec.scoreDiffs, absInt(m.Participants[0].Score-m.Participants[1].Score))
|
||
}
|
||
|
||
switch {
|
||
case m.WinnerID == "":
|
||
rec.draws++
|
||
rec.winnerSeq = append(rec.winnerSeq, "draw")
|
||
case m.WinnerID == rec.botAID:
|
||
rec.aWins++
|
||
rec.winnerSeq = append(rec.winnerSeq, rec.botAID)
|
||
default:
|
||
rec.bWins++
|
||
rec.winnerSeq = append(rec.winnerSeq, rec.botBID)
|
||
}
|
||
}
|
||
|
||
// Score and rank.
|
||
now := data.GeneratedAt
|
||
var candidates []RivalryEntry
|
||
|
||
for _, rec := range pairs {
|
||
total := rec.aWins + rec.bWins + rec.draws
|
||
if total < rivalryMinMatches {
|
||
continue
|
||
}
|
||
|
||
// Win-rate balance: 1.0 for perfect 50/50, approaches 0 for dominant pairs.
|
||
balance := 1.0 - float64(absInt(rec.aWins-rec.bWins))/float64(total)
|
||
|
||
// Recency: weighted sum where recent matches count more.
|
||
var recencyScore float64
|
||
for _, d := range rec.matchDates {
|
||
daysAgo := now.Sub(d).Hours() / 24
|
||
if daysAgo < 0 {
|
||
daysAgo = 0
|
||
}
|
||
recencyScore += math.Pow(rivalryRecencyDecay, daysAgo)
|
||
}
|
||
// Normalise recency to [0, 1] relative to total matches.
|
||
recencyNorm := recencyScore / float64(total)
|
||
|
||
// Final score: balance × recency × log(total) for volume weighting.
|
||
score := balance * recencyNorm * math.Log(float64(total))
|
||
|
||
// Closest match: smallest score diff.
|
||
closestMatch := ""
|
||
if len(rec.scoreDiffs) > 0 {
|
||
minDiff := rec.scoreDiffs[0]
|
||
minIdx := 0
|
||
for i, d := range rec.scoreDiffs {
|
||
if d < minDiff {
|
||
minDiff = d
|
||
minIdx = i
|
||
}
|
||
}
|
||
closestMatch = rec.matchIDs[minIdx]
|
||
}
|
||
|
||
// Longest win streak.
|
||
streak := longestStreak(rec.winnerSeq, rec.botAID, rec.botBID)
|
||
|
||
// Recent match IDs (last 10).
|
||
recentCount := 10
|
||
if len(rec.matchIDs) < recentCount {
|
||
recentCount = len(rec.matchIDs)
|
||
}
|
||
recentMatches := make([]string, recentCount)
|
||
for i := 0; i < recentCount; i++ {
|
||
recentMatches[i] = rec.matchIDs[len(rec.matchIDs)-1-i]
|
||
}
|
||
|
||
aName := botNameMap[rec.botAID]
|
||
bName := botNameMap[rec.botBID]
|
||
|
||
candidates = append(candidates, RivalryEntry{
|
||
BotA: RivalryBot{ID: rec.botAID, Name: aName},
|
||
BotB: RivalryBot{ID: rec.botBID, Name: bName},
|
||
TotalMatches: total,
|
||
Record: RivalryRecord{AWins: rec.aWins, BWins: rec.bWins, Draws: rec.draws},
|
||
ClosestMatch: closestMatch,
|
||
LongestStreak: streak,
|
||
RecentMatches: recentMatches,
|
||
Narrative: buildRivalryNarrative(aName, bName, rec.botAID, rec.botBID, total, rec.aWins, rec.bWins, rec.draws, streak),
|
||
Score: score,
|
||
})
|
||
}
|
||
|
||
// Sort by score descending.
|
||
sort.Slice(candidates, func(i, j int) bool {
|
||
return candidates[i].Score > candidates[j].Score
|
||
})
|
||
|
||
if len(candidates) > rivalryTopK {
|
||
candidates = candidates[:rivalryTopK]
|
||
}
|
||
|
||
return candidates
|
||
}
|
||
|
||
// longestStreak finds the longest consecutive win streak for either bot in the winner sequence.
|
||
func longestStreak(winners []string, botA, botB string) *RivalryStreak {
|
||
if len(winners) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var bestHolder string
|
||
var bestLen int
|
||
var curHolder string
|
||
var curLen int
|
||
|
||
for _, w := range winners {
|
||
if w == "draw" {
|
||
curLen = 0
|
||
curHolder = ""
|
||
continue
|
||
}
|
||
if w == curHolder {
|
||
curLen++
|
||
} else {
|
||
curHolder = w
|
||
curLen = 1
|
||
}
|
||
if curLen > bestLen {
|
||
bestLen = curLen
|
||
bestHolder = curHolder
|
||
}
|
||
}
|
||
|
||
if bestLen < 2 {
|
||
return nil
|
||
}
|
||
return &RivalryStreak{Holder: bestHolder, Length: bestLen}
|
||
}
|
||
|
||
// buildRivalryNarrative generates a template-based narrative from rivalry stats.
|
||
func buildRivalryNarrative(aName, bName, aID, bID string, total, aWins, bWins, draws int, streak *RivalryStreak) string {
|
||
leading := aName
|
||
trailing := bName
|
||
leadWins := aWins
|
||
trailWins := bWins
|
||
if bWins > aWins {
|
||
leading, trailing = trailing, leading
|
||
leadWins, trailWins = trailWins, leadWins
|
||
}
|
||
|
||
switch {
|
||
case aWins == bWins:
|
||
return fmt.Sprintf("%s and %s have met %d times — the series is dead even at %d-%d%s. Every match shifts the balance.",
|
||
aName, bName, total, aWins, bWins, drawSuffix(draws))
|
||
case streak != nil && streak.Length >= 3:
|
||
holderName := streak.Holder
|
||
if streak.Holder == aID {
|
||
holderName = aName
|
||
} else if streak.Holder == bID {
|
||
holderName = bName
|
||
}
|
||
return fmt.Sprintf("%s and %s have met %d times with %s holding a %d-%d edge. %s is currently on a %d-match winning streak.",
|
||
aName, bName, total, leading, leadWins, trailWins, holderName, streak.Length)
|
||
default:
|
||
return fmt.Sprintf("%s and %s have met %d times — %s leads the series %d-%d%s. A rivalry defined by closely contested grid battles.",
|
||
aName, bName, total, leading, leadWins, trailWins, drawSuffix(draws))
|
||
}
|
||
}
|
||
|
||
func drawSuffix(draws int) string {
|
||
if draws == 0 {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf(" (%d draw%s)", draws, pluralS(draws))
|
||
}
|
||
|
||
func pluralS(n int) string {
|
||
if n == 1 {
|
||
return ""
|
||
}
|
||
return "s"
|
||
}
|
||
|
||
func absInt(x int) int {
|
||
if x < 0 {
|
||
return -x
|
||
}
|
||
return x
|
||
}
|
||
|
||
// generateRivalriesIndex writes data/meta/rivalries.json.
|
||
func generateRivalriesIndex(rivalries []RivalryEntry, outputDir string) error {
|
||
metaDir := filepath.Join(outputDir, "data", "meta")
|
||
if err := os.MkdirAll(metaDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
index := RivalriesIndex{
|
||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
Rivalries: rivalries,
|
||
}
|
||
return writeJSON(filepath.Join(metaDir, "rivalries.json"), index)
|
||
}
|
||
|
||
// mapPosition is a grid coordinate used in map geometry output.
|
||
type mapPosition struct {
|
||
Row int `json:"row"`
|
||
Col int `json:"col"`
|
||
}
|
||
|
||
// mapCore is a spawn/core point in a map.
|
||
type mapCore struct {
|
||
Position mapPosition `json:"position"`
|
||
Owner int `json:"owner"`
|
||
}
|
||
|
||
// mapGeometryJSON mirrors the map_json column structure for geometry extraction.
|
||
type mapGeometryJSON struct {
|
||
Walls []mapPosition `json:"walls"`
|
||
Cores []mapCore `json:"cores"`
|
||
EnergyNodes []mapPosition `json:"energy_nodes"`
|
||
}
|
||
|
||
// MapIndexEntry is the per-map summary in maps/index.json.
|
||
type MapIndexEntry 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"`
|
||
NetVotes int `json:"net_votes"` // Sum of +1/-1 votes from map_votes table
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// MapIndexFile represents maps/index.json.
|
||
type MapIndexFile struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Maps []MapIndexEntry `json:"maps"`
|
||
ByPlayerCount map[string][]MapIndexEntry `json:"by_player_count"`
|
||
}
|
||
|
||
// MapDetail represents maps/{map_id}.json — summary metadata plus full geometry.
|
||
type MapDetail 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"`
|
||
NetVotes int `json:"net_votes"` // Sum of +1/-1 votes from map_votes table
|
||
CreatedAt string `json:"created_at"`
|
||
Walls []mapPosition `json:"walls"`
|
||
Cores []mapCore `json:"cores"`
|
||
EnergyNodes []mapPosition `json:"energy_nodes"`
|
||
}
|
||
|
||
func generateMapsIndex(data *IndexData, outputDir string) error {
|
||
mapsDir := filepath.Join(outputDir, "maps")
|
||
if err := os.MkdirAll(mapsDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
entries := make([]MapIndexEntry, 0, len(data.Maps))
|
||
byPlayerCount := make(map[string][]MapIndexEntry)
|
||
|
||
for _, m := range data.Maps {
|
||
entry := MapIndexEntry{
|
||
MapID: m.MapID,
|
||
PlayerCount: m.PlayerCount,
|
||
Status: m.Status,
|
||
Engagement: m.Engagement,
|
||
WallDensity: m.WallDensity,
|
||
EnergyCount: m.EnergyCount,
|
||
GridWidth: m.GridWidth,
|
||
GridHeight: m.GridHeight,
|
||
NetVotes: m.NetVotes,
|
||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||
}
|
||
entries = append(entries, entry)
|
||
key := fmt.Sprintf("%d", m.PlayerCount)
|
||
byPlayerCount[key] = append(byPlayerCount[key], entry)
|
||
|
||
var geo mapGeometryJSON
|
||
if len(m.RawJSON) > 0 {
|
||
if err := json.Unmarshal(m.RawJSON, &geo); err != nil {
|
||
return fmt.Errorf("parse map_json for %s: %w", m.MapID, err)
|
||
}
|
||
}
|
||
|
||
detail := MapDetail{
|
||
MapID: m.MapID,
|
||
PlayerCount: m.PlayerCount,
|
||
Status: m.Status,
|
||
Engagement: m.Engagement,
|
||
WallDensity: m.WallDensity,
|
||
EnergyCount: m.EnergyCount,
|
||
GridWidth: m.GridWidth,
|
||
GridHeight: m.GridHeight,
|
||
NetVotes: m.NetVotes,
|
||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||
Walls: geo.Walls,
|
||
Cores: geo.Cores,
|
||
EnergyNodes: geo.EnergyNodes,
|
||
}
|
||
if detail.Walls == nil {
|
||
detail.Walls = []mapPosition{}
|
||
}
|
||
if detail.Cores == nil {
|
||
detail.Cores = []mapCore{}
|
||
}
|
||
if detail.EnergyNodes == nil {
|
||
detail.EnergyNodes = []mapPosition{}
|
||
}
|
||
|
||
if err := writeJSON(filepath.Join(mapsDir, m.MapID+".json"), detail); err != nil {
|
||
return fmt.Errorf("write map %s: %w", m.MapID, err)
|
||
}
|
||
}
|
||
|
||
index := MapIndexFile{
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Maps: entries,
|
||
ByPlayerCount: byPlayerCount,
|
||
}
|
||
|
||
return writeJSON(filepath.Join(mapsDir, "index.json"), index)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// persistGeneratedPlaylists reads the generated playlist JSON files from the output
|
||
// directory and writes them to the playlists and playlist_matches DB tables.
|
||
func persistGeneratedPlaylists(ctx context.Context, db *sql.DB, outputDir string) error {
|
||
playlistsDir := filepath.Join(outputDir, "data", "playlists")
|
||
|
||
indexContent, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
|
||
if err != nil {
|
||
return fmt.Errorf("read playlist index: %w", err)
|
||
}
|
||
var index PlaylistIndex
|
||
if err := json.Unmarshal(indexContent, &index); err != nil {
|
||
return fmt.Errorf("parse playlist index: %w", err)
|
||
}
|
||
|
||
var persisted []persistedPlaylist
|
||
for _, summary := range index.Playlists {
|
||
plContent, err := os.ReadFile(filepath.Join(playlistsDir, summary.Slug+".json"))
|
||
if err != nil {
|
||
continue // skip playlists without files
|
||
}
|
||
var pl Playlist
|
||
if err := json.Unmarshal(plContent, &pl); err != nil {
|
||
continue
|
||
}
|
||
|
||
matches := make([]persistedPlaylistMatch, 0, len(pl.Matches))
|
||
for _, m := range pl.Matches {
|
||
matches = append(matches, persistedPlaylistMatch{
|
||
MatchID: m.MatchID,
|
||
SortOrder: m.Order,
|
||
CurationTag: m.CurationTag,
|
||
})
|
||
}
|
||
|
||
persisted = append(persisted, persistedPlaylist{
|
||
Slug: pl.Slug,
|
||
Title: pl.Title,
|
||
Description: pl.Description,
|
||
Category: pl.Category,
|
||
Matches: matches,
|
||
})
|
||
}
|
||
|
||
return persistPlaylists(ctx, db, persisted)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ─── Archetypes (§15.2) ────────────────────────────────────────────────────────
|
||
|
||
// ArchetypeBot is a bot entry within an archetype group.
|
||
type ArchetypeBot struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Rating float64 `json:"rating"`
|
||
}
|
||
|
||
// ArchetypeEntry aggregates bots sharing a behavioral archetype.
|
||
type ArchetypeEntry struct {
|
||
Name string `json:"name"`
|
||
BotCount int `json:"bot_count"`
|
||
AvgRating float64 `json:"avg_rating"`
|
||
WinRate float64 `json:"win_rate"`
|
||
Bots []ArchetypeBot `json:"bots"`
|
||
}
|
||
|
||
// ArchetypesIndex is the top-level structure for data/meta/archetypes.json.
|
||
type ArchetypesIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Archetypes []ArchetypeEntry `json:"archetypes"`
|
||
}
|
||
|
||
// MetaFileEntry represents a single meta data file in the meta directory index.
|
||
type MetaFileEntry struct {
|
||
Name string `json:"name"` // e.g., "archetypes.json"
|
||
Description string `json:"description"` // e.g., "Bot archetype classifications"
|
||
}
|
||
|
||
// MetaIndex is the top-level structure for data/meta/index.json.
|
||
type MetaIndex struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Files []MetaFileEntry `json:"files"`
|
||
}
|
||
|
||
// classifyArchetype infers a behavioral archetype from bot name when the
|
||
// archetype field is empty.
|
||
func classifyArchetype(bot BotData) string {
|
||
name := strings.ToLower(bot.Name)
|
||
switch {
|
||
case strings.Contains(name, "rush") || strings.Contains(name, "aggress") || strings.Contains(name, "attack") || strings.Contains(name, "blitz"):
|
||
return "aggressive"
|
||
case strings.Contains(name, "defend") || strings.Contains(name, "wall") || strings.Contains(name, "fort") || strings.Contains(name, "guard"):
|
||
return "defensive"
|
||
case strings.Contains(name, "swarm") || strings.Contains(name, "hive") || strings.Contains(name, "colony") || strings.Contains(name, "mass"):
|
||
return "swarm"
|
||
case strings.Contains(name, "hunt") || strings.Contains(name, "chase") || strings.Contains(name, "pursuit") || strings.Contains(name, "stalk"):
|
||
return "hunter"
|
||
case strings.Contains(name, "turtle") || strings.Contains(name, "base") || strings.Contains(name, "camp") || strings.Contains(name, "bunker"):
|
||
return "turtler"
|
||
default:
|
||
return "balanced"
|
||
}
|
||
}
|
||
|
||
// generateArchetypes builds data/meta/archetypes.json from the bot population.
|
||
func generateArchetypes(data *IndexData, outputDir string) error {
|
||
type archetypeAccum struct {
|
||
entry ArchetypeEntry
|
||
totalWins int
|
||
totalPlayed int
|
||
}
|
||
accum := make(map[string]*archetypeAccum)
|
||
|
||
for _, bot := range data.Bots {
|
||
arch := bot.Archetype
|
||
if arch == "" {
|
||
arch = classifyArchetype(bot)
|
||
}
|
||
|
||
a, ok := accum[arch]
|
||
if !ok {
|
||
a = &archetypeAccum{entry: ArchetypeEntry{Name: arch}}
|
||
accum[arch] = a
|
||
}
|
||
a.entry.BotCount++
|
||
a.entry.AvgRating += bot.Rating
|
||
a.entry.Bots = append(a.entry.Bots, ArchetypeBot{
|
||
ID: bot.ID,
|
||
Name: bot.Name,
|
||
Rating: bot.Rating,
|
||
})
|
||
a.totalWins += bot.MatchesWon
|
||
a.totalPlayed += bot.MatchesPlayed
|
||
}
|
||
|
||
archetypes := make([]ArchetypeEntry, 0, len(accum))
|
||
for arch, a := range accum {
|
||
if a.entry.BotCount > 0 {
|
||
a.entry.AvgRating = round1(a.entry.AvgRating / float64(a.entry.BotCount))
|
||
}
|
||
if a.totalPlayed > 0 {
|
||
a.entry.WinRate = round1(float64(a.totalWins) / float64(a.totalPlayed) * 100)
|
||
}
|
||
sort.Slice(a.entry.Bots, func(i, j int) bool {
|
||
return a.entry.Bots[i].Rating > a.entry.Bots[j].Rating
|
||
})
|
||
if len(a.entry.Bots) > 20 {
|
||
a.entry.Bots = a.entry.Bots[:20]
|
||
}
|
||
_ = arch
|
||
archetypes = append(archetypes, a.entry)
|
||
}
|
||
|
||
sort.Slice(archetypes, func(i, j int) bool {
|
||
return archetypes[i].BotCount > archetypes[j].BotCount
|
||
})
|
||
|
||
metaDir := filepath.Join(outputDir, "data", "meta")
|
||
if err := os.MkdirAll(metaDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
index := ArchetypesIndex{
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Archetypes: archetypes,
|
||
}
|
||
return writeJSON(filepath.Join(metaDir, "archetypes.json"), index)
|
||
}
|
||
|
||
// generateMetaIndex builds data/meta/index.json listing all available meta data files.
|
||
func generateMetaIndex(outputDir string) error {
|
||
metaDir := filepath.Join(outputDir, "data", "meta")
|
||
if err := os.MkdirAll(metaDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// List all available meta data files with descriptions
|
||
files := []MetaFileEntry{
|
||
{Name: "archetypes.json", Description: "Bot archetype classifications and statistics"},
|
||
{Name: "rivalries.json", Description: "Top rivalries between bots"},
|
||
}
|
||
|
||
index := MetaIndex{
|
||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||
Files: files,
|
||
}
|
||
return writeJSON(filepath.Join(metaDir, "index.json"), index)
|
||
}
|
||
|
||
// ─── Community Hints (§15.2 / §13.6) ──────────────────────────────────────────
|
||
|
||
// CommunityHint is a single high-upvote tactical insight from §13.6 feedback.
|
||
type CommunityHint struct {
|
||
FeedbackID string `json:"feedback_id"`
|
||
MatchID string `json:"match_id"`
|
||
Turn int `json:"turn"`
|
||
Type string `json:"type"`
|
||
Body string `json:"body"`
|
||
Upvotes int `json:"upvotes"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// CommunityHintsFile is the top-level structure for data/evolution/community_hints.json.
|
||
type CommunityHintsFile struct {
|
||
GeneratedAt string `json:"generated_at"`
|
||
Hints []CommunityHint `json:"hints"`
|
||
}
|
||
|
||
const communityHintMinUpvotes = 10
|
||
const communityHintMaxHints = 50
|
||
|
||
// generateCommunityHints builds data/evolution/community_hints.json from
|
||
// high-upvote 'insight' and 'idea' feedback entries (mapping to 'hint' and
|
||
// 'strategy' from plan §13.6). The evolver reads this file to include
|
||
// tactical community insights in LLM prompts.
|
||
func generateCommunityHints(data *IndexData, outputDir string) error {
|
||
var hints []CommunityHint
|
||
for _, f := range data.Feedback {
|
||
// Filter for 'insight' (hint/tactical insight) and 'idea' (strategy idea)
|
||
if f.Type != "insight" && f.Type != "idea" {
|
||
continue
|
||
}
|
||
if f.Upvotes < communityHintMinUpvotes {
|
||
continue
|
||
}
|
||
hints = append(hints, CommunityHint{
|
||
FeedbackID: f.FeedbackID,
|
||
MatchID: f.MatchID,
|
||
Turn: f.Turn,
|
||
Type: f.Type,
|
||
Body: f.Body,
|
||
Upvotes: f.Upvotes,
|
||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// Feedback is already sorted by upvotes DESC from DB; cap at max.
|
||
if len(hints) > communityHintMaxHints {
|
||
hints = hints[:communityHintMaxHints]
|
||
}
|
||
|
||
evolDir := filepath.Join(outputDir, "data", "evolution")
|
||
if err := os.MkdirAll(evolDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
file := CommunityHintsFile{
|
||
GeneratedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Hints: hints,
|
||
}
|
||
return writeJSON(filepath.Join(evolDir, "community_hints.json"), file)
|
||
}
|
||
|
||
// generateEvolutionMeta creates data/evolution/meta.json with evolution statistics
|
||
func generateEvolutionMeta(data *IndexData, outputDir string) error {
|
||
evolDir := filepath.Join(outputDir, "data", "evolution")
|
||
if err := os.MkdirAll(evolDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// If no evolution meta data, write empty placeholder
|
||
meta := data.EvolutionMeta
|
||
if meta == nil {
|
||
slog.Info("Evolution meta data not available - evolution system not running")
|
||
meta = &EvolutionMeta{
|
||
Generation: 0,
|
||
PromotedToday: 0,
|
||
Top10Count: 0,
|
||
IslandPopulations: make(map[string]int),
|
||
BestRatings: []EvolvedBotRating{},
|
||
TotalPromoted: 0,
|
||
PromotionRate: 0,
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
}
|
||
} else {
|
||
slog.Info("Writing evolution meta data",
|
||
"generation", meta.Generation,
|
||
"promoted_today", meta.PromotedToday,
|
||
"total_promoted", meta.TotalPromoted,
|
||
"islands", len(meta.IslandPopulations))
|
||
}
|
||
|
||
return writeJSON(filepath.Join(evolDir, "meta.json"), meta)
|
||
}
|
||
|
||
// generateLineage creates data/evolution/lineage.json with the full program lineage tree
|
||
func generateLineage(data *IndexData, outputDir string) error {
|
||
evolDir := filepath.Join(outputDir, "data", "evolution")
|
||
if err := os.MkdirAll(evolDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Lineage is already a slice; empty slice is fine
|
||
return writeJSON(filepath.Join(evolDir, "lineage.json"), data.Lineage)
|
||
}
|
||
|
||
// ─── Per-match Feedback (§15.2) ────────────────────────────────────────────────
|
||
|
||
// MatchFeedbackFile is the structure for data/matches/{id}/feedback.json.
|
||
type MatchFeedbackFile struct {
|
||
MatchID string `json:"match_id"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
Feedback []FeedbackEntry `json:"feedback"`
|
||
}
|
||
|
||
// generateMatchFeedback creates data/matches/{match_id}/feedback.json for every
|
||
// match that has community annotations. The static file mirrors the live API
|
||
// response so annotation.ts can fall back to it when the API is unavailable.
|
||
func generateMatchFeedback(data *IndexData, outputDir string) error {
|
||
byMatch := make(map[string][]FeedbackEntry)
|
||
for _, f := range data.Feedback {
|
||
byMatch[f.MatchID] = append(byMatch[f.MatchID], f)
|
||
}
|
||
|
||
for matchID, entries := range byMatch {
|
||
matchDir := filepath.Join(outputDir, "data", "matches", matchID)
|
||
if err := os.MkdirAll(matchDir, 0755); err != nil {
|
||
return fmt.Errorf("create match dir %s: %w", matchID, err)
|
||
}
|
||
|
||
file := MatchFeedbackFile{
|
||
MatchID: matchID,
|
||
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
|
||
Feedback: entries,
|
||
}
|
||
if err := writeJSON(filepath.Join(matchDir, "feedback.json"), file); err != nil {
|
||
return fmt.Errorf("write feedback for match %s: %w", matchID, err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ─── Sitemap Generation ───────────────────────────────────────────────────────────
|
||
|
||
// SitemapURL represents a single URL entry in the sitemap
|
||
type SitemapURL struct {
|
||
Loc string `xml:"loc"`
|
||
LastMod string `xml:"lastmod,omitempty"`
|
||
ChangeFreq string `xml:"changefreq,omitempty"`
|
||
Priority string `xml:"priority,omitempty"`
|
||
}
|
||
|
||
// Sitemap represents the root sitemap XML structure
|
||
type Sitemap struct {
|
||
XMLName xml.Name `xml:"urlset"`
|
||
Xmlns string `xml:"xmlns,attr"`
|
||
URLs []SitemapURL `xml:"url"`
|
||
}
|
||
|
||
// generateSitemap creates sitemap.xml covering all public pages
|
||
func generateSitemap(data *IndexData, outputDir string, siteURL string) error {
|
||
now := data.GeneratedAt.Format("2006-01-02")
|
||
|
||
urls := []SitemapURL{
|
||
// Core pages
|
||
{Loc: siteURL + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
|
||
{Loc: siteURL + "/leaderboard", LastMod: now, ChangeFreq: "hourly", Priority: "0.9"},
|
||
{Loc: siteURL + "/watch", LastMod: now, ChangeFreq: "hourly", Priority: "0.9"},
|
||
{Loc: siteURL + "/watch/replays", LastMod: now, ChangeFreq: "hourly", Priority: "0.8"},
|
||
{Loc: siteURL + "/compete", LastMod: now, ChangeFreq: "daily", Priority: "0.7"},
|
||
{Loc: siteURL + "/compete/register", LastMod: now, ChangeFreq: "monthly", Priority: "0.5"},
|
||
{Loc: siteURL + "/compete/sandbox", LastMod: now, ChangeFreq: "monthly", Priority: "0.5"},
|
||
{Loc: siteURL + "/compete/docs", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
|
||
{Loc: siteURL + "/evolution", LastMod: now, ChangeFreq: "hourly", Priority: "0.8"},
|
||
{Loc: siteURL + "/blog", LastMod: now, ChangeFreq: "daily", Priority: "0.7"},
|
||
{Loc: siteURL + "/watch/predictions", LastMod: now, ChangeFreq: "hourly", Priority: "0.7"},
|
||
}
|
||
|
||
// Bot list page
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/bots",
|
||
LastMod: now,
|
||
ChangeFreq: "daily",
|
||
Priority: "0.8",
|
||
})
|
||
|
||
// Individual bot profiles (limit to 1000 for sitemap size)
|
||
for i, bot := range data.Bots {
|
||
if i >= 1000 {
|
||
break
|
||
}
|
||
priority := "0.6"
|
||
if i < 10 {
|
||
priority = "0.8" // Top bots get higher priority
|
||
}
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/bot/" + bot.ID,
|
||
LastMod: bot.UpdatedAt.Format("2006-01-02"),
|
||
ChangeFreq: "daily",
|
||
Priority: priority,
|
||
})
|
||
}
|
||
|
||
// Individual match replay pages (limit to 500 most recent)
|
||
for i, m := range data.Matches {
|
||
if i >= 500 {
|
||
break
|
||
}
|
||
priority := "0.5"
|
||
if m.WinnerID != "" && m.CombatTurns > 0 {
|
||
priority = "0.7" // Completed matches with combat get priority
|
||
}
|
||
var lastMod string
|
||
if !m.CompletedAt.IsZero() {
|
||
lastMod = m.CompletedAt.Format("2006-01-02")
|
||
} else {
|
||
lastMod = m.CreatedAt.Format("2006-01-02")
|
||
}
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/watch/replay/" + m.ID,
|
||
LastMod: lastMod,
|
||
ChangeFreq: "monthly",
|
||
Priority: priority,
|
||
})
|
||
}
|
||
|
||
// Series pages
|
||
for _, s := range data.Series {
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/watch/series/" + fmt.Sprintf("%d", s.ID),
|
||
LastMod: s.UpdatedAt.Format("2006-01-02"),
|
||
ChangeFreq: "weekly",
|
||
Priority: "0.6",
|
||
})
|
||
}
|
||
|
||
// Seasons list page
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/season",
|
||
LastMod: now,
|
||
ChangeFreq: "weekly",
|
||
Priority: "0.7",
|
||
})
|
||
|
||
// Individual season pages
|
||
for _, s := range data.Seasons {
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/season/" + fmt.Sprintf("%d", s.ID),
|
||
LastMod: s.StartsAt.Format("2006-01-02"),
|
||
ChangeFreq: "weekly",
|
||
Priority: "0.7",
|
||
})
|
||
}
|
||
|
||
// Rivalries page
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/rivalries",
|
||
LastMod: now,
|
||
ChangeFreq: "weekly",
|
||
Priority: "0.6",
|
||
})
|
||
|
||
// Docs pages
|
||
docsPages := []string{"protocol", "replay-format", "getting-started", "starter-kits"}
|
||
for _, doc := range docsPages {
|
||
urls = append(urls, SitemapURL{
|
||
Loc: siteURL + "/compete/docs/" + doc,
|
||
LastMod: now,
|
||
ChangeFreq: "monthly",
|
||
Priority: "0.5",
|
||
})
|
||
}
|
||
|
||
// Build XML sitemap
|
||
sitemap := Sitemap{
|
||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||
URLs: urls,
|
||
}
|
||
|
||
// Write sitemap.xml to output directory (alongside leaderboard.json in the Pages deploy)
|
||
outputPath := filepath.Join(outputDir, "sitemap.xml")
|
||
f, err := os.Create(outputPath)
|
||
if err != nil {
|
||
return fmt.Errorf("create sitemap.xml: %w", err)
|
||
}
|
||
defer f.Close()
|
||
|
||
// Write XML header
|
||
if _, err := f.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n"); err != nil {
|
||
return fmt.Errorf("write xml header: %w", err)
|
||
}
|
||
|
||
// Write sitemap content
|
||
enc := xml.NewEncoder(f)
|
||
enc.Indent("", " ")
|
||
if err := enc.Encode(sitemap); err != nil {
|
||
return fmt.Errorf("encode sitemap: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|