diff --git a/cmd/acb-index-builder/deploy.go b/cmd/acb-index-builder/deploy.go index fa0178d..e473526 100644 --- a/cmd/acb-index-builder/deploy.go +++ b/cmd/acb-index-builder/deploy.go @@ -14,17 +14,19 @@ import ( "time" ) -// fetchExemptMatchIDs retrieves match IDs that should never be pruned (from series, seasons, playlists) +// fetchExemptMatchIDs retrieves match IDs that should never be pruned (from +// series, seasons, and playlists). func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map[string]bool, error) { exempt := make(map[string]bool) if db != nil { - // Matches in active series + // Matches in active/pending series (series_games, not series_matches) seriesQuery := ` - SELECT DISTINCT sm.match_id - FROM series_matches sm - JOIN series s ON sm.series_id = s.id + SELECT DISTINCT sg.match_id + FROM series_games sg + JOIN series s ON sg.series_id = s.id WHERE s.status IN ('active', 'pending') + AND sg.match_id IS NOT NULL ` rows, err := db.QueryContext(ctx, seriesQuery) if err == nil { @@ -37,13 +39,15 @@ func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map rows.Close() } - // Matches in active seasons + // Matches in active seasons (via series → series_games) seasonQuery := ` - SELECT DISTINCT match_id - FROM season_matches - WHERE season_id IN ( + SELECT DISTINCT sg.match_id + FROM series_games sg + JOIN series s ON sg.series_id = s.id + WHERE s.season_id IN ( SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW() ) + AND sg.match_id IS NOT NULL ` rows, err = db.QueryContext(ctx, seasonQuery) if err == nil { @@ -55,9 +59,22 @@ func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map } rows.Close() } + + // Matches in persisted playlists (playlist_matches table) + playlistQuery := `SELECT DISTINCT match_id FROM playlist_matches` + 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) + // Also read from generated playlist files (covers cases where DB persist failed) playlistMatchIDs := fetchPlaylistMatchIDsFromFiles(outputDir) for id := range playlistMatchIDs { exempt[id] = true diff --git a/cmd/acb-index-builder/enrichment_test.go b/cmd/acb-index-builder/enrichment_test.go new file mode 100644 index 0000000..4b7780b --- /dev/null +++ b/cmd/acb-index-builder/enrichment_test.go @@ -0,0 +1,603 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// ── shouldEnrich tests ───────────────────────────────────────────────────── + +func TestShouldEnrich_BackAndForth(t *testing.T) { + m := MatchData{ + ID: "m_backforth", + WinnerID: "bot_a", + TurnCount: 250, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1500}, + {BotID: "bot_b", Score: 4, Won: false, PreMatchRating: 1500}, + }, + } + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Rating: 1500}, + {ID: "bot_b", Rating: 1500}, + }, + } + + criteria, ok := shouldEnrich(m, data) + if !ok { + t.Fatal("expected match to be selected for enrichment") + } + found := false + for _, c := range criteria { + if c == "back_and_forth" { + found = true + } + } + if !found { + t.Errorf("expected back_and_forth criterion, got %v", criteria) + } +} + +func TestShouldEnrich_Upset(t *testing.T) { + m := MatchData{ + ID: "m_upset", + WinnerID: "bot_weak", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot_weak", Score: 5, Won: true, PreMatchRating: 1200}, + {BotID: "bot_strong", Score: 2, Won: false, PreMatchRating: 1600}, + }, + } + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_weak", Rating: 1200}, + {ID: "bot_strong", Rating: 1600}, + }, + } + + criteria, ok := shouldEnrich(m, data) + if !ok { + t.Fatal("expected upset match to be selected for enrichment") + } + + upsetFound := false + for _, c := range criteria { + if c == "upset_400" { + upsetFound = true + } + } + if !upsetFound { + t.Errorf("expected upset_400 criterion, got %v", criteria) + } +} + +func TestShouldEnrich_EvolutionMilestone(t *testing.T) { + // getBotRank returns index+1 in the Bots slice, so evo_bot at index 2 = rank 3 + m := MatchData{ + ID: "m_evomilestone", + WinnerID: "evo_bot", + TurnCount: 200, + Participants: []ParticipantData{ + {BotID: "evo_bot", Score: 5, Won: true, PreMatchRating: 1700}, + {BotID: "bot_other", Score: 3, Won: false, PreMatchRating: 1650}, + }, + } + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_top1", Rating: 1800}, + {ID: "bot_top2", Rating: 1750}, + {ID: "evo_bot", Rating: 1700, Evolved: true}, + {ID: "bot_other", Rating: 1650}, + }, + } + + criteria, ok := shouldEnrich(m, data) + if !ok { + t.Fatal("expected evolution milestone match to be selected") + } + + found := false + for _, c := range criteria { + if c == "evolution_milestone" { + found = true + } + } + if !found { + t.Errorf("expected evolution_milestone criterion, got %v", criteria) + } +} + +func TestShouldEnrich_HighInterest(t *testing.T) { + m := MatchData{ + ID: "m_interesting", + WinnerID: "bot_a", + TurnCount: 450, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1800}, + {BotID: "bot_b", Score: 4, Won: false, PreMatchRating: 1700}, + }, + } + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Rating: 1800}, + {ID: "bot_b", Rating: 1700}, + }, + } + + _, ok := shouldEnrich(m, data) + if !ok { + t.Fatal("expected high interest match to be selected") + } +} + +func TestShouldEnrich_BoringMatchNotSelected(t *testing.T) { + m := MatchData{ + ID: "m_trulyboring", + WinnerID: "bot_a", + TurnCount: 50, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 10, Won: true, PreMatchRating: 1500}, + {BotID: "bot_b", Score: 8, Won: false, PreMatchRating: 1510}, + }, + } + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Rating: 1500}, + {ID: "bot_b", Rating: 1510}, + }, + } + + _, ok := shouldEnrich(m, data) + if ok { + t.Error("expected boring match to not be selected for enrichment") + } +} + +func TestShouldEnrich_NoWinner(t *testing.T) { + m := MatchData{ + ID: "m_nowinner", + WinnerID: "", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 5}, + {BotID: "bot_b", Score: 5}, + }, + } + data := &IndexData{} + + _, ok := shouldEnrich(m, data) + if ok { + t.Error("expected match with no winner to not be selected") + } +} + +func TestShouldEnrich_TooFewParticipants(t *testing.T) { + m := MatchData{ + ID: "m_onebot", + WinnerID: "bot_a", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 5, Won: true}, + }, + } + data := &IndexData{} + + _, ok := shouldEnrich(m, data) + if ok { + t.Error("expected match with < 2 participants to not be selected") + } +} + +// ── parseCommentaryResponse tests ────────────────────────────────────────── + +func TestParseCommentaryResponse(t *testing.T) { + input := "1|setup|The bots face off on a 60x60 grid\n" + + "42|action|First contact near the central energy cluster\n" + + "87|climax|SwarmBot breaks through the eastern corridor\n" + + "200|reaction|GathererBot attempts to regroup\n" + + "499|denouement|The match ends with a decisive victory" + + entries := parseCommentaryResponse(input) + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } + + if entries[0].Turn != 1 { + t.Errorf("entry 0 turn: got %d, want 1", entries[0].Turn) + } + if entries[0].Type != "setup" { + t.Errorf("entry 0 type: got %q, want setup", entries[0].Type) + } + if entries[0].Text != "The bots face off on a 60x60 grid" { + t.Errorf("entry 0 text: got %q", entries[0].Text) + } + + if entries[2].Type != "climax" { + t.Errorf("entry 2 type: got %q, want climax", entries[2].Type) + } + if entries[4].Turn != 499 { + t.Errorf("entry 4 turn: got %d, want 499", entries[4].Turn) + } + if entries[4].Type != "denouement" { + t.Errorf("entry 4 type: got %q, want denouement", entries[4].Type) + } +} + +func TestParseCommentaryResponse_SkipsInvalid(t *testing.T) { + input := "# This is a comment\n" + + "1|setup|Valid entry\n" + + "\n" + + "INVALID_LINE\n" + + "42|action|Another valid entry\n" + + "bad_turn|action|Bad turn number\n" + + "100|invalid_type|Defaults to action" + + entries := parseCommentaryResponse(input) + if len(entries) != 3 { + t.Fatalf("expected 3 entries (skipping comment, blank, invalid line, bad turn), got %d", len(entries)) + } + + if entries[0].Turn != 1 { + t.Errorf("entry 0 turn: got %d, want 1", entries[0].Turn) + } + if entries[1].Turn != 42 { + t.Errorf("entry 1 turn: got %d, want 42", entries[1].Turn) + } + if entries[2].Turn != 100 { + t.Errorf("entry 2 turn: got %d, want 100", entries[2].Turn) + } + if entries[2].Type != "action" { + t.Errorf("entry 2 type: got %q, want action (default for invalid type)", entries[2].Type) + } +} + +func TestParseCommentaryResponse_Empty(t *testing.T) { + entries := parseCommentaryResponse("") + if len(entries) != 0 { + t.Errorf("expected 0 entries for empty input, got %d", len(entries)) + } +} + +func TestParseCommentaryResponse_SlashSlashComments(t *testing.T) { + input := "// Another kind of comment\n10|action|Real entry" + + entries := parseCommentaryResponse(input) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Turn != 10 { + t.Errorf("entry 0 turn: got %d, want 10", entries[0].Turn) + } +} + +// ── countWinProbCrossings tests ──────────────────────────────────────────── + +func TestCountWinProbCrossings_NoCrossings(t *testing.T) { + wp := [][]float64{ + {0.8, 0.2}, + {0.85, 0.15}, + {0.9, 0.1}, + {0.88, 0.12}, + } + if n := countWinProbCrossings(wp); n != 0 { + t.Errorf("expected 0 crossings, got %d", n) + } +} + +func TestCountWinProbCrossings_ThreeCrossings(t *testing.T) { + wp := [][]float64{ + {0.7, 0.3}, + {0.4, 0.6}, // crossing 1 + {0.3, 0.7}, + {0.6, 0.4}, // crossing 2 + {0.8, 0.2}, + {0.4, 0.6}, // crossing 3 + {0.3, 0.7}, + } + if n := countWinProbCrossings(wp); n != 3 { + t.Errorf("expected 3 crossings, got %d", n) + } +} + +func TestCountWinProbCrossings_Empty(t *testing.T) { + if n := countWinProbCrossings(nil); n != 0 { + t.Errorf("expected 0 for nil, got %d", n) + } + if n := countWinProbCrossings([][]float64{}); n != 0 { + t.Errorf("expected 0 for empty, got %d", n) + } + if n := countWinProbCrossings([][]float64{{0.5, 0.5}}); n != 0 { + t.Errorf("expected 0 for single entry, got %d", n) + } +} + +func TestCountWinProbCrossings_Exactly50(t *testing.T) { + wp := [][]float64{ + {0.4, 0.6}, + {0.5, 0.5}, // crosses up to >= 0.5 + } + if n := countWinProbCrossings(wp); n != 1 { + t.Errorf("expected 1 crossing at 0.5 boundary, got %d", n) + } +} + +// ── buildCommentaryPrompt tests ──────────────────────────────────────────── + +func TestBuildCommentaryPrompt(t *testing.T) { + m := MatchData{ + ID: "m_test", + WinnerID: "bot_a", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1800}, + {BotID: "bot_b", Score: 3, Won: false, PreMatchRating: 1600}, + }, + } + + replay := makeTestReplayStruct("SwarmBot", "HunterBot", 0, "turn_limit", 300, []int{5, 3}) + + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Name: "SwarmBot", Rating: 1800}, + {ID: "bot_b", Name: "HunterBot", Rating: 1600}, + }, + } + + prompt := buildCommentaryPrompt(m, replay, []string{"upset_200", "back_and_forth"}, data) + + checks := []string{ + "AI Code Battle commentator", + "TURN|TYPE|TEXT", + "SwarmBot vs HunterBot", + "turn_limit", + "300 turns", + "upset_200", + "back_and_forth", + "SwarmBot", + "HunterBot", + } + for _, check := range checks { + if !strings.Contains(prompt, check) { + t.Errorf("prompt missing expected substring %q", check) + } + } +} + +func TestBuildCommentaryPrompt_WithCriticalMoments(t *testing.T) { + m := MatchData{ + ID: "m_cm", + WinnerID: "bot_a", + TurnCount: 200, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 4, Won: true, PreMatchRating: 1500}, + {BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500}, + }, + } + + replay := makeTestReplayStruct("BotA", "BotB", 0, "elimination", 200, []int{4, 2}) + + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Name: "BotA", Rating: 1500}, + {ID: "bot_b", Name: "BotB", Rating: 1500}, + }, + } + + prompt := buildCommentaryPrompt(m, replay, []string{"high_interest"}, data) + + if !strings.Contains(prompt, "Critical moments:") { + t.Error("prompt missing critical moments header") + } + if !strings.Contains(prompt, "Turn 87") { + t.Error("prompt missing critical moment turn 87") + } + if !strings.Contains(prompt, "6 bots killed") { + t.Error("prompt missing critical moment description") + } + if !strings.Contains(prompt, "Turn 150") { + t.Error("prompt missing critical moment turn 150") + } +} + +func TestBuildCommentaryPrompt_WithWinProb(t *testing.T) { + m := MatchData{ + ID: "m_wp", + WinnerID: "bot_a", + TurnCount: 100, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 3, Won: true, PreMatchRating: 1500}, + {BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500}, + }, + } + + replay := makeTestReplayStruct("BotA", "BotB", 0, "turn_limit", 100, []int{3, 2}) + replay.WinProb = [][]float64{ + {0.5, 0.5}, + {0.3, 0.7}, + {0.6, 0.4}, + {0.2, 0.8}, + {0.8, 0.2}, + } + + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_a", Name: "BotA", Rating: 1500}, + {ID: "bot_b", Name: "BotB", Rating: 1500}, + }, + } + + prompt := buildCommentaryPrompt(m, replay, []string{"back_and_forth"}, data) + + if !strings.Contains(prompt, "crossed 0.5:") { + t.Error("prompt missing win prob crossings info") + } + if !strings.Contains(prompt, "Biggest swing:") { + t.Error("prompt missing biggest swing info") + } +} + +// ── EnrichedCommentary JSON round-trip ───────────────────────────────────── + +func TestEnrichedCommentaryJSON(t *testing.T) { + comm := &EnrichedCommentary{ + MatchID: "m_test", + Generated: "2026-04-21T12:00:00Z", + Criteria: []string{"upset_300", "back_and_forth"}, + Entries: []CommentaryEntry{ + {Turn: 1, Text: "Opening moves", Type: "setup"}, + {Turn: 87, Text: "Major engagement", Type: "climax"}, + {Turn: 499, Text: "Match concludes", Type: "denouement"}, + }, + } + + data, err := json.Marshal(comm) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var parsed EnrichedCommentary + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if parsed.MatchID != "m_test" { + t.Errorf("match_id: got %q, want m_test", parsed.MatchID) + } + if len(parsed.Entries) != 3 { + t.Fatalf("entries: got %d, want 3", len(parsed.Entries)) + } + if parsed.Entries[1].Turn != 87 { + t.Errorf("entry 1 turn: got %d, want 87", parsed.Entries[1].Turn) + } + if parsed.Entries[1].Type != "climax" { + t.Errorf("entry 1 type: got %q, want climax", parsed.Entries[1].Type) + } + if len(parsed.Criteria) != 2 { + t.Errorf("criteria: got %d, want 2", len(parsed.Criteria)) + } +} + +// ── enrichReplays integration test (LLM nil) ─────────────────────────────── + +func TestEnrichReplays_NilLLM(t *testing.T) { + ctx := context.Background() + data := &IndexData{ + Matches: []MatchData{ + { + ID: "m_test", + WinnerID: "bot_a", + TurnCount: 300, + Participants: []ParticipantData{ + {BotID: "bot_a", Score: 3, Won: true, PreMatchRating: 1500}, + {BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500}, + }, + }, + }, + } + + err := enrichReplays(ctx, data, &Config{}, nil) + if err != nil { + t.Errorf("enrichReplays with nil LLM should not error, got: %v", err) + } +} + +// ── isEvolved helper test ────────────────────────────────────────────────── + +func TestIsEvolved(t *testing.T) { + data := &IndexData{ + Bots: []BotData{ + {ID: "bot_human", Evolved: false}, + {ID: "bot_evo", Evolved: true}, + }, + } + + if isEvolved("bot_human", data) { + t.Error("human bot should not be marked as evolved") + } + if !isEvolved("bot_evo", data) { + t.Error("evolved bot should be marked as evolved") + } + if isEvolved("bot_missing", data) { + t.Error("missing bot should not be marked as evolved") + } +} + +// ── makeTestReplayStruct creates the anonymous struct used by buildCommentaryPrompt ─ + +func makeTestReplayStruct(p0Name, p1Name string, winner int, reason string, turns int, scores []int) 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"` +} { + var r 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"` + } + + r.Players = []struct { + ID int `json:"id"` + Name string `json:"name"` + }{{ID: 0, Name: p0Name}, {ID: 1, Name: p1Name}} + r.Result.Winner = winner + r.Result.Reason = reason + r.Result.Turns = turns + r.Result.Scores = scores + r.CriticalMoments = []struct { + Turn int `json:"turn"` + Delta float64 `json:"delta"` + Description string `json:"description"` + }{ + {Turn: 87, Delta: 0.22, Description: "6 bots killed in eastern engagement"}, + {Turn: 150, Delta: -0.31, Description: "Core captured by " + p0Name}, + } + + return r +} diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index a5b9afe..cabce0f 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -382,6 +382,10 @@ func generatePredictionsIndex(data *IndexData, outputDir string) error { func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error { playlistsDir := filepath.Join(outputDir, "data", "playlists") + // Pre-build lookup maps for O(1) playlist curation instead of O(n^2) per match. + firstMatchPerBot := buildFirstMatchPerBot(data.Matches) + pairFrequency := buildPairFrequency(data.Matches) + type playlistDef struct { slug string title string @@ -481,7 +485,7 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string] description: "The most closely contested matchups between frequent opponents", category: "rivalry", filter: func(m MatchData) bool { - return isRivalryMatch(m, data) + return isRivalryMatchFast(m, pairFrequency) }, sort: func(matches []MatchData) { sortSlice(matches, func(i, j int) bool { @@ -512,7 +516,7 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string] description: "First matches of newly registered bots — watch their opening games", category: "tutorial", filter: func(m MatchData) bool { - return isNewBotDebut(m, data) + return isNewBotDebutFast(m, firstMatchPerBot) }, sort: func(matches []MatchData) { // Newest debuts first @@ -1114,6 +1118,69 @@ func isEvolutionBreakthrough(m MatchData, data *IndexData) bool { return false } +// buildFirstMatchPerBot returns a map from botID to the matchID of their earliest +// completed match. O(n*p) where n=matches, p=avg participants. +func buildFirstMatchPerBot(matches []MatchData) map[string]string { + first := make(map[string]string) + firstTime := make(map[string]time.Time) + for _, m := range matches { + if m.CompletedAt.IsZero() || m.WinnerID == "" { + continue + } + for _, p := range m.Participants { + if t, ok := firstTime[p.BotID]; !ok || m.CompletedAt.Before(t) { + firstTime[p.BotID] = m.CompletedAt + first[p.BotID] = m.ID + } + } + } + return first +} + +// isNewBotDebutFast checks if any participant's earliest completed match is this one, +// using a pre-built lookup map. +func isNewBotDebutFast(m MatchData, firstMatchPerBot map[string]string) bool { + if m.WinnerID == "" { + return false + } + for _, p := range m.Participants { + if firstMatchPerBot[p.BotID] == m.ID { + return true + } + } + return false +} + +// buildPairFrequency returns a map from "botA:botB" (sorted) to the count of +// 2-player matches between them. O(n) where n=matches. +func buildPairFrequency(matches []MatchData) map[string]int { + freq := make(map[string]int) + for _, m := range matches { + if len(m.Participants) != 2 { + continue + } + a, b := m.Participants[0].BotID, m.Participants[1].BotID + if a > b { + a, b = b, a + } + freq[a+":"+b]++ + } + return freq +} + +// isRivalryMatchFast checks if a 2-player match is between frequent opponents, +// using a pre-built pair frequency map. +func isRivalryMatchFast(m MatchData, pairFrequency map[string]int) bool { + if len(m.Participants) != 2 || m.WinnerID == "" { + return false + } + a, b := m.Participants[0].BotID, m.Participants[1].BotID + if a > b { + a, b = b, a + } + return pairFrequency[a+":"+b] >= 3 +} + // isRivalryMatch detects matches between bots that have played each other frequently. // Builds a frequency map from all matches and checks if this pair qualifies. func isRivalryMatch(m MatchData, data *IndexData) bool { diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 8cb73c1..7406c1f 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -6,6 +6,7 @@ import { fetchEvolutionMeta, fetchSeasonIndex, fetchMatchIndex, + fetchEnrichedIndex, type Season, type MatchSummary } from '../api-types'; @@ -51,15 +52,29 @@ async function fetchWithCache( } } -// Find featured replay (highest-viewed recent match) -function findFeaturedReplay(matches: MatchSummary[]): MatchSummary | null { - const completed = matches.filter(m => m.completed_at && m.participants.length === 2); - if (completed.length === 0) return null; - // For now, just return the most recent completed match - // TODO: Add view_count to match index and sort by that - return completed.sort((a, b) => +// Find featured replay — prefer enriched/AI-commentary matches, then most recent +async function findFeaturedReplay(matches: MatchSummary[]): Promise<{ match: MatchSummary | null; enriched: boolean }> { + const completed = matches.filter(m => m.completed_at && m.participants.length >= 2); + if (completed.length === 0) return { match: null, enriched: false }; + + // Sort by most recent first + const sorted = [...completed].sort((a, b) => new Date(b.completed_at!).getTime() - new Date(a.completed_at!).getTime() - )[0] || null; + ); + + // Try to find an enriched match among recent replays + try { + const enrichedIndex = await fetchEnrichedIndex(); + const enrichedIDs = new Set(enrichedIndex.entries.map(e => e.match_id)); + const enrichedMatch = sorted.find(m => enrichedIDs.has(m.id)); + if (enrichedMatch) { + return { match: enrichedMatch, enriched: true }; + } + } catch { + // enriched index not available — fall through + } + + return { match: sorted[0], enriched: false }; } // Format time remaining @@ -115,7 +130,7 @@ export async function renderHomePage(): Promise { const top5 = leaderboardData.entries.slice(0, 5); const latestStories = blogData.posts.slice(0, 3); const featuredPlaylists = playlistsData.playlists.slice(0, 6); - const featuredReplay = findFeaturedReplay(matchesData.matches); + const { match: featuredReplay } = await findFeaturedReplay(matchesData.matches); const activeSeason = seasonData.active_season; const seasonProgress = getSeasonProgress(activeSeason);