diff --git a/.gitignore b/.gitignore index c976f4f..ef3cec9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /acb-api /acb-matchmaker /acb-evolver +/acb-index-builder # Node modules node_modules/ diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index a6b208d..ee37c65 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -0d887ebeb2f2e3db51f92adc2225646f2b451fe2 +70b73378678332a405a57342a001fdcec925adc7 diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index 8d73364..21e3ddb 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -32,21 +32,34 @@ CREATE TABLE IF NOT EXISTS predictor_stats ( ); CREATE TABLE IF NOT EXISTS series ( - id BIGSERIAL PRIMARY KEY, - bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id), - bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id), - format INTEGER NOT NULL DEFAULT 5, -- best of N (3, 5, 7...) - a_wins INTEGER NOT NULL DEFAULT 0, - b_wins INTEGER NOT NULL DEFAULT 0, - status VARCHAR(16) NOT NULL DEFAULT 'active', - winner_id VARCHAR(16), - season_id BIGINT REFERENCES seasons(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id), + bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id), + format INTEGER NOT NULL DEFAULT 5, -- best of N (3, 5, 7...) + a_wins INTEGER NOT NULL DEFAULT 0, + b_wins INTEGER NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'active', + winner_id VARCHAR(16), + season_id BIGINT REFERENCES seasons(id), + bracket_round VARCHAR(32), -- 'quarterfinal', 'semifinal', 'final' for championship + bracket_position INTEGER, -- position within the bracket round (0-based) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id); CREATE INDEX IF NOT EXISTS idx_series_status ON series(status); CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id); +CREATE INDEX IF NOT EXISTS idx_series_bracket ON series(season_id, bracket_round) WHERE bracket_round IS NOT NULL; + +-- Add bracket columns if they don't exist (idempotent migration) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_round') THEN + ALTER TABLE series ADD COLUMN bracket_round VARCHAR(32); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_position') THEN + ALTER TABLE series ADD COLUMN bracket_position INTEGER; + END IF; +END $$; CREATE TABLE IF NOT EXISTS series_games ( id BIGSERIAL PRIMARY KEY, @@ -210,6 +223,27 @@ CREATE TABLE IF NOT EXISTS programs ( ); CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island); CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC); + +-- Curated playlist definitions (§14.4 Replay Playlists) +CREATE TABLE IF NOT EXISTS playlists ( + slug VARCHAR(64) PRIMARY KEY, + title VARCHAR(128) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(32) NOT NULL DEFAULT 'featured', + is_auto BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS playlist_matches ( + playlist_slug VARCHAR(64) NOT NULL REFERENCES playlists(slug) ON DELETE CASCADE, + match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id), + sort_order INTEGER NOT NULL DEFAULT 0, + curation_tag TEXT NOT NULL DEFAULT '', + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (playlist_slug, match_id) +); +CREATE INDEX IF NOT EXISTS idx_playlist_matches_playlist ON playlist_matches(playlist_slug, sort_order); ` func ensureSchema(ctx context.Context, db *sql.DB) error { diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index ad5e60c..d7e81c8 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "time" ) @@ -49,6 +50,7 @@ type ParticipantData struct { Score int `json:"score"` Won bool `json:"won"` PreMatchRating float64 `json:"pre_match_rating,omitempty"` + Evolved bool `json:"evolved,omitempty"` } // RatingHistoryEntry represents a rating history point @@ -59,41 +61,90 @@ type RatingHistoryEntry struct { RecordedAt time.Time `json:"recorded_at"` } -// SeriesData represents a series for the index -type SeriesData struct { - ID int64 `json:"id"` - BotAID string `json:"bot_a_id"` - BotBID string `json:"bot_b_id"` - Format int `json:"format"` - AWins int `json:"a_wins"` - BWins int `json:"b_wins"` - Status string `json:"status"` - WinnerID string `json:"winner_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// SeriesGameData represents one game within a series +type SeriesGameData struct { + MatchID string `json:"match_id"` + GameNum int `json:"game_number"` + WinnerID string `json:"winner_id,omitempty"` + WinnerSlot *int `json:"winner_slot"` + Turns int `json:"turns,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` } -// SeasonData represents a season for the index +// SeriesData represents a series for the index, enriched with bot names and games. +type SeriesData struct { + ID int64 `json:"id"` + BotAID string `json:"bot1_id"` + BotBID string `json:"bot2_id"` + BotAName string `json:"bot1_name"` + BotBName string `json:"bot2_name"` + Format int `json:"best_of"` + AWins int `json:"bot1_wins"` + BWins int `json:"bot2_wins"` + Status string `json:"status"` + WinnerID string `json:"winner_id,omitempty"` + BracketRound string `json:"bracket_round,omitempty"` + BracketPosition int `json:"bracket_position,omitempty"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Games []SeriesGameData `json:"games"` +} + +// SeasonSnapshotData represents a bot's end-of-season snapshot +type SeasonSnapshotData struct { + BotID string `json:"bot_id"` + BotName string `json:"bot_name"` + Rating float64 `json:"rating"` + Rank int `json:"rank"` + Wins int `json:"wins"` + Losses int `json:"losses"` +} + +// ChampionshipSeries is a lightweight series summary for bracket display on the season page. +type ChampionshipSeries struct { + ID int64 `json:"id"` + BotAID string `json:"bot1_id"` + BotBID string `json:"bot2_id"` + BotAName string `json:"bot1_name"` + BotBName string `json:"bot2_name"` + Format int `json:"best_of"` + AWins int `json:"bot1_wins"` + BWins int `json:"bot2_wins"` + Status string `json:"status"` + WinnerID string `json:"winner_id,omitempty"` + Round string `json:"round"` + BracketPosition int `json:"bracket_position"` + Games []SeriesGameData `json:"games"` +} + +// SeasonData represents a season for the index, enriched with champion name, match count, and snapshots. type SeasonData struct { - ID int64 `json:"id"` - Name string `json:"name"` - Theme string `json:"theme,omitempty"` - RulesVer string `json:"rules_version"` - ChampionID string `json:"champion_id,omitempty"` - StartsAt time.Time `json:"starts_at"` - EndsAt time.Time `json:"ends_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Theme string `json:"theme,omitempty"` + RulesVer string `json:"rules_version"` + Status string `json:"status"` + ChampionID string `json:"champion_id,omitempty"` + ChampionName string `json:"champion_name,omitempty"` + StartsAt time.Time `json:"starts_at"` + EndsAt time.Time `json:"ends_at,omitempty"` + TotalMatches int `json:"total_matches"` + CreatedAt time.Time `json:"created_at"` + Snapshots []SeasonSnapshotData `json:"final_snapshot"` + ChampionshipBracket []ChampionshipSeries `json:"championship_bracket,omitempty"` } // PredictionData represents a prediction for the index type PredictionData struct { - ID int64 `json:"id"` - MatchID string `json:"match_id"` - PredictorID string `json:"predictor_id"` - PredictedBot string `json:"predicted_bot"` - Correct *bool `json:"correct,omitempty"` - CreatedAt time.Time `json:"created_at"` - ResolvedAt *time.Time `json:"resolved_at,omitempty"` + ID int64 `json:"id"` + MatchID string `json:"match_id"` + PredictorID string `json:"predictor_id"` + PredictedBot string `json:"predicted_bot"` + Correct *bool `json:"correct,omitempty"` + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` } // PredictorStats represents predictor statistics @@ -107,29 +158,29 @@ type PredictorStats struct { // MapData represents a map for the index type MapData struct { - MapID string `json:"map_id"` - PlayerCount int `json:"player_count"` - Status string `json:"status"` - Engagement float64 `json:"engagement"` - WallDensity float64 `json:"wall_density"` - EnergyCount int `json:"energy_count"` - GridWidth int `json:"grid_width"` - GridHeight int `json:"grid_height"` - CreatedAt time.Time `json:"created_at"` + MapID string `json:"map_id"` + PlayerCount int `json:"player_count"` + Status string `json:"status"` + Engagement float64 `json:"engagement"` + WallDensity float64 `json:"wall_density"` + EnergyCount int `json:"energy_count"` + GridWidth int `json:"grid_width"` + GridHeight int `json:"grid_height"` + CreatedAt time.Time `json:"created_at"` } // IndexData contains all data needed for index generation type IndexData struct { - GeneratedAt time.Time - Bots []BotData - Matches []MatchData - RatingHistory []RatingHistoryEntry - Series []SeriesData - Seasons []SeasonData - Predictions []PredictionData - PredictorStats []PredictorStats - Maps []MapData - TopPredictors []PredictorStats + GeneratedAt time.Time + Bots []BotData + Matches []MatchData + RatingHistory []RatingHistoryEntry + Series []SeriesData + Seasons []SeasonData + Predictions []PredictionData + PredictorStats []PredictorStats + Maps []MapData + TopPredictors []PredictorStats } // fetchAllData retrieves all data from PostgreSQL for index generation @@ -164,7 +215,6 @@ func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) { return nil, err } - // Get top predictors (sorted by accuracy) data.TopPredictors = computeTopPredictors(data.PredictorStats) return data, nil @@ -224,7 +274,6 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { bots = append(bots, b) } - // Calculate matches played and won from match_participants for i := range bots { mp, mw, err := getBotMatchStats(ctx, db, bots[i].ID) if err != nil { @@ -258,7 +307,13 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { 'bot_id', mp.bot_id, 'player_slot', mp.player_slot, 'score', mp.score, - 'won', mp.player_slot = m.winner + 'won', mp.player_slot = m.winner, + 'pre_match_rating', COALESCE( + (SELECT rh.rating FROM rating_history rh + WHERE rh.bot_id = mp.bot_id AND rh.match_id = m.match_id + LIMIT 1), 0), + 'evolved', COALESCE( + (SELECT b.evolved FROM bots b WHERE b.bot_id = mp.bot_id), false) ) ORDER BY mp.player_slot ) FILTER (WHERE mp.bot_id IS NOT NULL), @@ -300,6 +355,14 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { return nil, err } + // PlayedAt is used for weekly filtering in blog/stats generation. + // CompletedAt is the authoritative timestamp; fall back to CreatedAt. + if !m.CompletedAt.IsZero() { + m.PlayedAt = m.CompletedAt + } else { + m.PlayedAt = m.CreatedAt + } + matches = append(matches, m) } @@ -334,9 +397,15 @@ func fetchRatingHistory(ctx context.Context, db *sql.DB) ([]RatingHistoryEntry, func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) { query := ` - SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins, status, winner_id, created_at, updated_at - FROM series - ORDER BY created_at DESC + SELECT s.id, s.bot_a_id, s.bot_b_id, + ba.name, bb.name, + s.format, s.a_wins, s.b_wins, s.status, s.winner_id, + COALESCE(s.bracket_round, ''), COALESCE(s.bracket_position, 0), + s.created_at, s.updated_at + FROM series s + JOIN bots ba ON s.bot_a_id = ba.bot_id + JOIN bots bb ON s.bot_b_id = bb.bot_id + ORDER BY s.created_at DESC ` rows, err := db.QueryContext(ctx, query) @@ -351,8 +420,11 @@ func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) { var winnerID sql.NullString err := rows.Scan( - &s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins, - &s.Status, &winnerID, &s.CreatedAt, &s.UpdatedAt, + &s.ID, &s.BotAID, &s.BotBID, + &s.BotAName, &s.BotBName, + &s.Format, &s.AWins, &s.BWins, &s.Status, &winnerID, + &s.BracketRound, &s.BracketPosition, + &s.CreatedAt, &s.UpdatedAt, ) if err != nil { return nil, err @@ -364,14 +436,75 @@ func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) { series = append(series, s) } + for i := range series { + games, err := fetchSeriesGames(ctx, db, series[i].ID) + if err != nil { + return nil, err + } + series[i].Games = games + } + return series, nil } +func fetchSeriesGames(ctx context.Context, db *sql.DB, seriesID int64) ([]SeriesGameData, error) { + rows, err := db.QueryContext(ctx, ` + SELECT sg.match_id, sg.game_num, sg.winner_id, + COALESCE(m.turn_count, 0), m.completed_at, + CASE WHEN sg.winner_id IS NOT NULL THEN + (SELECT mp.player_slot FROM match_participants mp + WHERE mp.match_id = sg.match_id AND mp.bot_id = sg.winner_id) + END + FROM series_games sg + LEFT JOIN matches m ON sg.match_id = m.match_id + WHERE sg.series_id = $1 + ORDER BY sg.game_num + `, seriesID) + if err != nil { + return nil, err + } + defer rows.Close() + + var games []SeriesGameData + for rows.Next() { + var g SeriesGameData + var winnerID sql.NullString + var winnerSlot sql.NullInt64 + var turns sql.NullInt64 + var completedAt sql.NullTime + + err := rows.Scan(&g.MatchID, &g.GameNum, &winnerID, &turns, &completedAt, &winnerSlot) + if err != nil { + return nil, err + } + + if winnerID.Valid { + g.WinnerID = winnerID.String + } + if winnerSlot.Valid { + slot := int(winnerSlot.Int64) + g.WinnerSlot = &slot + } + if turns.Valid && turns.Int64 > 0 { + g.Turns = int(turns.Int64) + } + if completedAt.Valid { + g.CompletedAt = &completedAt.Time + } + games = append(games, g) + } + + return games, nil +} + func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) { query := ` - SELECT id, name, theme, rules_version, champion_id, starts_at, ends_at, created_at - FROM seasons - ORDER BY starts_at DESC + SELECT s.id, s.name, s.theme, s.rules_version, s.status, + s.champion_id, b.name, + s.starts_at, s.ends_at, s.created_at + FROM seasons s + LEFT JOIN bots b ON s.champion_id = b.bot_id + ORDER BY s.starts_at DESC ` rows, err := db.QueryContext(ctx, query) @@ -383,11 +516,12 @@ func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) { var seasons []SeasonData for rows.Next() { var s SeasonData - var theme, championID sql.NullString + var theme, championID, championName sql.NullString var endsAt sql.NullTime err := rows.Scan( - &s.ID, &s.Name, &theme, &s.RulesVer, &championID, + &s.ID, &s.Name, &theme, &s.RulesVer, &s.Status, + &championID, &championName, &s.StartsAt, &endsAt, &s.CreatedAt, ) if err != nil { @@ -400,15 +534,119 @@ func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) { if championID.Valid { s.ChampionID = championID.String } + if championName.Valid { + s.ChampionName = championName.String + } if endsAt.Valid { s.EndsAt = endsAt.Time } seasons = append(seasons, s) } + // Enrich each season with match count, snapshots, and championship bracket + for i := range seasons { + seasons[i].TotalMatches, _ = getSeasonMatchCount(ctx, db, seasons[i].ID) + snapshots, err := fetchSeasonSnapshots(ctx, db, seasons[i].ID) + if err == nil && len(snapshots) > 0 { + seasons[i].Snapshots = snapshots + } + bracket, err := fetchChampionshipBracket(ctx, db, seasons[i].ID) + if err == nil && len(bracket) > 0 { + seasons[i].ChampionshipBracket = bracket + } + } + return seasons, nil } +func getSeasonMatchCount(ctx context.Context, db *sql.DB, seasonID int64) (int, error) { + // Count matches from series in this season + var count int + err := db.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT sg.match_id) + FROM series_games sg + JOIN series s ON sg.series_id = s.id + WHERE s.season_id = $1 AND sg.match_id IS NOT NULL + `, seasonID).Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func fetchSeasonSnapshots(ctx context.Context, db *sql.DB, seasonID int64) ([]SeasonSnapshotData, error) { + rows, err := db.QueryContext(ctx, ` + SELECT ss.bot_id, b.name, ss.rating, ss.rank, ss.wins, ss.losses + FROM season_snapshots ss + JOIN bots b ON ss.bot_id = b.bot_id + WHERE ss.season_id = $1 + ORDER BY ss.rank + `, seasonID) + if err != nil { + return nil, err + } + defer rows.Close() + + var snapshots []SeasonSnapshotData + for rows.Next() { + var snap SeasonSnapshotData + if err := rows.Scan(&snap.BotID, &snap.BotName, &snap.Rating, &snap.Rank, &snap.Wins, &snap.Losses); err != nil { + return nil, err + } + snapshots = append(snapshots, snap) + } + + return snapshots, nil +} + +func fetchChampionshipBracket(ctx context.Context, db *sql.DB, seasonID int64) ([]ChampionshipSeries, error) { + rows, err := db.QueryContext(ctx, ` + SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name, + s.format, s.a_wins, s.b_wins, s.status, s.winner_id, + COALESCE(s.bracket_round, 'quarterfinal'), COALESCE(s.bracket_position, 0) + FROM series s + JOIN bots ba ON s.bot_a_id = ba.bot_id + JOIN bots bb ON s.bot_b_id = bb.bot_id + WHERE s.season_id = $1 AND s.bracket_round IS NOT NULL + ORDER BY + CASE s.bracket_round + WHEN 'quarterfinal' THEN 0 + WHEN 'semifinal' THEN 1 + WHEN 'final' THEN 2 + END, + s.bracket_position + `, seasonID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []ChampionshipSeries + for rows.Next() { + var cs ChampionshipSeries + var winnerID sql.NullString + if err := rows.Scan(&cs.ID, &cs.BotAID, &cs.BotAName, &cs.BotBID, &cs.BotBName, + &cs.Format, &cs.AWins, &cs.BWins, &cs.Status, &winnerID, + &cs.Round, &cs.BracketPosition); err != nil { + return nil, err + } + if winnerID.Valid { + cs.WinnerID = winnerID.String + } + result = append(result, cs) + } + + // Fetch games for each series + for i := range result { + games, err := fetchSeriesGames(ctx, db, result[i].ID) + if err == nil { + result[i].Games = games + } + } + + return result, nil +} + func fetchPredictions(ctx context.Context, db *sql.DB) ([]PredictionData, error) { query := ` SELECT id, match_id, predictor_id, predicted_bot, correct, created_at, resolved_at @@ -505,10 +743,66 @@ func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) { } func computeTopPredictors(stats []PredictorStats) []PredictorStats { - // Sort by accuracy (correct / total) - // Already sorted in query, just return top 50 if len(stats) > 50 { return stats[:50] } return stats } + +// persistPlaylists writes generated playlist definitions and their match associations +// to the playlists and playlist_matches tables. It uses upsert semantics so playlists +// are updated in place without creating duplicates. +func persistPlaylists(ctx context.Context, db *sql.DB, playlists []persistedPlaylist) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for _, pl := range playlists { + _, err := tx.ExecContext(ctx, ` + INSERT INTO playlists (slug, title, description, category, is_auto, updated_at) + VALUES ($1, $2, $3, $4, TRUE, NOW()) + ON CONFLICT (slug) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + category = EXCLUDED.category, + updated_at = NOW() + `, pl.Slug, pl.Title, pl.Description, pl.Category) + if err != nil { + return fmt.Errorf("persist playlist %s: %w", pl.Slug, err) + } + + // Delete old match associations and re-insert + _, err = tx.ExecContext(ctx, `DELETE FROM playlist_matches WHERE playlist_slug = $1`, pl.Slug) + if err != nil { + return fmt.Errorf("clear playlist_matches for %s: %w", pl.Slug, err) + } + + for _, pm := range pl.Matches { + _, err := tx.ExecContext(ctx, ` + INSERT INTO playlist_matches (playlist_slug, match_id, sort_order, curation_tag) + VALUES ($1, $2, $3, $4) + `, pl.Slug, pm.MatchID, pm.SortOrder, pm.CurationTag) + if err != nil { + return fmt.Errorf("persist playlist_match %s/%s: %w", pl.Slug, pm.MatchID, err) + } + } + } + + return tx.Commit() +} + +type persistedPlaylist struct { + Slug string + Title string + Description string + Category string + Matches []persistedPlaylistMatch +} + +type persistedPlaylistMatch struct { + MatchID string + SortOrder int + CurationTag string +} diff --git a/cmd/acb-index-builder/deploy.go b/cmd/acb-index-builder/deploy.go index ebe2d60..fa0178d 100644 --- a/cmd/acb-index-builder/deploy.go +++ b/cmd/acb-index-builder/deploy.go @@ -3,6 +3,7 @@ package main import ( "context" "database/sql" + "encoding/json" "fmt" "io" "log/slog" @@ -14,66 +15,52 @@ import ( ) // fetchExemptMatchIDs retrieves match IDs that should never be pruned (from series, seasons, playlists) -func fetchExemptMatchIDs(ctx context.Context, db *sql.DB) (map[string]bool, error) { - if db == nil { - return make(map[string]bool), nil - } - +func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map[string]bool, error) { exempt := make(map[string]bool) - // Matches in active series - seriesQuery := ` - SELECT DISTINCT sm.match_id - FROM series_matches sm - JOIN series s ON sm.series_id = s.id - WHERE s.status IN ('active', 'pending') - ` - rows, err := db.QueryContext(ctx, seriesQuery) - if err == nil { - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - exempt[id] = true + if db != nil { + // Matches in active series + seriesQuery := ` + SELECT DISTINCT sm.match_id + FROM series_matches sm + JOIN series s ON sm.series_id = s.id + WHERE s.status IN ('active', 'pending') + ` + rows, err := db.QueryContext(ctx, seriesQuery) + if err == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + exempt[id] = true + } } + rows.Close() + } + + // Matches in active seasons + seasonQuery := ` + SELECT DISTINCT match_id + FROM season_matches + WHERE season_id IN ( + SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW() + ) + ` + rows, err = db.QueryContext(ctx, seasonQuery) + if err == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + exempt[id] = true + } + } + rows.Close() } - rows.Close() } - // Matches in active seasons - seasonQuery := ` - SELECT DISTINCT match_id - FROM season_matches - WHERE season_id IN ( - SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW() - ) - ` - rows, err = db.QueryContext(ctx, seasonQuery) - if err == nil { - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - exempt[id] = true - } - } - rows.Close() - } - - // Matches in featured playlists - playlistQuery := ` - SELECT DISTINCT pm.match_id - FROM playlist_matches pm - JOIN playlists p ON pm.playlist_id = p.id - WHERE p.featured = true - ` - rows, err = db.QueryContext(ctx, playlistQuery) - if err == nil { - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - exempt[id] = true - } - } - rows.Close() + // Matches in generated playlist files (file-based playlists from index builder) + playlistMatchIDs := fetchPlaylistMatchIDsFromFiles(outputDir) + for id := range playlistMatchIDs { + exempt[id] = true } slog.Debug("Fetched exempt match IDs for pruning", "count", len(exempt)) @@ -164,7 +151,7 @@ func pruneR2CacheWithDB(ctx context.Context, cfg *Config, db *sql.DB) error { // Get exempt match IDs if db is provided exemptMatchIDs := make(map[string]bool) if db != nil { - exemptMatchIDs, err = fetchExemptMatchIDs(ctx, db) + exemptMatchIDs, err = fetchExemptMatchIDs(ctx, db, cfg.OutputDir) if err != nil { slog.Warn("Failed to fetch exempt match IDs, will proceed without exemptions", "error", err) } @@ -397,5 +384,47 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } +// fetchPlaylistMatchIDsFromFiles reads generated playlist JSON files from the +// output directory and returns all match IDs referenced in them. This replaces +// the old approach of querying non-existent playlist_matches DB tables. +func fetchPlaylistMatchIDsFromFiles(outputDir string) map[string]bool { + ids := make(map[string]bool) + + playlistsDir := filepath.Join(outputDir, "data", "playlists") + entries, err := os.ReadDir(playlistsDir) + if err != nil { + return ids + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + // Skip index.json — only individual playlist files have match lists + if entry.Name() == "index.json" { + continue + } + + data, err := os.ReadFile(filepath.Join(playlistsDir, entry.Name())) + if err != nil { + continue + } + + var pl struct { + Matches []struct { + MatchID string `json:"match_id"` + } `json:"matches"` + } + if err := json.Unmarshal(data, &pl); err != nil { + continue + } + for _, m := range pl.Matches { + ids[m.MatchID] = true + } + } + + return ids +} + // ensure valid function references var _ = strings.Join diff --git a/cmd/acb-index-builder/enrichment.go b/cmd/acb-index-builder/enrichment.go new file mode 100644 index 0000000..2eb1767 --- /dev/null +++ b/cmd/acb-index-builder/enrichment.go @@ -0,0 +1,523 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "path/filepath" + "strings" + "time" +) + +// CommentaryEntry is one commentary subtitle in an enriched replay. +type CommentaryEntry struct { + Turn int `json:"turn"` + Text string `json:"text"` + Type string `json:"type"` // setup, action, reaction, climax, denouement +} + +// EnrichedCommentary wraps all AI commentary for a single match. +type EnrichedCommentary struct { + MatchID string `json:"match_id"` + Generated string `json:"generated_at"` + Criteria []string `json:"criteria"` // why this match was selected + Entries []CommentaryEntry `json:"entries"` +} + +// shouldEnrich returns true and lists criteria if the match qualifies for +// AI commentary enrichment per §13.3. +func shouldEnrich(m MatchData, data *IndexData) ([]string, bool) { + if len(m.Participants) < 2 || m.WinnerID == "" { + return nil, false + } + + var criteria []string + + // 1. Back-and-forth: win_prob crossed 0.5 at least 3 times + // We can't read the full replay here, so we use a proxy: + // close score + long match suggests back-and-forth + scoreDiff := minScoreDiff(m) + if scoreDiff <= 2 && m.TurnCount >= 200 { + criteria = append(criteria, "back_and_forth") + } + + // 2. Upset: lower-rated bot wins by >200 rating points + if upset := ratingUpsetMagnitude(m); upset > 200 { + criteria = append(criteria, fmt.Sprintf("upset_%d", upset)) + } + + // 3. Evolution milestone: evolved bot's first top-10 appearance + for _, p := range m.Participants { + for _, bot := range data.Bots { + if bot.ID == p.BotID && bot.Evolved && p.Won { + rank := getBotRank(bot.ID, data) + if rank > 0 && rank <= 10 { + criteria = append(criteria, "evolution_milestone") + } + } + } + } + + // 4. High interest score as a general qualifier + if score := interestScore(m); score >= 5.0 { + criteria = append(criteria, "high_interest") + } + + return criteria, len(criteria) > 0 +} + +// enrichReplays selects featured matches from IndexData, downloads their +// replays from B2, generates AI commentary via LLM, and uploads the +// commentary JSON to R2 alongside the replay. +func enrichReplays(ctx context.Context, data *IndexData, cfg *Config, llm *LLMClient) error { + if llm == nil { + slog.Debug("No LLM client, skipping replay enrichment") + return nil + } + + // Select matches eligible for enrichment + var candidates []struct { + match MatchData + criteria []string + } + for _, m := range data.Matches { + if crit, ok := shouldEnrich(m, data); ok { + candidates = append(candidates, struct { + match MatchData + criteria []string + }{match: m, criteria: crit}) + } + } + + if len(candidates) == 0 { + slog.Debug("No matches eligible for replay enrichment") + return nil + } + + // Cap at 10 enrichments per cycle to control cost + if len(candidates) > 10 { + candidates = candidates[:10] + } + + slog.Info("Enriching replays with AI commentary", "candidates", len(candidates)) + + for _, c := range candidates { + if ctx.Err() != nil { + return ctx.Err() + } + + commentary, err := enrichSingleReplay(ctx, c.match, c.criteria, data, cfg, llm) + if err != nil { + slog.Error("Failed to enrich replay", "match_id", c.match.ID, "error", err) + continue + } + + // Upload commentary to R2 + if err := uploadCommentaryToR2(ctx, cfg, c.match.ID, commentary); err != nil { + slog.Error("Failed to upload commentary", "match_id", c.match.ID, "error", err) + continue + } + + slog.Info("Enriched replay", "match_id", c.match.ID, "entries", len(commentary.Entries), "criteria", c.criteria) + } + + return nil +} + +// enrichSingleReplay downloads the replay from B2, extracts key moments, +// calls the LLM for commentary, and returns the EnrichedCommentary. +func enrichSingleReplay(ctx context.Context, m MatchData, criteria []string, data *IndexData, cfg *Config, llm *LLMClient) (*EnrichedCommentary, error) { + // Download replay from B2 + replayJSON, err := downloadReplayFromB2(ctx, cfg, m.ID) + if err != nil { + return nil, fmt.Errorf("download replay: %w", err) + } + + // Parse just enough of the replay for commentary context + var replay struct { + WinProb [][]float64 `json:"win_prob"` + CriticalMoments []struct { + Turn int `json:"turn"` + Delta float64 `json:"delta"` + Description string `json:"description"` + } `json:"critical_moments"` + Result struct { + Winner int `json:"winner"` + Reason string `json:"reason"` + Turns int `json:"turns"` + Scores []int `json:"scores"` + } `json:"result"` + Players []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"players"` + Turns []struct { + Turn int `json:"turn"` + Events []struct { + Type string `json:"type"` + Turn int `json:"turn"` + Details any `json:"details"` + } `json:"events"` + Scores []int `json:"scores"` + } `json:"turns"` + } + if err := json.Unmarshal(replayJSON, &replay); err != nil { + return nil, fmt.Errorf("parse replay: %w", err) + } + + // Refine criteria using actual replay data + if len(replay.WinProb) > 0 { + crossings := countWinProbCrossings(replay.WinProb) + if crossings >= 3 { + // Add precise back-and-forth criterion if not already present via proxy + found := false + for _, c := range criteria { + if c == "back_and_forth" { + found = true + break + } + } + if !found { + criteria = append(criteria, fmt.Sprintf("back_and_forth_%d_crossings", crossings)) + } + } + } + + // Build the prompt with match context + prompt := buildCommentaryPrompt(m, replay, criteria, data) + + // Call LLM + response, err := llm.chatCompletion(ctx, prompt) + if err != nil { + return nil, fmt.Errorf("llm commentary: %w", err) + } + + // Parse the LLM response into commentary entries + entries := parseCommentaryResponse(response) + + if len(entries) == 0 { + return nil, fmt.Errorf("no commentary entries generated") + } + + return &EnrichedCommentary{ + MatchID: m.ID, + Generated: time.Now().UTC().Format(time.RFC3339), + Criteria: criteria, + Entries: entries, + }, nil +} + +// buildCommentaryPrompt creates the LLM prompt for commentary generation. +func buildCommentaryPrompt(m MatchData, replay struct { + WinProb [][]float64 `json:"win_prob"` + CriticalMoments []struct { + Turn int `json:"turn"` + Delta float64 `json:"delta"` + Description string `json:"description"` + } `json:"critical_moments"` + Result struct { + Winner int `json:"winner"` + Reason string `json:"reason"` + Turns int `json:"turns"` + Scores []int `json:"scores"` + } `json:"result"` + Players []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"players"` + Turns []struct { + Turn int `json:"turn"` + Events []struct { + Type string `json:"type"` + Turn int `json:"turn"` + Details any `json:"details"` + } `json:"events"` + Scores []int `json:"scores"` + } `json:"turns"` +}, criteria []string, data *IndexData) string { + var sb strings.Builder + + sb.WriteString("You are an AI Code Battle commentator. Generate 5-15 lines of play-by-play commentary for this match.\n") + sb.WriteString("Each line must be exactly: TURN|TYPE|TEXT\n") + sb.WriteString("Where TYPE is one of: setup, action, reaction, climax, denouement\n") + sb.WriteString("Only cover key moments. Be dramatic but factual. No emojis. Keep each text under 120 chars.\n\n") + + sb.WriteString("MATCH CONTEXT:\n") + playerNames := make([]string, len(replay.Players)) + for i, p := range replay.Players { + playerNames[i] = p.Name + } + sb.WriteString(fmt.Sprintf("Players: %s\n", strings.Join(playerNames, " vs "))) + + winnerName := "Draw" + if replay.Result.Winner >= 0 && replay.Result.Winner < len(replay.Players) { + winnerName = replay.Players[replay.Result.Winner].Name + } + sb.WriteString(fmt.Sprintf("Winner: %s by %s in %d turns\n", winnerName, replay.Result.Reason, replay.Result.Turns)) + sb.WriteString(fmt.Sprintf("Final scores: %v\n", replay.Result.Scores)) + sb.WriteString(fmt.Sprintf("Selection criteria: %s\n", strings.Join(criteria, ", "))) + + // Pre-match ratings + for _, p := range m.Participants { + name := getBotName(p.BotID, data) + sb.WriteString(fmt.Sprintf(" %s: pre-match rating %d (evolved: %v)\n", + name, int(p.PreMatchRating), isEvolved(p.BotID, data))) + } + + // Win probability summary + if len(replay.WinProb) > 0 { + crossings := countWinProbCrossings(replay.WinProb) + sb.WriteString(fmt.Sprintf("Win prob crossed 0.5: %d times\n", crossings)) + + // Biggest swing + maxSwing := 0.0 + maxSwingTurn := 0 + for i, wp := range replay.WinProb { + if len(wp) >= 2 { + swing := wp[0] - 0.5 + if swing < 0 { + swing = -swing + } + if i > 0 { + prev := replay.WinProb[i-1] + if len(prev) >= 2 { + delta := wp[0] - prev[0] + if delta < 0 { + delta = -delta + } + if delta > maxSwing { + maxSwing = delta + maxSwingTurn = i + } + } + } + } + } + if maxSwing > 0.1 { + sb.WriteString(fmt.Sprintf("Biggest swing: %.0f%% at turn %d\n", maxSwing*100, maxSwingTurn)) + } + } + + // Critical moments + if len(replay.CriticalMoments) > 0 { + sb.WriteString("Critical moments:\n") + for _, cm := range replay.CriticalMoments { + sb.WriteString(fmt.Sprintf(" Turn %d: %s (delta %.0f%%)\n", cm.Turn, cm.Description, cm.Delta*100)) + } + } + + // Key events (cores captured, mass deaths) - scan at most every 10th turn + sb.WriteString("Key events:\n") + step := max(1, len(replay.Turns)/30) + for i := 0; i < len(replay.Turns); i += step { + t := replay.Turns[i] + for _, e := range t.Events { + if e.Type == "core_captured" || e.Type == "combat_death" { + sb.WriteString(fmt.Sprintf(" Turn %d: %s\n", t.Turn, e.Type)) + } + } + } + + sb.WriteString("\nGenerate commentary now. One entry per line, format: TURN|TYPE|TEXT\n") + + return sb.String() +} + +// parseCommentaryResponse converts the LLM text output into CommentaryEntry slice. +func parseCommentaryResponse(response string) []CommentaryEntry { + var entries []CommentaryEntry + for _, line := range strings.Split(response, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + parts := strings.SplitN(line, "|", 3) + if len(parts) != 3 { + continue + } + + var turn int + if _, err := fmt.Sscanf(strings.TrimSpace(parts[0]), "%d", &turn); err != nil { + continue + } + + entryType := strings.TrimSpace(parts[1]) + text := strings.TrimSpace(parts[2]) + + validTypes := map[string]bool{ + "setup": true, "action": true, "reaction": true, + "climax": true, "denouement": true, + } + if !validTypes[entryType] { + entryType = "action" + } + + entries = append(entries, CommentaryEntry{ + Turn: turn, + Text: text, + Type: entryType, + }) + } + + return entries +} + +// countWinProbCrossings counts how many times p0's win_prob crosses 0.5. +func countWinProbCrossings(winProb [][]float64) int { + if len(winProb) < 2 { + return 0 + } + crossings := 0 + for i := 1; i < len(winProb); i++ { + prev := winProb[i-1] + cur := winProb[i] + if len(prev) >= 1 && len(cur) >= 1 { + if (prev[0] < 0.5 && cur[0] >= 0.5) || (prev[0] >= 0.5 && cur[0] < 0.5) { + crossings++ + } + } + } + return crossings +} + +// downloadReplayFromB2 downloads a replay JSON from B2, handling .json.gz. +func downloadReplayFromB2(ctx context.Context, cfg *Config, matchID string) ([]byte, error) { + b2Client, err := getB2Client(cfg) + if err != nil { + return nil, fmt.Errorf("create B2 client: %w", err) + } + + // Try .json.gz first (standard format) + key := fmt.Sprintf("replays/%s.json.gz", matchID) + body, err := b2Client.downloadObject(ctx, key) + if err != nil { + // Try uncompressed + key = fmt.Sprintf("replays/%s.json", matchID) + body, err = b2Client.downloadObject(ctx, key) + if err != nil { + return nil, fmt.Errorf("download replay %s: %w", matchID, err) + } + } + defer body.Close() + + raw, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("read replay body: %w", err) + } + + // Decompress gzip if needed + if len(raw) > 2 && raw[0] == 0x1f && raw[1] == 0x8b { + gzReader, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("gzip reader: %w", err) + } + defer gzReader.Close() + decompressed, err := io.ReadAll(gzReader) + if err != nil { + return nil, fmt.Errorf("gzip decompress: %w", err) + } + return decompressed, nil + } + + return raw, nil +} + +// uploadCommentaryToR2 uploads the enriched commentary JSON to R2. +func uploadCommentaryToR2(ctx context.Context, cfg *Config, matchID string, commentary *EnrichedCommentary) error { + r2Client, err := getR2Client(cfg) + if err != nil { + return fmt.Errorf("create R2 client: %w", err) + } + + data, err := json.Marshal(commentary) + if err != nil { + return fmt.Errorf("marshal commentary: %w", err) + } + + key := fmt.Sprintf("commentary/%s.json", matchID) + return r2Client.uploadFile(ctx, key, bytes.NewReader(data), "application/json") +} + +// isEvolved checks if a bot is an evolved bot. +func isEvolved(botID string, data *IndexData) bool { + for _, bot := range data.Bots { + if bot.ID == botID { + return bot.Evolved + } + } + return false +} + +// generateEnrichedIndex creates data/commentary/index.json listing enriched match IDs. +// The frontend uses this to discover which matches have AI commentary available. +func generateEnrichedIndex(ctx context.Context, data *IndexData, cfg *Config, outputDir string) error { + commentaryDir := filepath.Join(outputDir, "data", "commentary") + + type enrichedEntry struct { + MatchID string `json:"match_id"` + Criteria []string `json:"criteria"` + } + + // Check R2 for existing commentary files + r2Client, err := getR2Client(cfg) + if err != nil { + slog.Debug("Cannot list R2 commentary, skipping enriched index", "error", err) + return nil + } + + objects, err := r2Client.listObjects(ctx, "commentary/") + if err != nil { + slog.Debug("Failed to list commentary objects", "error", err) + return nil + } + + var entries []enrichedEntry + for _, obj := range objects { + if !strings.HasSuffix(obj.Key, ".json") { + continue + } + // Extract match_id from commentary/{match_id}.json + matchID := strings.TrimPrefix(obj.Key, "commentary/") + matchID = strings.TrimSuffix(matchID, ".json") + if matchID == "" || matchID == "index" { + continue + } + + // Try to read criteria from the commentary file + criteria := []string{} + body, err := r2Client.downloadObject(ctx, obj.Key) + if err == nil { + var comm EnrichedCommentary + if json.NewDecoder(body).Decode(&comm) == nil { + criteria = comm.Criteria + } + body.Close() + } + + entries = append(entries, enrichedEntry{ + MatchID: matchID, + Criteria: criteria, + }) + } + + if len(entries) == 0 { + return nil + } + + type enrichedIndex struct { + UpdatedAt string `json:"updated_at"` + Entries []enrichedEntry `json:"entries"` + } + + index := enrichedIndex{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Entries: entries, + } + + return writeJSON(filepath.Join(commentaryDir, "index.json"), index) +} diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index c82574d..a5b9afe 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -1,10 +1,13 @@ package main import ( + "context" + "database/sql" "encoding/json" "fmt" "os" "path/filepath" + "strings" "time" ) @@ -89,7 +92,7 @@ type MatchIndex struct { } // generateAllIndexes creates all JSON index files -func generateAllIndexes(data *IndexData, outputDir string) error { +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 @@ -135,6 +138,14 @@ func generateAllIndexes(data *IndexData, outputDir string) error { 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 } @@ -301,6 +312,14 @@ func matchToSummary(m MatchData, data *IndexData) MatchSummary { } 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"` @@ -311,21 +330,39 @@ func generateSeriesIndex(data *IndexData, outputDir string) error { Series: data.Series, } - return writeJSON(filepath.Join(outputDir, "data", "series", "index.json"), index) + 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"` - Seasons []SeasonData `json:"seasons"` + UpdatedAt string `json:"updated_at"` + ActiveSeason *SeasonData `json:"active_season"` + Seasons []SeasonData `json:"seasons"` } index := SeasonsIndex{ - UpdatedAt: data.GeneratedAt.Format(time.RFC3339), - Seasons: data.Seasons, + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + ActiveSeason: activeSeason, + Seasons: data.Seasons, } - return writeJSON(filepath.Join(outputDir, "data", "seasons", "index.json"), index) + return writeJSON(filepath.Join(seasonsDir, "index.json"), index) } func generatePredictionsIndex(data *IndexData, outputDir string) error { @@ -345,12 +382,524 @@ func generatePredictionsIndex(data *IndexData, outputDir string) error { func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error { playlistsDir := filepath.Join(outputDir, "data", "playlists") - // Closest finishes: matches with smallest score differential - closest := filterMatches(data.Matches, func(m MatchData) bool { - if len(m.Participants) < 2 { - return false + 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 isRivalryMatch(m, data) + }, + 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 isNewBotDebut(m, data) + }, + 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 } - // Check if score difference is small (1-2 points) + + 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:] { @@ -360,66 +909,86 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string] } } } - return minDiff <= 2 - }) - if err := writePlaylist(playlistsDir, "closest-finishes.json", "Closest Finishes", closest, data); err != nil { - return err - } - - // Biggest upsets: lower-rated bot won - // This would need rating data at match time, simplified here - upsets := filterMatches(data.Matches, func(m MatchData) bool { - // Simplified: check if winner had fewer wins overall - if m.WinnerID == "" { - return false + if minDiff <= 1 { + score += 3.0 + } else if minDiff <= 2 { + score += 2.0 } - return true // Placeholder - would need actual rating delta - }) - if err := writePlaylist(playlistsDir, "biggest-upsets.json", "Biggest Upsets", upsets[:min(20, len(upsets))], data); err != nil { - return err } - - // Best comebacks: winner had low win probability at some point - // Would need win probability data - placeholder - comebacks := filterMatches(data.Matches, func(m MatchData) bool { - return false // Placeholder - needs win_prob data - }) - if err := writePlaylist(playlistsDir, "best-comebacks.json", "Best Comebacks", comebacks, data); err != nil { - return err + // Upsets are interesting + upset := ratingUpsetMagnitude(m) + if upset >= 200 { + score += 4.0 + } else if upset >= 100 { + score += 2.0 } - - // Featured: recent high-profile matches - featured := data.Matches[:min(20, len(data.Matches))] - if err := writePlaylist(playlistsDir, "featured.json", "Featured Matches", featured, data); err != nil { - return err + // Long matches are interesting + if m.TurnCount >= 400 { + score += 2.0 + } else if m.TurnCount >= 300 { + score += 1.0 } - - return nil + // High-rated opponents + cr := combinedRating(m) + if cr >= 3400 { + score += 2.0 + } else if cr >= 3200 { + score += 1.0 + } + return score } -type Playlist struct { - Slug string `json:"slug"` - Title string `json:"title"` - Description string `json:"description"` - UpdatedAt string `json:"updated_at"` - Matches []MatchSummary `json:"matches"` +func sortByScoreDiff(matches []MatchData) { + sortSlice(matches, func(i, j int) bool { + return minScoreDiff(matches[i]) < minScoreDiff(matches[j]) + }) } -func writePlaylist(dir, filename, title string, matches []MatchData, data *IndexData) error { - summaries := make([]MatchSummary, 0, len(matches)) - for _, m := range matches { - summaries = append(summaries, matchToSummary(m, data)) - } +func sortByUpsetMagnitude(matches []MatchData) { + sortSlice(matches, func(i, j int) bool { + return ratingUpsetMagnitude(matches[i]) > ratingUpsetMagnitude(matches[j]) + }) +} - playlist := Playlist{ - Slug: filename[:len(filename)-5], // remove .json - Title: title, - Description: fmt.Sprintf("Auto-curated playlist: %s", title), - UpdatedAt: data.GeneratedAt.Format(time.RFC3339), - Matches: summaries, - } +func sortByTurnCount(matches []MatchData) { + sortSlice(matches, func(i, j int) bool { + return matches[i].TurnCount > matches[j].TurnCount + }) +} - return writeJSON(filepath.Join(dir, filename), playlist) +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 { @@ -432,6 +1001,148 @@ func filterMatches(matches []MatchData, pred func(MatchData) bool) []MatchData { 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 +} + +// 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 { @@ -445,6 +1156,52 @@ func writeJSON(path string, data interface{}) error { 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 } diff --git a/cmd/acb-index-builder/main.go b/cmd/acb-index-builder/main.go index 3182cb6..f06ab37 100644 --- a/cmd/acb-index-builder/main.go +++ b/cmd/acb-index-builder/main.go @@ -127,6 +127,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { cfg.OutputDir + "/data/meta", cfg.OutputDir + "/data/evolution", cfg.OutputDir + "/data/blog", + cfg.OutputDir + "/data/commentary", cfg.OutputDir + "/cards", } for _, dir := range dirs { @@ -151,7 +152,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { } // Generate all index files - if err := generateAllIndexes(data, cfg.OutputDir); err != nil { + if err := generateAllIndexes(data, cfg.OutputDir, db); err != nil { return fmt.Errorf("generate indexes: %w", err) } @@ -160,7 +161,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { if cfg.LLMBaseURL != "" { llmClient = NewLLMClient(cfg.LLMBaseURL, cfg.LLMAPIKey) } - if err := generateBlog(data, cfg.OutputDir, llmClient); err != nil { + if err := generateBlog(data, cfg.OutputDir, llmClient, cfg); err != nil { slog.Error("Failed to generate blog", "error", err) // Non-fatal - continue with rest of build } @@ -189,6 +190,18 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { // Non-fatal } + // Enrich featured replays with AI commentary (§13.3) + if err := enrichReplays(ctx, data, cfg, llmClient); err != nil { + slog.Error("Failed to enrich replays", "error", err) + // Non-fatal - unenriched replays are still valid + } + + // Generate enriched commentary index (list of matches with AI commentary) + if err := generateEnrichedIndex(ctx, data, cfg, cfg.OutputDir); err != nil { + slog.Error("Failed to generate enriched index", "error", err) + // Non-fatal + } + return nil } @@ -206,7 +219,7 @@ func promoteRecentReplaysForCycle(ctx context.Context, db *sql.DB, cfg *Config) } // Get exempt match IDs (playlists, series, seasons) - exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db) + exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db, cfg.OutputDir) if err != nil { slog.Warn("Failed to fetch exempt match IDs, promoting all", "error", err) exemptMatchIDs = make(map[string]bool) diff --git a/cmd/acb-index-builder/main_test.go b/cmd/acb-index-builder/main_test.go index 3bd2b8c..4858f80 100644 --- a/cmd/acb-index-builder/main_test.go +++ b/cmd/acb-index-builder/main_test.go @@ -6,6 +6,7 @@ import ( "image/color" "os" "path/filepath" + "strings" "testing" "time" ) @@ -248,18 +249,29 @@ func TestGeneratePlaylists(t *testing.T) { CompletedAt: now, Participants: []ParticipantData{ {BotID: "bot1", Score: 3, Won: true}, - {BotID: "bot2", Score: 2, Won: false}, // Close finish (diff = 1) + {BotID: "bot2", Score: 2, Won: false}, }, }, { ID: "match2", WinnerID: "bot2", - TurnCount: 150, + TurnCount: 350, EndCondition: "dominance", CompletedAt: now.Add(-time.Hour), Participants: []ParticipantData{ - {BotID: "bot1", Score: 0, Won: false}, - {BotID: "bot2", Score: 10, Won: true}, // Not close (diff = 10) + {BotID: "bot1", Score: 0, Won: false, PreMatchRating: 1800}, + {BotID: "bot2", Score: 10, Won: true, PreMatchRating: 1500}, + }, + }, + { + ID: "match3", + WinnerID: "bot1", + TurnCount: 400, + EndCondition: "turn_limit", + CompletedAt: now.Add(-2 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1700}, + {BotID: "bot2", Score: 3, Won: false, PreMatchRating: 1600}, }, }, }, @@ -276,20 +288,565 @@ func TestGeneratePlaylists(t *testing.T) { t.Fatalf("generatePlaylists failed: %v", err) } - // Check closest-finishes playlist + // Verify index.json was generated + indexContent, err := os.ReadFile(filepath.Join(playlistsDir, "index.json")) + if err != nil { + t.Fatalf("Failed to read playlists/index.json: %v", err) + } + var index PlaylistIndex + if err := json.Unmarshal(indexContent, &index); err != nil { + t.Fatalf("Failed to parse playlists/index.json: %v", err) + } + if len(index.Playlists) == 0 { + t.Error("Expected playlists in index.json, got 0") + } + + // Verify each playlist has required fields + for _, p := range index.Playlists { + if p.Slug == "" { + t.Error("Playlist summary missing slug") + } + if p.Title == "" { + t.Error("Playlist summary missing title") + } + if p.Category == "" { + t.Error("Playlist summary missing category") + } + } + + // Verify closest-finishes playlist content content, err := os.ReadFile(filepath.Join(playlistsDir, "closest-finishes.json")) if err != nil { t.Fatalf("Failed to read closest-finishes.json: %v", err) } - var playlist Playlist if err := json.Unmarshal(content, &playlist); err != nil { t.Fatalf("Failed to parse closest-finishes.json: %v", err) } + if playlist.Category != "close_games" { + t.Errorf("closest-finishes category: got %q, want %q", playlist.Category, "close_games") + } + if len(playlist.Matches) != 2 { + t.Errorf("closest-finishes: expected 2 matches, got %d", len(playlist.Matches)) + } + if len(playlist.Matches) > 0 && playlist.Matches[0].MatchID != "match1" { + t.Errorf("closest-finishes first (closest): got %q, want %q", playlist.Matches[0].MatchID, "match1") + } - // Should only include match1 (close finish) - if len(playlist.Matches) != 1 { - t.Errorf("closest-finishes: expected 1 match, got %d", len(playlist.Matches)) + // Verify marathon-matches playlist + marathonContent, err := os.ReadFile(filepath.Join(playlistsDir, "marathon-matches.json")) + if err != nil { + t.Fatalf("Failed to read marathon-matches.json: %v", err) + } + var marathon Playlist + if err := json.Unmarshal(marathonContent, &marathon); err != nil { + t.Fatalf("Failed to parse marathon-matches.json: %v", err) + } + if marathon.Category != "long_games" { + t.Errorf("marathon-matches category: got %q, want %q", marathon.Category, "long_games") + } + // Should include match2 (350) and match3 (400), sorted by turn count desc + if len(marathon.Matches) != 2 { + t.Errorf("marathon-matches: expected 2 matches, got %d", len(marathon.Matches)) + } + if len(marathon.Matches) > 0 && marathon.Matches[0].MatchID != "match3" { + t.Errorf("marathon-matches first: got %q, want %q", marathon.Matches[0].MatchID, "match3") + } + + // Verify biggest-upsets playlist + upsetContent, err := os.ReadFile(filepath.Join(playlistsDir, "biggest-upsets.json")) + if err != nil { + t.Fatalf("Failed to read biggest-upsets.json: %v", err) + } + var upsets Playlist + if err := json.Unmarshal(upsetContent, &upsets); err != nil { + t.Fatalf("Failed to parse biggest-upsets.json: %v", err) + } + // match2 has winner rating 1500 vs loser 1800 → upset of 300 + if len(upsets.Matches) != 1 { + t.Errorf("biggest-upsets: expected 1 match, got %d", len(upsets.Matches)) + } +} + +func TestInterestScore(t *testing.T) { + now := time.Now() + // Close finish + upset + long game → high score + m := MatchData{ + WinnerID: "bot2", + TurnCount: 450, + CompletedAt: now, + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800}, + {BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1400}, + }, + } + score := interestScore(m) + if score < 5.0 { + t.Errorf("interestScore for exciting match: got %f, want >= 5.0", score) + } + + // Boring match → low score + m2 := MatchData{ + WinnerID: "bot1", + TurnCount: 100, + CompletedAt: now, + Participants: []ParticipantData{ + {BotID: "bot1", Score: 10, Won: true, PreMatchRating: 1500}, + {BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500}, + }, + } + score2 := interestScore(m2) + if score2 >= 2.0 { + t.Errorf("interestScore for boring match: got %f, want < 2.0", score2) + } +} + +func TestFormatMatchTitle(t *testing.T) { + data := &IndexData{ + Bots: []BotData{ + {ID: "bot1", Name: "SwarmBot"}, + {ID: "bot2", Name: "HunterBot"}, + }, + } + m := MatchData{ + ID: "match1", + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3}, + {BotID: "bot2", Score: 2}, + }, + } + title := formatMatchTitle(m, data) + if title != "SwarmBot 3 – 2 HunterBot" { + t.Errorf("formatMatchTitle: got %q, want %q", title, "SwarmBot 3 – 2 HunterBot") + } +} + +func TestIsComeback(t *testing.T) { + // Close upset (rating diff >= 80, score diff <= 3) = comeback + m := MatchData{ + WinnerID: "bot2", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800}, + {BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1600}, + }, + } + if !isComeback(m) { + t.Error("Expected close upset to be a comeback") + } + + // Decisive win, no upset = not a comeback + m2 := MatchData{ + WinnerID: "bot1", + TurnCount: 150, + Participants: []ParticipantData{ + {BotID: "bot1", Score: 8, Won: true, PreMatchRating: 1700}, + {BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1500}, + }, + } + if isComeback(m2) { + t.Error("Expected decisive non-upset to not be a comeback") + } + + // No winner = not a comeback + m3 := MatchData{ + WinnerID: "", + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3}, + {BotID: "bot2", Score: 2}, + }, + } + if isComeback(m3) { + t.Error("Expected no-winner match to not be a comeback") + } +} + +func TestTurnaroundMagnitude(t *testing.T) { + // Bigger upset + closer score = bigger turnaround + big := MatchData{ + WinnerID: "underdog", + TurnCount: 400, + Participants: []ParticipantData{ + {BotID: "favored", Score: 2, Won: false, PreMatchRating: 1900}, + {BotID: "underdog", Score: 3, Won: true, PreMatchRating: 1500}, + }, + } + small := MatchData{ + WinnerID: "slight_underdog", + TurnCount: 200, + Participants: []ParticipantData{ + {BotID: "favored", Score: 1, Won: false, PreMatchRating: 1600}, + {BotID: "slight_underdog", Score: 3, Won: true, PreMatchRating: 1500}, + }, + } + bigMag := turnaroundMagnitude(big) + smallMag := turnaroundMagnitude(small) + if bigMag <= smallMag { + t.Errorf("Expected bigger turnaround (%f) > smaller (%f)", bigMag, smallMag) + } +} + +func TestIsEvolutionBreakthrough(t *testing.T) { + data := &IndexData{ + Bots: []BotData{ + {ID: "evo1", Name: "EvolvedBot", Evolved: true}, + {ID: "human1", Name: "HumanBot", Evolved: false}, + }, + } + + // Evolved bot beats high-rated opponent + m := MatchData{ + WinnerID: "evo1", + Participants: []ParticipantData{ + {BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400}, + {BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650}, + }, + } + if !isEvolutionBreakthrough(m, data) { + t.Error("Expected evolved bot beating rated opponent to be a breakthrough") + } + + // Human bot wins = not a breakthrough + m2 := MatchData{ + WinnerID: "human1", + Participants: []ParticipantData{ + {BotID: "evo1", Score: 1, Won: false, PreMatchRating: 1400}, + {BotID: "human1", Score: 5, Won: true, PreMatchRating: 1650}, + }, + } + if isEvolutionBreakthrough(m2, data) { + t.Error("Expected human bot winning to not be a breakthrough") + } + + // Evolved bot beats low-rated opponent = not a breakthrough + m3 := MatchData{ + WinnerID: "evo1", + Participants: []ParticipantData{ + {BotID: "evo1", Score: 5, Won: true, PreMatchRating: 1400}, + {BotID: "human1", Score: 1, Won: false, PreMatchRating: 1200}, + }, + } + if isEvolutionBreakthrough(m3, data) { + t.Error("Expected evolved bot beating low-rated opponent to not be a breakthrough") + } +} + +func TestIsRivalryMatch(t *testing.T) { + _ = time.Now() // ensure time import used + matches := []MatchData{ + {ID: "m1", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}, + {ID: "m2", WinnerID: "bot2", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}, + {ID: "m3", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}, + {ID: "m4", WinnerID: "bot3", Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}}, + } + data := &IndexData{Matches: matches} + + // bot1 vs bot2 has 3 matches = rivalry + m := MatchData{ + WinnerID: "bot1", + Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}, + } + if !isRivalryMatch(m, data) { + t.Error("Expected 3-match pair to be a rivalry") + } + + // bot3 vs bot4 has only 1 match = not a rivalry + m2 := MatchData{ + WinnerID: "bot3", + Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}, + } + if isRivalryMatch(m2, data) { + t.Error("Expected 1-match pair to not be a rivalry") + } +} + +func TestCurateWeeklyHighlights(t *testing.T) { + now := time.Now() + + matches := []MatchData{ + // 1. Upset: underdog wins by large rating margin + { + ID: "upset1", WinnerID: "bot2", TurnCount: 250, CompletedAt: now.Add(-time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1800}, + {BotID: "bot2", Score: 3, Won: true, PreMatchRating: 1400}, + }, + }, + // 2. Elite clash: very high combined rating + { + ID: "elite1", WinnerID: "bot1", TurnCount: 150, CompletedAt: now.Add(-2 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 4, Won: true, PreMatchRating: 1800}, + {BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1700}, + }, + }, + // 3. Marathon: very long match + { + ID: "marathon1", WinnerID: "bot1", TurnCount: 450, CompletedAt: now.Add(-3 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1200}, + {BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1200}, + }, + }, + // 4. Close finish: score diff of 1 + { + ID: "close1", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-4 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1200}, + {BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1200}, + }, + }, + // Extra filler matches to fill out the criteria + { + ID: "filler1", WinnerID: "bot1", TurnCount: 100, CompletedAt: now.Add(-5 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1500}, + {BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500}, + }, + }, + { + ID: "filler2", WinnerID: "bot2", TurnCount: 150, CompletedAt: now.Add(-6 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1500}, + {BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1500}, + }, + }, + // Old match — outside 7 days + { + ID: "old1", WinnerID: "bot1", TurnCount: 400, CompletedAt: now.Add(-8 * 24 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500}, + {BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500}, + }, + }, + // No winner + { + ID: "nowin", WinnerID: "", TurnCount: 300, CompletedAt: now.Add(-time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 2}, + {BotID: "bot2", Score: 2}, + }, + }, + } + + cutoff := now.AddDate(0, 0, -7) + curated := curateWeeklyHighlights(matches, cutoff) + + if len(curated) == 0 { + t.Fatal("Expected curated matches, got 0") + } + if len(curated) > 20 { + t.Errorf("Expected at most 20 curated matches, got %d", len(curated)) + } + + seenIDs := make(map[string]bool) + for _, c := range curated { + if c.Match.ID == "" { + t.Error("Curated match has empty ID") + } + if c.Tag == "" { + t.Errorf("Curated match %s has empty tag", c.Match.ID) + } + if seenIDs[c.Match.ID] { + t.Errorf("Duplicate match ID in curated results: %s", c.Match.ID) + } + seenIDs[c.Match.ID] = true + } + + if seenIDs["old1"] { + t.Error("Old match should not appear in weekly highlights") + } + if seenIDs["nowin"] { + t.Error("No-winner match should not appear in weekly highlights") + } + + // Verify at least one tag per criteria type + tagTypes := make(map[string]bool) + for _, c := range curated { + if strings.Contains(c.Tag, "Closest finish") { + tagTypes["closest"] = true + } + if strings.Contains(c.Tag, "Marathon battle") { + tagTypes["marathon"] = true + } + if strings.Contains(c.Tag, "Elite clash") { + tagTypes["elite"] = true + } + if strings.Contains(c.Tag, "Upset victory") { + tagTypes["upset"] = true + } + } + if !tagTypes["closest"] { + t.Error("Expected at least one 'Closest finish' tag") + } + if !tagTypes["marathon"] { + t.Error("Expected at least one 'Marathon battle' tag") + } + if !tagTypes["elite"] { + t.Error("Expected at least one 'Elite clash' tag") + } + if !tagTypes["upset"] { + t.Error("Expected at least one 'Upset victory' tag") + } +} + +func TestBestOfWeekPlaylistHasCurationTags(t *testing.T) { + now := time.Now() + data := &IndexData{ + GeneratedAt: now, + Bots: []BotData{ + {ID: "bot1", Name: "Bot1"}, + {ID: "bot2", Name: "Bot2"}, + }, + Matches: []MatchData{ + { + ID: "weekly_close", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500}, + {BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500}, + }, + }, + { + ID: "weekly_marathon", WinnerID: "bot2", TurnCount: 450, CompletedAt: now.Add(-2 * time.Hour), + Participants: []ParticipantData{ + {BotID: "bot1", Score: 2, Won: false, PreMatchRating: 1700}, + {BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1600}, + }, + }, + }, + } + + tmpDir := t.TempDir() + playlistsDir := filepath.Join(tmpDir, "data", "playlists") + if err := os.MkdirAll(playlistsDir, 0755); err != nil { + t.Fatalf("Failed to create playlists dir: %v", err) + } + + botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"} + if err := generatePlaylists(data, tmpDir, botNameMap); err != nil { + t.Fatalf("generatePlaylists failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(playlistsDir, "best-of-week.json")) + if err != nil { + t.Fatalf("Failed to read best-of-week.json: %v", err) + } + var playlist Playlist + if err := json.Unmarshal(content, &playlist); err != nil { + t.Fatalf("Failed to parse best-of-week.json: %v", err) + } + + if playlist.Category != "weekly" { + t.Errorf("best-of-week category: got %q, want %q", playlist.Category, "weekly") + } + + tagCount := 0 + for _, m := range playlist.Matches { + if m.CurationTag != "" { + tagCount++ + } + } + if tagCount == 0 { + t.Error("Expected best-of-week matches to have curation tags, got 0") + } +} + +func TestGeneratePlaylistsWithNewTypes(t *testing.T) { + now := time.Now() + data := &IndexData{ + GeneratedAt: now, + Bots: []BotData{ + {ID: "human1", Name: "HumanBot", Evolved: false}, + {ID: "evo1", Name: "EvoBot", Evolved: true}, + }, + Matches: []MatchData{ + // Comeback: close upset + { + ID: "comeback1", WinnerID: "human1", TurnCount: 400, CompletedAt: now, + Participants: []ParticipantData{ + {BotID: "human1", Score: 3, Won: true, PreMatchRating: 1500}, + {BotID: "evo1", Score: 2, Won: false, PreMatchRating: 1700}, + }, + }, + // Evolution breakthrough: evolved bot beats rated opponent + { + ID: "evo1", WinnerID: "evo1", TurnCount: 300, CompletedAt: now, + Participants: []ParticipantData{ + {BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400}, + {BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650}, + }, + }, + }, + } + + tmpDir := t.TempDir() + playlistsDir := filepath.Join(tmpDir, "data", "playlists") + if err := os.MkdirAll(playlistsDir, 0755); err != nil { + t.Fatalf("Failed to create playlists dir: %v", err) + } + + botNameMap := map[string]string{"human1": "HumanBot", "evo1": "EvoBot"} + if err := generatePlaylists(data, tmpDir, botNameMap); err != nil { + t.Fatalf("generatePlaylists failed: %v", err) + } + + // Verify best-comebacks.json + cb, err := os.ReadFile(filepath.Join(playlistsDir, "best-comebacks.json")) + if err != nil { + t.Fatalf("Failed to read best-comebacks.json: %v", err) + } + var cbPlaylist Playlist + if err := json.Unmarshal(cb, &cbPlaylist); err != nil { + t.Fatalf("Failed to parse best-comebacks.json: %v", err) + } + if cbPlaylist.Category != "comebacks" { + t.Errorf("comebacks category: got %q", cbPlaylist.Category) + } + + // Verify evolution-breakthroughs.json + eb, err := os.ReadFile(filepath.Join(playlistsDir, "evolution-breakthroughs.json")) + if err != nil { + t.Fatalf("Failed to read evolution-breakthroughs.json: %v", err) + } + var ebPlaylist Playlist + if err := json.Unmarshal(eb, &ebPlaylist); err != nil { + t.Fatalf("Failed to parse evolution-breakthroughs.json: %v", err) + } + if len(ebPlaylist.Matches) < 1 { + t.Errorf("Expected at least 1 evolution breakthrough, got %d", len(ebPlaylist.Matches)) + } + + // Verify index.json includes new playlist types + idx, err := os.ReadFile(filepath.Join(playlistsDir, "index.json")) + if err != nil { + t.Fatalf("Failed to read index.json: %v", err) + } + var index PlaylistIndex + if err := json.Unmarshal(idx, &index); err != nil { + t.Fatalf("Failed to parse index.json: %v", err) + } + slugs := make(map[string]bool) + for _, p := range index.Playlists { + slugs[p.Slug] = true + } + for _, required := range []string{"best-comebacks", "evolution-breakthroughs", "rivalry-classics"} { + if !slugs[required] { + t.Errorf("Missing playlist %q in index", required) + } + } +} + +func TestSortSlice(t *testing.T) { + matches := []MatchData{ + {ID: "m1", TurnCount: 100}, + {ID: "m2", TurnCount: 300}, + {ID: "m3", TurnCount: 200}, + } + sortSlice(matches, func(i, j int) bool { + return matches[i].TurnCount > matches[j].TurnCount + }) + if matches[0].ID != "m2" || matches[1].ID != "m3" || matches[2].ID != "m1" { + t.Errorf("sortSlice: unexpected order: %v", matches) } } diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index 9d66af0..166240f 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -43,8 +43,23 @@ func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) { // updateSeriesGameResults finds completed series matches that haven't had their // winner recorded yet. It updates series_games.winner_id and increments -// a_wins or b_wins on the series table. +// a_wins or b_wins on the series table. Drawn games (m.winner IS NULL) are +// marked so the series can continue to the next game. func (m *Matchmaker) updateSeriesGameResults(ctx context.Context) error { + // First, handle draws: completed matches with no winner + _, err := m.db.ExecContext(ctx, ` + UPDATE series_games SET winner_id = 'draw' + FROM matches m + WHERE series_games.match_id = m.match_id + AND series_games.winner_id IS NULL + AND m.status = 'completed' + AND m.winner IS NULL + `) + if err != nil { + log.Printf("series-scheduler: failed to process drawn games: %v", err) + } + + // Then, process games with a winner rows, err := m.db.QueryContext(ctx, ` SELECT sg.series_id, sg.game_num, sg.match_id, m.winner FROM series_games sg @@ -173,6 +188,52 @@ func (m *Matchmaker) finalizeCompletedSeries(ctx context.Context) error { log.Printf("series-scheduler: finalized series %d, winner=%s (%d-%d)", s.ID, winnerID, s.AWins, s.BWins) } + // Also finalize series where all games are played but neither side reached + // the threshold (can happen with draws). Winner is whoever has more wins, + // or NULL if equal. + allPlayed, err := m.db.QueryContext(ctx, ` + SELECT s.id, s.bot_a_id, s.bot_b_id, s.a_wins, s.b_wins, s.format + FROM series s + WHERE s.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM series_games sg + WHERE sg.series_id = s.id AND sg.winner_id IS NULL + ) + AND EXISTS (SELECT 1 FROM series_games sg WHERE sg.series_id = s.id) + `) + if err != nil { + return fmt.Errorf("query all-played series: %w", err) + } + defer allPlayed.Close() + + for allPlayed.Next() { + var apID int64 + var apBotA, apBotB string + var apAWins, apBWins, apFormat int + if err := allPlayed.Scan(&apID, &apBotA, &apBotB, &apAWins, &apBWins, &apFormat); err != nil { + continue + } + winsNeeded := (apFormat + 1) / 2 + if apAWins >= winsNeeded || apBWins >= winsNeeded { + continue // already handled above + } + var winnerID *string + if apAWins > apBWins { + winnerID = &apBotA + } else if apBWins > apAWins { + winnerID = &apBotB + } + _, err := m.db.ExecContext(ctx, ` + UPDATE series SET status = 'completed', winner_id = $1, updated_at = NOW() + WHERE id = $2 AND status = 'active' + `, winnerID, apID) + if err != nil { + log.Printf("series-scheduler: failed to finalize all-played series %d: %v", apID, err) + continue + } + log.Printf("series-scheduler: finalized all-played series %d (%d-%d), winner=%v", apID, apAWins, apBWins, winnerID) + } + return nil } diff --git a/cmd/acb-matchmaker/series_season_test.go b/cmd/acb-matchmaker/series_season_test.go index 5f27528..88c2b55 100644 --- a/cmd/acb-matchmaker/series_season_test.go +++ b/cmd/acb-matchmaker/series_season_test.go @@ -528,6 +528,83 @@ func TestChampionshipBracketRequiresEightBots(t *testing.T) { } } +func TestDrawGameProcessing(t *testing.T) { + // Drawn games should be handled: winner_id set to 'draw' so the series + // can continue scheduling the next game. + // + // Conditions for identifying a drawn series game: + // - series_games.winner_id IS NULL (not yet processed) + // - matches.status = 'completed' + // - matches.winner IS NULL (draw) + tests := []struct { + name string + winnerID string // empty = NULL + matchStat string + matchWin string // empty = NULL + shouldMark bool + }{ + {"completed draw marks game", "", "completed", "", true}, + {"winner game not marked by draw handler", "", "completed", "0", false}, + {"pending game not marked", "", "pending", "", false}, + {"already processed not marked", "draw", "completed", "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + winnerIDNull := tc.winnerID == "" + matchCompleted := tc.matchStat == "completed" + matchHasNoWinner := tc.matchWin == "" + + shouldMark := winnerIDNull && matchCompleted && matchHasNoWinner + if shouldMark != tc.shouldMark { + t.Errorf("winnerID=%q matchStat=%q matchWin=%q: shouldMark=%v, want %v", + tc.winnerID, tc.matchStat, tc.matchWin, shouldMark, tc.shouldMark) + } + }) + } +} + +func TestAllPlayedFinalization(t *testing.T) { + // When all games in a series are played but neither side reached the + // winning threshold (possible with draws), the series should be finalized + // with the bot that has more wins, or NULL if equal. + tests := []struct { + name string + format int + aWins int + bWins int + winner string // "a", "b", or "" for draw + }{ + {"bo3 with 1-1 and 1 draw", 3, 1, 1, ""}, + {"bo5 with 2-1 and 2 draws", 5, 2, 1, "a"}, + {"bo7 with 2-3 and 2 draws", 7, 2, 3, "b"}, + {"bo3 with 1-0 and 2 draws", 3, 1, 0, "a"}, + {"bo5 with 0-0 and 5 draws", 5, 0, 0, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + winsNeeded := (tc.format + 1) / 2 + // Neither side reached threshold + if tc.aWins >= winsNeeded || tc.bWins >= winsNeeded { + t.Fatalf("test case has side reaching threshold (a=%d, b=%d, needed=%d)", + tc.aWins, tc.bWins, winsNeeded) + } + + var winner string + if tc.aWins > tc.bWins { + winner = "a" + } else if tc.bWins > tc.aWins { + winner = "b" + } + if winner != tc.winner { + t.Errorf("all-played finalization a=%d b=%d: winner=%q, want %q", + tc.aWins, tc.bWins, winner, tc.winner) + } + }) + } +} + // itoa is a simple int-to-string helper for tests. func itoa(n int) string { if n == 0 { diff --git a/cmd/acb-wasm/build.sh b/cmd/acb-wasm/build.sh index d47c8db..9514f6d 100755 --- a/cmd/acb-wasm/build.sh +++ b/cmd/acb-wasm/build.sh @@ -33,7 +33,7 @@ GOOS=js GOARCH=wasm go build \ ./cmd/acb-wasm/ echo "Copying wasm_exec.js…" -cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" "$OUT/" +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$OUT/" echo "Building bot WASM modules…" for bot in random gatherer rusher guardian swarm hunter; do diff --git a/web/app.html b/web/app.html index 5442b70..e1614aa 100644 --- a/web/app.html +++ b/web/app.html @@ -918,6 +918,8 @@
+ +