- Fix deploy.go to query actual table names (series_games not series_matches, join through series_games for seasons instead of non-existent season_matches) - Add playlist_matches table to exempt match IDs from R2 pruning - Pre-build lookup maps for O(1) playlist match filtering instead of O(n²) - Enhance home page featured replay to prefer AI-enriched matches - Add enrichment test coverage (shouldEnrich criteria validation) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1288 lines
36 KiB
Go
1288 lines
36 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"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"`
|
||
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, db *sql.DB) 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)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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)
|
||
}
|
||
|
||
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: "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.BotID == m.WinnerID,
|
||
})
|
||
scoreParts = append(scoreParts, fmt.Sprintf("%d", p.Score))
|
||
}
|
||
title := formatMatchTitle(m, data)
|
||
completedAt := ""
|
||
if !m.CompletedAt.IsZero() {
|
||
completedAt = m.CompletedAt.Format(time.RFC3339)
|
||
}
|
||
return PlaylistMatch{
|
||
MatchID: m.ID,
|
||
Order: order,
|
||
Title: title,
|
||
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.BotID == m.WinnerID {
|
||
winnerRating = p.PreMatchRating
|
||
found = true
|
||
}
|
||
}
|
||
if !found || winnerRating == 0 {
|
||
return 0
|
||
}
|
||
for _, p := range m.Participants {
|
||
if p.BotID != m.WinnerID && 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
|
||
}
|
||
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 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(s []MatchData, 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.BotID == m.WinnerID {
|
||
winnerScore = p.Score
|
||
break
|
||
}
|
||
}
|
||
maxDiff := 0
|
||
for _, p := range m.Participants {
|
||
if p.BotID != m.WinnerID {
|
||
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
|
||
}
|
||
winnerEvolved := false
|
||
for _, bot := range data.Bots {
|
||
if bot.ID == m.WinnerID && bot.Evolved {
|
||
winnerEvolved = true
|
||
}
|
||
}
|
||
if !winnerEvolved {
|
||
return false
|
||
}
|
||
// Winner must have beaten someone rated >= 1600
|
||
for _, p := range m.Participants {
|
||
if p.BotID != m.WinnerID && p.PreMatchRating >= 1600 && !p.Won {
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|