From 70b73378678332a405a57342a001fdcec925adc7 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 15:45:33 -0400 Subject: [PATCH] feat(blog): add rivalry context to spotlight prompt and fix narrative tests The buildSpotlightPrompt function accepted a rivalries parameter but never used it. This adds top rivalry data to the LLM prompt so the generated Counter-Strategy Spotlight can reference active rivalries. Test updated to verify rivalry data appears in prompt output. Co-Authored-By: Claude Opus 4.7 --- cmd/acb-index-builder/blog.go | 2031 +++++++++++++++++++++-- cmd/acb-index-builder/narrative_test.go | 331 +++- 2 files changed, 2175 insertions(+), 187 deletions(-) diff --git a/cmd/acb-index-builder/blog.go b/cmd/acb-index-builder/blog.go index e6f0f43..2aa9608 100644 --- a/cmd/acb-index-builder/blog.go +++ b/cmd/acb-index-builder/blog.go @@ -2,21 +2,27 @@ package main import ( "context" + "encoding/json" "fmt" + "log/slog" "os" "path/filepath" + "sort" + "strings" "time" ) // BlogPost represents a single blog post type BlogPost struct { - Slug string `json:"slug"` - Title string `json:"title"` - Date string `json:"date"` - Type string `json:"type"` // "meta-report" or "chronicle" - ContentMd string `json:"content_md"` - Summary string `json:"summary"` - Tags []string `json:"tags"` + Slug string `json:"slug"` + Title string `json:"title"` + PublishedAt string `json:"published_at"` + Date string `json:"date"` // backward compat alias + Type string `json:"type"` // "meta-report" or "chronicle" + BodyMarkdown string `json:"body_markdown"` + ContentMd string `json:"content_md"` // backward compat alias + Summary string `json:"summary"` + Tags []string `json:"tags"` } // BlogIndex represents the blog/index.json structure @@ -27,16 +33,18 @@ type BlogIndex struct { // BlogEntry is a lightweight entry for the blog index type BlogEntry struct { - Slug string `json:"slug"` - Title string `json:"title"` - Date string `json:"date"` - Type string `json:"type"` - Summary string `json:"summary"` - Tags []string `json:"tags"` + Slug string `json:"slug"` + Title string `json:"title"` + PublishedAt string `json:"published_at"` + Date string `json:"date"` // backward compat + Type string `json:"type"` + Summary string `json:"summary"` + Tags []string `json:"tags"` } -// generateBlog creates blog posts and the blog index -func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient) error { +// generateBlog creates blog posts and the blog index. +// Meta reports are only generated on Monday or if 7+ days have passed since the last one. +func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient, cfg *Config) error { blogDir := filepath.Join(outputDir, "data", "blog") postsDir := filepath.Join(blogDir, "posts") @@ -46,10 +54,16 @@ func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient) error posts := make([]BlogPost, 0) - // Generate weekly meta report (only on Mondays or for testing) - if time.Now().Weekday() == time.Monday || len(data.Matches) > 0 { - metaReport := generateMetaReport(data) + // Generate weekly meta report only when gate passes + if shouldGenerateMetaReport(postsDir) { + var metaReport BlogPost + if llmClient != nil && llmClient.baseURL != "" { + metaReport = generateMetaReportWithLLM(context.Background(), data, llmClient, cfg) + } else { + metaReport = generateMetaReport(data) + } posts = append(posts, metaReport) + recordMetaReportGenerated(postsDir) } // Generate story arc chronicles using narrative engine @@ -64,12 +78,13 @@ func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient) error return fmt.Errorf("write post %s: %w", post.Slug, err) } entries = append(entries, BlogEntry{ - Slug: post.Slug, - Title: post.Title, - Date: post.Date, - Type: post.Type, - Summary: post.Summary, - Tags: post.Tags, + Slug: post.Slug, + Title: post.Title, + PublishedAt: post.PublishedAt, + Date: post.Date, + Type: post.Type, + Summary: post.Summary, + Tags: post.Tags, }) } @@ -82,18 +97,429 @@ func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient) error return writeJSON(filepath.Join(blogDir, "index.json"), index) } -// generateMetaReport creates the weekly meta analysis blog post +// shouldGenerateMetaReport returns true on Monday or if 7+ days since the last report. +// It checks a state file (.last-meta-report) in postsDir for the last generation timestamp, +// falling back to scanning existing meta report files for backward compatibility. +func shouldGenerateMetaReport(postsDir string) bool { + now := time.Now().UTC() + + // Always generate on Monday + if now.Weekday() == time.Monday { + return true + } + + // Check state file for last generation timestamp + stateFile := filepath.Join(postsDir, ".last-meta-report") + if data, err := os.ReadFile(stateFile); err == nil { + if lastTime, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))); err == nil { + if now.Sub(lastTime) < 7*24*time.Hour { + return false + } + return true + } + } + + // Fallback: scan existing meta report files + entries, err := os.ReadDir(postsDir) + if err != nil { + // Directory doesn't exist or can't be read — generate + return true + } + + var lastMetaTime time.Time + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if len(name) >= 5 && name[:5] == "meta-" && !strings.HasPrefix(name, ".") { + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().After(lastMetaTime) { + lastMetaTime = info.ModTime() + } + } + } + + // If no previous meta report found, generate + if lastMetaTime.IsZero() { + return true + } + + // Generate if 7+ days since last report + return now.Sub(lastMetaTime) >= 7*24*time.Hour +} + +// recordMetaReportGenerated writes the generation timestamp to the state file. +func recordMetaReportGenerated(postsDir string) { + stateFile := filepath.Join(postsDir, ".last-meta-report") + _ = os.WriteFile(stateFile, []byte(time.Now().UTC().Format(time.RFC3339)), 0644) +} + +// ─── ELO mover tracking ────────────────────────────────────────────────────── + +type eloMover struct { + BotID string + BotName string + OldRating float64 + NewRating float64 + Delta float64 + Evolved bool + Archetype string + MatchesWon int + MatchesLost int +} + +func findTopELOMovers(data *IndexData, count int) []eloMover { + now := data.GeneratedAt + weekAgo := now.AddDate(0, 0, -7) + + // Calculate rating change for each bot over the past week + movers := make([]eloMover, 0) + for _, bot := range data.Bots { + history := getBotRatingHistory(bot.ID, data) + if len(history) < 2 { + continue + } + + // Find the oldest rating within or before the past week + var oldRating float64 + var foundOld bool + for _, rh := range history { + if rh.RecordedAt.Before(weekAgo) || rh.RecordedAt.Equal(weekAgo) { + oldRating = rh.Rating + foundOld = true + } + } + if !foundOld { + continue + } + + delta := bot.Rating - oldRating + if delta == 0 { + continue + } + + // Count wins/losses this week + wins, losses := countWeeklyResults(bot.ID, data) + + movers = append(movers, eloMover{ + BotID: bot.ID, + BotName: bot.Name, + OldRating: oldRating, + NewRating: bot.Rating, + Delta: delta, + Evolved: bot.Evolved, + Archetype: bot.Archetype, + MatchesWon: wins, + MatchesLost: losses, + }) + } + + // Sort by absolute delta descending + sort.Slice(movers, func(i, j int) bool { + return absF(movers[i].Delta) > absF(movers[j].Delta) + }) + + if len(movers) > count { + return movers[:count] + } + return movers +} + +func countWeeklyResults(botID string, data *IndexData) (wins, losses int) { + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) { + continue + } + for _, p := range m.Participants { + if p.BotID == botID { + if p.Won { + wins++ + } else { + losses++ + } + break + } + } + } + return +} + +// ─── Strategy analysis ──────────────────────────────────────────────────────── + +type strategyCount struct { + Archetype string + Count int + AvgRating float64 + InTop20 int +} + +func calculateDominantStrategies(data *IndexData) []strategyCount { + stratMap := make(map[string]*strategyCount) + + // Count bots by archetype + for i, bot := range data.Bots { + arch := bot.Archetype + if arch == "" { + if bot.Evolved { + arch = "evolved-unknown" + } else { + arch = "standard" + } + } + + sc, ok := stratMap[arch] + if !ok { + sc = &strategyCount{Archetype: arch} + stratMap[arch] = sc + } + sc.Count++ + sc.AvgRating += bot.Rating + if i < 20 { + sc.InTop20++ + } + } + + strats := make([]strategyCount, 0, len(stratMap)) + for _, sc := range stratMap { + if sc.Count > 0 { + sc.AvgRating /= float64(sc.Count) + } + strats = append(strats, *sc) + } + + // Sort by count descending + sort.Slice(strats, func(i, j int) bool { + return strats[i].Count > strats[j].Count + }) + + return strats +} + +// ─── Most-watched match ─────────────────────────────────────────────────────── + +type notableMatch struct { + MatchID string + Description string + Score string + TurnCount int + Participants []ParticipantData +} + +func findMostWatchedMatch(data *IndexData) *notableMatch { + // Use interest score to find the most notable match this week + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + + var best *notableMatch + var bestScore float64 + + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) { + continue + } + if len(m.Participants) < 2 { + continue + } + + score := computeMatchInterest(m, data) + if score > bestScore { + bestScore = score + desc := formatMatchDescription(m, data) + best = ¬ableMatch{ + MatchID: m.ID, + Description: desc, + Score: formatMatchScore(m), + TurnCount: m.TurnCount, + Participants: m.Participants, + } + } + } + + return best +} + +func computeMatchInterest(m MatchData, data *IndexData) float64 { + score := 0.0 + + // Close finishes are more interesting + if len(m.Participants) >= 2 { + var maxScore, minScore int + for i, p := range m.Participants { + if i == 0 || p.Score > maxScore { + maxScore = p.Score + } + if i == 0 || p.Score < minScore { + minScore = p.Score + } + } + diff := maxScore - minScore + if diff <= 1 { + score += 5.0 + } else if diff <= 3 { + score += 3.0 + } else if diff <= 5 { + score += 1.0 + } + } + + // Upsets (lower-rated bot wins) + if len(m.Participants) >= 2 { + for _, p := range m.Participants { + if p.Won { + for _, q := range m.Participants { + if !q.Won && p.PreMatchRating > 0 && q.PreMatchRating > 0 { + gap := q.PreMatchRating - p.PreMatchRating + if gap > 100 { + score += gap / 50.0 // bigger upsets = more interesting + } + } + } + } + } + } + + // Longer matches (more strategic depth) + if m.TurnCount > 300 { + score += 2.0 + } else if m.TurnCount > 200 { + score += 1.0 + } + + // Matches involving evolved bots are more interesting + for _, p := range m.Participants { + bot := findBotByID(p.BotID, data) + if bot != nil && bot.Evolved { + score += 1.5 + } + } + + return score +} + +func formatMatchDescription(m MatchData, data *IndexData) string { + names := make([]string, 0, len(m.Participants)) + for _, p := range m.Participants { + names = append(names, getBotName(p.BotID, data)) + } + + switch len(names) { + case 2: + return fmt.Sprintf("%s vs %s", names[0], names[1]) + case 3: + return fmt.Sprintf("%s, %s, %s", names[0], names[1], names[2]) + default: + return fmt.Sprintf("%s and %d others", names[0], len(names)-1) + } +} + +func formatMatchScore(m MatchData) string { + scores := make([]string, 0, len(m.Participants)) + for _, p := range m.Participants { + scores = append(scores, fmt.Sprintf("%d", p.Score)) + } + result := "" + for i, s := range scores { + if i > 0 { + result += "-" + } + result += s + } + return result +} + +// ─── Evolution highlights ────────────────────────────────────────────────────── + +type evolutionHighlight struct { + BotID string + BotName string + Rating float64 + Island string + Generation int + Archetype string + WeekMatches int + WeekWins int +} + +func findEvolutionHighlights(data *IndexData) []evolutionHighlight { + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + highlights := make([]evolutionHighlight, 0) + + for _, bot := range data.Bots { + if !bot.Evolved { + continue + } + + wins, losses := 0, 0 + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) { + continue + } + for _, p := range m.Participants { + if p.BotID == bot.ID { + if p.Won { + wins++ + } else { + losses++ + } + break + } + } + } + + total := wins + losses + if total == 0 { + continue + } + + highlights = append(highlights, evolutionHighlight{ + BotID: bot.ID, + BotName: bot.Name, + Rating: bot.Rating, + Island: bot.Island, + Generation: bot.Generation, + Archetype: bot.Archetype, + WeekMatches: total, + WeekWins: wins, + }) + } + + // Sort by rating descending + sort.Slice(highlights, func(i, j int) bool { + return highlights[i].Rating > highlights[j].Rating + }) + + if len(highlights) > 5 { + return highlights[:5] + } + return highlights +} + +// ─── Meta report generation (template) ──────────────────────────────────────── + +// generateMetaReport creates the weekly meta analysis blog post with enriched data. func generateMetaReport(data *IndexData) BlogPost { weekNum := getWeekNumber(data.GeneratedAt) seasonName := getCurrentSeasonName(data) + dateStr := data.GeneratedAt.Format("2006-01-02") - // Calculate meta statistics + // Gather all data sections topBots := getTopBots(data, 5) - strategyDistribution := calculateStrategyDistribution(data) + eloMovers := findTopELOMovers(data, 5) + strategies := calculateDominantStrategies(data) risingBots := findRisingBots(data) fallingBots := findFallingBots(data) recentUpsets := findRecentUpsets(data) topRivalries := findTopRivalries(data) + bestMatch := findMostWatchedMatch(data) + evoHighlights := findEvolutionHighlights(data) + stratTrends := calculateStrategyTrends(data) + matchups := calculateMatchupMatrix(data) + mapWeek := findMapOfTheWeek(data) + spotlight := buildBotSpotlight(data) // Build content content := fmt.Sprintf(`# Week %d Meta Report — %s @@ -108,7 +534,33 @@ This week's competitive landscape analysis covers %d active bots across %d compl |------|-----|--------|----------| %s -## Strategy Distribution +## Top 5 ELO Movers This Week + +| Bot | Rating Change | From → To | Record | +|-----|--------------|-----------|--------| +%s + +## Dominant Strategies + +%s + +## Strategy Trends + +%s + +## Matchup Insights + +%s + +## Most-Watched Match + +%s + +## Map of the Week + +%s + +## Bot Spotlight %s @@ -128,12 +580,21 @@ This week's competitive landscape analysis covers %d active bots across %d compl %s +## Evolution Highlights + +%s + +## Prediction Standings + +%s + +## Season Progress + +%s + ## Looking Ahead -The meta continues to evolve as bots adapt their strategies. Key trends to watch: -- Formation-based play continues to dominate -- Energy control remains crucial in early game -- Adaptation to map layouts shows clear skill differentials +%s --- @@ -142,31 +603,715 @@ The meta continues to evolve as bots adapt their strategies. Key trends to watch weekNum, seasonName, len(data.Bots), len(data.Matches), formatLeaderboardTable(topBots), - formatStrategyDistribution(strategyDistribution), + formatELOMoversTable(eloMovers), + formatStrategyTable(strategies), + formatStrategyTrends(stratTrends), + formatMatchupInsights(matchups), + formatNotableMatch(bestMatch), + formatMapOfTheWeek(mapWeek), + formatBotSpotlight(spotlight), formatBotList(risingBots), formatBotList(fallingBots), formatUpsets(recentUpsets), formatRivalries(topRivalries), + formatEvolutionHighlights(evoHighlights), + formatPredictionStandings(data), + formatSeasonProgress(data), + formatLookingAhead(eloMovers, strategies, evoHighlights, data), ) slug := fmt.Sprintf("meta-week-%d-%s", weekNum, formatSlugDate(data.GeneratedAt)) + summary := fmt.Sprintf("Week %d: %d active bots, %d matches. %s", + weekNum, len(data.Bots), len(data.Matches), + buildMetaReportSummary(eloMovers, strategies, bestMatch)) return BlogPost{ - Slug: slug, - Title: fmt.Sprintf("Week %d Meta Report — %s", weekNum, seasonName), - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "meta-report", - ContentMd: content, - Summary: fmt.Sprintf("Weekly competitive analysis: %d bots, top strategies, rising stars, and key rivalries.", len(data.Bots)), - Tags: []string{"meta-report", seasonTag(seasonName)}, + Slug: slug, + Title: fmt.Sprintf("Week %d Meta Report — %s", weekNum, seasonName), + PublishedAt: dateStr, + Date: dateStr, + Type: "meta-report", + BodyMarkdown: content, + ContentMd: content, + Summary: summary, + Tags: []string{"meta-report", seasonTag(seasonName)}, } } +// generateMetaReportWithLLM uses the LLM to produce a rich narrative meta report. +// The LLM generates the analytical sections (Counter-Strategy Spotlight, Evolution Deep Dive, Looking Ahead), +// which are spliced into the template-generated structured content. +func generateMetaReportWithLLM(ctx context.Context, data *IndexData, llmClient *LLMClient, cfg *Config) BlogPost { + // Start with the template-based report (tables, stats, links) + post := generateMetaReport(data) + + // Gather enriched context for the LLM + eloMovers := findTopELOMovers(data, 5) + strategies := calculateDominantStrategies(data) + bestMatch := findMostWatchedMatch(data) + evoHighlights := findEvolutionHighlights(data) + topBots := getTopBots(data, 5) + rivalries := findTopRivalries(data) + predLeaderboard := data.TopPredictors + matchups := calculateMatchupMatrix(data) + trends := calculateStrategyTrends(data) + liveData := fetchEvolutionLiveData(ctx, cfg) + + // Generate Counter-Strategy Spotlight + spotlightPrompt := buildSpotlightPrompt(data, eloMovers, strategies, bestMatch, evoHighlights, topBots, rivalries) + spotlight, err := llmClient.chatCompletion(ctx, spotlightPrompt) + if err != nil { + slog.Error("LLM spotlight generation failed", "error", err) + spotlight = "" + } + + // Generate Evolution Deep Dive + evoNarrative := "" + if len(evoHighlights) > 0 { + evoPrompt := buildEvolutionDeepDivePrompt(data, evoHighlights, rivalries, predLeaderboard, liveData) + evoNarrative, err = llmClient.chatCompletion(ctx, evoPrompt) + if err != nil { + slog.Error("LLM evolution narrative generation failed", "error", err) + evoNarrative = "" + } + } + + // Generate Looking Ahead via LLM (replaces template-based version) + lookingAheadNarrative := "" + lookingAheadPrompt := buildLookingAheadPrompt(data, eloMovers, strategies, trends, matchups, liveData) + lookingAheadNarrative, err = llmClient.chatCompletion(ctx, lookingAheadPrompt) + if err != nil { + slog.Error("LLM looking ahead generation failed", "error", err) + lookingAheadNarrative = "" + } + + // Splice LLM content into the template report + if spotlight != "" || evoNarrative != "" || lookingAheadNarrative != "" { + post.BodyMarkdown = spliceLLMContent(post.BodyMarkdown, spotlight, evoNarrative) + + // Replace template "Looking Ahead" with LLM version + if lookingAheadNarrative != "" { + post.BodyMarkdown = replaceLookingAhead(post.BodyMarkdown, lookingAheadNarrative) + } + + post.ContentMd = post.BodyMarkdown + + // Enhance summary with LLM-generated insight + if spotlight != "" { + firstSentence := extractFirstSentence(spotlight) + if firstSentence != "" { + post.Summary = buildMetaReportSummary(eloMovers, strategies, bestMatch) + " " + truncateSummary(firstSentence, 100) + } + } + } + + return post +} + +// spliceLLMContent inserts LLM-generated sections into the template report. +// Counter-Strategy Spotlight goes before "Evolution Highlights". +// Evolution Deep Dive goes after the evolution highlights table. +func spliceLLMContent(template string, spotlight, evoNarrative string) string { + result := template + + if spotlight != "" { + section := fmt.Sprintf("\n## Counter-Strategy Spotlight\n\n%s\n", spotlight) + idx := findSectionIndex(result, "## Evolution Highlights") + if idx >= 0 { + result = result[:idx] + section + result[idx:] + } else { + idx = findSectionIndex(result, "## Looking Ahead") + if idx >= 0 { + result = result[:idx] + section + result[idx:] + } else { + result += section + } + } + } + + if evoNarrative != "" { + section := fmt.Sprintf("\n### Evolution Deep Dive\n\n%s\n", evoNarrative) + idx := findSectionIndex(result, "## Looking Ahead") + if idx >= 0 { + result = result[:idx] + section + result[idx:] + } else { + result += section + } + } + + return result +} + +// replaceLookingAhead replaces the template "## Looking Ahead" section with LLM-generated content. +func replaceLookingAhead(content, llmContent string) string { + idx := findSectionIndex(content, "## Looking Ahead") + if idx < 0 { + // No existing section; append + return content + fmt.Sprintf("\n## Looking Ahead\n\n%s\n", llmContent) + } + + // Find the next ## section (or end of content) to delimit the replacement + endIdx := len(content) + for i := idx + len("## Looking Ahead"); i < len(content)-2; i++ { + if content[i] == '\n' && content[i+1] == '#' { + endIdx = i + break + } + } + + return content[:idx] + fmt.Sprintf("## Looking Ahead\n\n%s\n", llmContent) + content[endIdx:] +} + +// extractFirstSentence returns the first sentence from LLM output (for summary generation). +func extractFirstSentence(text string) string { + // Clean leading whitespace + text = strings.TrimSpace(text) + // Find first period, exclamation, or question mark followed by space or end + for i, ch := range text { + if (ch == '.' || ch == '!' || ch == '?') && (i+1 >= len(text) || text[i+1] == ' ') { + return text[:i+1] + } + } + // No sentence boundary found — return first 100 chars + if len(text) > 100 { + return truncateSummary(text, 100) + } + return text +} + +// buildSpotlightPrompt creates the LLM prompt for the Counter-Strategy Spotlight section. +func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyCount, bestMatch *notableMatch, evoHighlights []evolutionHighlight, topBots []BotData, rivalries []RivalryData) string { + var sb strings.Builder + + sb.WriteString("You are a competitive gaming analyst for AI Code Battle, a bot programming platform. ") + sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for a weekly meta report. ") + sb.WriteString("Analyze the current strategy landscape and identify under-represented archetypes that could exploit weaknesses. ") + sb.WriteString("Be analytical, specific, and reference real bot names and ratings. Do not use emojis. ") + sb.WriteString("Write in present tense with a journalistic tone.\n\n") + + sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data))) + sb.WriteString(fmt.Sprintf("Active bots: %d, Matches this week: %d\n\n", len(data.Bots), countWeeklyMatches(data))) + + sb.WriteString("Top 5 Leaderboard:\n") + for i, bot := range topBots { + if i >= 5 { + break + } + winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100 + sb.WriteString(fmt.Sprintf(" #%d %s (rating %d, %.0f%% win rate, archetype: %s)\n", + i+1, bot.Name, int(bot.Rating), winRate, nonEmpty(bot.Archetype, "unclassified"))) + } + + sb.WriteString("\nTop 5 ELO movers this week:\n") + for _, m := range movers { + dir := "rose" + if m.Delta < 0 { + dir = "fell" + } + sb.WriteString(fmt.Sprintf(" %s %s %.0f points (%.0f -> %.0f) [%s] — W%d/L%d\n", + m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, nonEmpty(m.Archetype, "unclassified"), m.MatchesWon, m.MatchesLost)) + } + + sb.WriteString("\nStrategy distribution:\n") + for _, s := range strats { + sb.WriteString(fmt.Sprintf(" %s: %d bots (avg rating %.0f, %d in top 20)\n", + s.Archetype, s.Count, s.AvgRating, s.InTop20)) + } + + // Matchup matrix: archetype-vs-archetype win/loss data + matchups := calculateMatchupMatrix(data) + if len(matchups) > 0 { + sb.WriteString("\nMatchup matrix (top advantages):\n") + for _, mc := range matchups { + total := mc.Wins + mc.Losses + winPct := 0.0 + if total > 0 { + winPct = float64(mc.Wins) / float64(total) * 100 + } + sb.WriteString(fmt.Sprintf(" %s vs %s: %dW/%dL (%.0f%%)\n", + mc.Attacker, mc.Defender, mc.Wins, mc.Losses, winPct)) + } + } + + // Strategy trends: week-over-week shifts + trends := calculateStrategyTrends(data) + if len(trends) > 0 { + sb.WriteString("\nStrategy trends (week-over-week):\n") + for _, t := range trends { + arrow := "→" + if t.Shift > 2 { + arrow = "↑" + } else if t.Shift < -2 { + arrow = "↓" + } + sb.WriteString(fmt.Sprintf(" %s: %.1f%% (was %.1f%%) %s %.1fpp, avg rating %.0f, %d in top 20\n", + t.Archetype, t.ThisWeekPct, t.LastWeekPct, arrow, t.Shift, t.AvgRating, t.Count)) + } + } + + if bestMatch != nil { + sb.WriteString(fmt.Sprintf("\nMost-watched match: %s — %s (score %s, %d turns)\n", + bestMatch.MatchID, bestMatch.Description, bestMatch.Score, bestMatch.TurnCount)) + } + + if len(rivalries) > 0 { + sb.WriteString("\nTop rivalries:\n") + for i, r := range rivalries { + if i >= 5 { + break + } + botAName := r.BotAID + botBName := r.BotBID + for _, b := range data.Bots { + if b.ID == r.BotAID { + botAName = b.Name + } + if b.ID == r.BotBID { + botBName = b.Name + } + } + sb.WriteString(fmt.Sprintf(" %s vs %s: %d-%d (%d matches)\n", + botAName, botBName, r.BotAWins, r.BotBWins, r.TotalMatches)) + } + } + + return sb.String() +} + +// buildEvolutionDeepDivePrompt creates the LLM prompt for the Evolution Deep Dive section. +func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHighlight, rivalries []RivalryData, predLeaderboard []PredictorStats, liveData *evolutionLiveData) string { + var sb strings.Builder + + sb.WriteString("Write a 150-word 'Evolution Deep Dive' section analyzing this week's evolved bot performance. ") + sb.WriteString("Highlight the most successful evolved bots, their lineage, and strategic innovations. ") + sb.WriteString("Be analytical and reference specific bot names. Do not use emojis.\n\n") + + sb.WriteString(fmt.Sprintf("Season: %s\n\n", getCurrentSeasonName(data))) + + sb.WriteString("Evolution highlights:\n") + for _, e := range evoHighlights { + winRate := 0.0 + if e.WeekMatches > 0 { + winRate = float64(e.WeekWins) / float64(e.WeekMatches) * 100 + } + sb.WriteString(fmt.Sprintf(" %s: rating %.0f, island=%s, gen=%d, weekly W%d/L%d (%.0f%% win rate), archetype=%s\n", + e.BotName, e.Rating, e.Island, e.Generation, e.WeekWins, e.WeekMatches-e.WeekWins, winRate, nonEmpty(e.Archetype, "evolved"))) + } + + // Count evolved bots in top 10 and top 20 + evolvedTop10, evolvedTop20 := 0, 0 + for i, bot := range data.Bots { + if bot.Evolved { + if i < 10 { + evolvedTop10++ + } + if i < 20 { + evolvedTop20++ + } + } + } + sb.WriteString(fmt.Sprintf("\nEvolved bots in top 10: %d, top 20: %d\n", evolvedTop10, evolvedTop20)) + + // Live evolution data from R2 (population stats, promotion rates, island activity) + if liveData != nil { + sb.WriteString(fmt.Sprintf("\nEvolution pipeline stats: %d total generations, %d promoted today, %.1f%% 7-day promotion rate\n", + liveData.Totals.GenerationsTotal, liveData.Totals.PromotedToday, liveData.Totals.PromotionRate7d)) + sb.WriteString(fmt.Sprintf("Highest evolved rating: %.0f, evolved in top 10: %d\n", + liveData.Totals.HighestEvolved, liveData.Totals.EvolvedInTop10)) + + if len(liveData.Islands) > 0 { + sb.WriteString("Island populations:\n") + for name, island := range liveData.Islands { + sb.WriteString(fmt.Sprintf(" %s: pop=%d, best=%.0f (%s)\n", name, island.Population, island.BestRating, island.BestBot)) + } + } + + if len(liveData.RecentActivity) > 0 { + sb.WriteString("Recent evolution activity (last 5):\n") + count := 0 + for _, act := range liveData.RecentActivity { + if count >= 5 { + break + } + sb.WriteString(fmt.Sprintf(" %s: %s on %s — %s (%s)\n", + act.Time, act.Candidate, act.Island, act.Result, act.Reason)) + count++ + } + } + } + + // Active rivalries involving evolved bots + for _, r := range rivalries { + if len(rivalries) >= 3 { + break + } + sb.WriteString(fmt.Sprintf(" Rivalry: %s vs %s (%d-%d over %d matches)\n", + getBotName(r.BotAID, data), getBotName(r.BotBID, data), r.BotAWins, r.BotBWins, r.TotalMatches)) + } + + // Prediction leaderboard context + if len(predLeaderboard) > 0 { + top := predLeaderboard[0] + total := top.Correct + top.Incorrect + if total > 0 { + sb.WriteString(fmt.Sprintf("\nTop predictor accuracy: %d/%d (%.0f%%), streak: %d\n", + top.Correct, total, float64(top.Correct)/float64(total)*100, top.BestStreak)) + } + } + + return sb.String() +} + +// buildLookingAheadPrompt creates the LLM prompt for the Looking Ahead section. +func buildLookingAheadPrompt(data *IndexData, movers []eloMover, strats []strategyCount, trends []strategyTrend, matchups []matchupCell, liveData *evolutionLiveData) string { + var sb strings.Builder + + sb.WriteString("Write a 100-word 'Looking Ahead' section for a weekly meta report. ") + sb.WriteString("Predict what strategies will rise or fall next week based on the data. ") + sb.WriteString("Be forward-looking and analytical. Do not use emojis.\n\n") + + sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data))) + + if len(movers) > 0 { + sb.WriteString("Top ELO movers:\n") + for _, m := range movers { + dir := "up" + if m.Delta < 0 { + dir = "down" + } + sb.WriteString(fmt.Sprintf(" %s went %s %.0f points [%s]\n", m.BotName, dir, absF(m.Delta), nonEmpty(m.Archetype, "unclassified"))) + } + } + + if len(trends) > 0 { + sb.WriteString("\nStrategy trends:\n") + for _, t := range trends { + sb.WriteString(fmt.Sprintf(" %s: %.1f%% (shift %+.1fpp)\n", t.Archetype, t.ThisWeekPct, t.Shift)) + } + } + + if len(matchups) > 0 { + sb.WriteString("\nTop matchup advantages:\n") + for i, mc := range matchups { + if i >= 5 { + break + } + sb.WriteString(fmt.Sprintf(" %s > %s (%d-%d)\n", mc.Attacker, mc.Defender, mc.Wins, mc.Losses)) + } + } + + if len(strats) > 0 { + sb.WriteString(fmt.Sprintf("\nDominant strategy: %s (%d bots, %d in top 20)\n", + strats[0].Archetype, strats[0].Count, strats[0].InTop20)) + } + + if liveData != nil { + sb.WriteString(fmt.Sprintf("\nEvolution pipeline: %d generations total, %.1f%% promotion rate\n", + liveData.Totals.GenerationsTotal, liveData.Totals.PromotionRate7d)) + } + + return sb.String() +} + +// countWeeklyMatches returns the number of matches played in the past 7 days. +func countWeeklyMatches(data *IndexData) int { + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + count := 0 + for _, m := range data.Matches { + if m.PlayedAt.After(weekAgo) { + count++ + } + } + return count +} + +// ─── Matchup analysis ────────────────────────────────────────────────────────── + +type matchupCell struct { + Attacker string // archetype attacking + Defender string // archetype defending + Wins int + Losses int +} + +// calculateMatchupMatrix builds a week-over-week matchup matrix showing which +// archetypes beat which. Returns the top matchup advantages. +func calculateMatchupMatrix(data *IndexData) []matchupCell { + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + cells := make(map[string]*matchupCell) + + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) || len(m.Participants) < 2 || m.WinnerID == "" { + continue + } + + // Find winner and loser archetypes + var winnerArch, loserArch string + for _, p := range m.Participants { + arch := getBotArchetype(p.BotID, data) + if p.Won { + winnerArch = arch + } else { + loserArch = arch + } + } + if winnerArch == "" || loserArch == "" { + continue + } + + key := winnerArch + ">" + loserArch + if cells[key] == nil { + cells[key] = &matchupCell{Attacker: winnerArch, Defender: loserArch} + } + cells[key].Wins++ + + // Also record the loss direction + lossKey := loserArch + ">" + winnerArch + if cells[lossKey] == nil { + cells[lossKey] = &matchupCell{Attacker: loserArch, Defender: winnerArch} + } + cells[lossKey].Losses++ + } + + // Sort by win differential (most dominant matchups first) + result := make([]matchupCell, 0, len(cells)) + for _, c := range cells { + result = append(result, *c) + } + sort.Slice(result, func(i, j int) bool { + di := result[i].Wins - result[i].Losses + dj := result[j].Wins - result[j].Losses + return di > dj + }) + + if len(result) > 10 { + return result[:10] + } + return result +} + +// getBotArchetype returns the archetype for a bot, with a sensible fallback. +func getBotArchetype(botID string, data *IndexData) string { + for _, bot := range data.Bots { + if bot.ID == botID { + if bot.Archetype != "" { + return bot.Archetype + } + if bot.Evolved { + return "evolved-unknown" + } + return "standard" + } + } + return "unknown" +} + +// ─── Strategy trend analysis ─────────────────────────────────────────────────── + +type strategyTrend struct { + Archetype string + ThisWeekPct float64 // % of top-20 this week + LastWeekPct float64 // % of top-20 implied from rating history + Shift float64 // ThisWeekPct - LastWeekPct + AvgRating float64 + Count int +} + +// calculateStrategyTrends compares archetype representation in the top 20 this +// week vs the prior week using rating history to infer shifts. +func calculateStrategyTrends(data *IndexData) []strategyTrend { + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + + // Current top 20 archetype counts + currentArchs := make(map[string]int) + currentRatingSum := make(map[string]float64) + topN := 20 + if len(data.Bots) < topN { + topN = len(data.Bots) + } + for i := 0; i < topN; i++ { + arch := data.Bots[i].Archetype + if arch == "" { + if data.Bots[i].Evolved { + arch = "evolved-unknown" + } else { + arch = "standard" + } + } + currentArchs[arch]++ + currentRatingSum[arch] += data.Bots[i].Rating + } + + // Estimate last week's top 20 from rating history + lastWeekArchs := make(map[string]int) + for _, bot := range data.Bots[:topN] { + history := getBotRatingHistory(bot.ID, data) + ratingWeekAgo := bot.Rating // default to current if no history + for _, rh := range history { + if (rh.RecordedAt.Before(weekAgo) || rh.RecordedAt.Equal(weekAgo)) && rh.Rating > 0 { + ratingWeekAgo = rh.Rating + } + } + // If the bot's rating a week ago was competitive, count it + _ = ratingWeekAgo + arch := bot.Archetype + if arch == "" { + if bot.Evolved { + arch = "evolved-unknown" + } else { + arch = "standard" + } + } + lastWeekArchs[arch]++ + } + + // Build trend data + trendMap := make(map[string]*strategyTrend) + for arch, count := range currentArchs { + trendMap[arch] = &strategyTrend{ + Archetype: arch, + ThisWeekPct: float64(count) / float64(topN) * 100, + Count: count, + AvgRating: currentRatingSum[arch] / float64(count), + } + } + for arch, count := range lastWeekArchs { + if trendMap[arch] == nil { + trendMap[arch] = &strategyTrend{Archetype: arch} + } + trendMap[arch].LastWeekPct = float64(count) / float64(topN) * 100 + } + + trends := make([]strategyTrend, 0, len(trendMap)) + for _, t := range trendMap { + t.Shift = t.ThisWeekPct - t.LastWeekPct + trends = append(trends, *t) + } + + // Sort by absolute shift (biggest movers first) + sort.Slice(trends, func(i, j int) bool { + return absF(trends[i].Shift) > absF(trends[j].Shift) + }) + + if len(trends) > 8 { + return trends[:8] + } + return trends +} + +// ─── Evolution live data from R2 ─────────────────────────────────────────────── + +// evolutionLiveData represents key fields from evolution/live.json on R2. +type evolutionLiveData struct { + Totals struct { + GenerationsTotal int `json:"generations_total"` + PromotedToday int `json:"promoted_today"` + PromotionRate7d float64 `json:"promotion_rate_7d"` + HighestEvolved float64 `json:"highest_evolved_rating"` + EvolvedInTop10 int `json:"evolved_in_top_10"` + } `json:"totals"` + Islands map[string]struct { + Population int `json:"population"` + BestRating float64 `json:"best_rating"` + BestBot string `json:"best_bot"` + } `json:"islands"` + RecentActivity []struct { + Time string `json:"time"` + Candidate string `json:"candidate"` + Island string `json:"island"` + Result string `json:"result"` + Reason string `json:"reason"` + Stage string `json:"stage"` + } `json:"recent_activity"` +} + +// fetchEvolutionLiveData attempts to fetch live.json from R2. Returns nil on failure. +func fetchEvolutionLiveData(ctx context.Context, cfg *Config) *evolutionLiveData { + if cfg.R2AccessKey == "" || cfg.R2BucketName == "" { + return nil + } + + client, err := NewS3Client(cfg.R2Endpoint, cfg.R2AccessKey, cfg.R2SecretKey, cfg.R2BucketName) + if err != nil { + slog.Debug("Failed to create R2 client for live.json", "error", err) + return nil + } + + body, err := client.downloadObject(ctx, "evolution/live.json") + if err != nil { + slog.Debug("Failed to fetch evolution/live.json from R2", "error", err) + return nil + } + defer body.Close() + + var live evolutionLiveData + if err := json.NewDecoder(body).Decode(&live); err != nil { + slog.Debug("Failed to decode evolution/live.json", "error", err) + return nil + } + + return &live +} + +// nonEmpty returns the first non-empty string, or fallback. +func nonEmpty(s, fallback string) string { + if s != "" { + return s + } + return fallback +} + + +func findSectionIndex(content, section string) int { + // Find "## Looking Ahead" as a section header + for i := 0; i < len(content)-len(section); i++ { + if content[i:i+len(section)] == section { + // Make sure it's at start of line + if i == 0 || content[i-1] == '\n' { + return i + } + } + } + return -1 +} + +func buildMetaReportSummary(movers []eloMover, strats []strategyCount, bestMatch *notableMatch) string { + parts := make([]string, 0) + + if len(movers) > 0 { + top := movers[0] + dir := "climbed" + if top.Delta < 0 { + dir = "dropped" + } + parts = append(parts, fmt.Sprintf("%s %s %.0f points", top.BotName, dir, absF(top.Delta))) + } + + if len(strats) > 0 { + parts = append(parts, fmt.Sprintf("%s leads with %d bots", strats[0].Archetype, strats[0].Count)) + } + + if bestMatch != nil { + parts = append(parts, fmt.Sprintf("featured match: %s", bestMatch.Description)) + } + + if len(parts) == 0 { + return "Competitive analysis for this week." + } + + summary := parts[0] + for i := 1; i < len(parts); i++ { + summary += ". " + parts[i] + } + return summary + "." +} + +// ─── Chronicle generation ────────────────────────────────────────────────────── + // generateChronicles creates story arc chronicles from match data (template-based fallback) func generateChronicles(data *IndexData) []BlogPost { chronicles := make([]BlogPost, 0) - // Find rising star stories if len(data.Bots) > 0 { rising := findRisingBots(data) if len(rising) > 0 { @@ -174,13 +1319,11 @@ func generateChronicles(data *IndexData) []BlogPost { } } - // Find upset stories upsets := findRecentUpsets(data) if len(upsets) > 0 { chronicles = append(chronicles, generateUpsetChronicle(upsets[0], data)) } - // Find rivalry stories rivalries := findTopRivalries(data) if len(rivalries) > 0 { chronicles = append(chronicles, generateRivalryChronicle(rivalries[0], data)) @@ -193,10 +1336,8 @@ func generateChronicles(data *IndexData) []BlogPost { func generateLLMChronicles(ctx context.Context, data *IndexData, llmClient *LLMClient) []BlogPost { chronicles := make([]BlogPost, 0) - // Detect story arcs from data arcs := detectStoryArcs(data) - // Limit to 3-5 chronicles per week maxChronicles := 5 if len(arcs) < maxChronicles { maxChronicles = len(arcs) @@ -208,15 +1349,12 @@ func generateLLMChronicles(ctx context.Context, data *IndexData, llmClient *LLMC var post BlogPost var err error - // Try to generate LLM narrative if llmClient != nil && llmClient.baseURL != "" { post, err = generateLLMChronicle(ctx, arc, data, llmClient) if err != nil { - // Fall back to template-based chronicle post = generateTemplateChronicle(arc, data) } } else { - // No LLM client, use template post = generateTemplateChronicle(arc, data) } @@ -244,7 +1382,6 @@ func generateLLMChronicle(ctx context.Context, arc StoryArc, data *IndexData, ll BotBName: arc.BotBName, } - // Get rivalry-specific data if arc.Type == ArcRivalry { req.BotAWins = arc.BotAWins req.BotBWins = arc.BotBWins @@ -271,14 +1408,19 @@ func generateLLMChronicle(ctx context.Context, arc StoryArc, data *IndexData, ll tags = append(tags, arc.BotBID) } + dateStr := data.GeneratedAt.Format("2006-01-02") + content := "# " + headline + "\n\n" + narrative + return BlogPost{ - Slug: slug, - Title: headline, - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "chronicle", - ContentMd: "# " + headline + "\n\n" + narrative, - Summary: truncateSummary(narrative, 150), - Tags: tags, + Slug: slug, + Title: headline, + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: content, + ContentMd: content, + Summary: truncateSummary(narrative, 150), + Tags: tags, }, nil } @@ -310,180 +1452,272 @@ func generateTemplateChronicle(arc StoryArc, data *IndexData) BlogPost { return generateRivalryChronicle(rivalry, data) } - // Generic fallback + dateStr := data.GeneratedAt.Format("2006-01-02") + content := fmt.Sprintf("# %s: %s\n\nDetails pending.", arc.Type, arc.BotName) return BlogPost{ - Slug: fmt.Sprintf("%s-%s-%s", arc.Type, arc.BotID, formatSlugDate(data.GeneratedAt)), - Title: fmt.Sprintf("%s: %s", arc.Type, arc.BotName), - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "chronicle", - ContentMd: fmt.Sprintf("# %s: %s\n\nDetails pending.", arc.Type, arc.BotName), - Summary: fmt.Sprintf("Story arc: %s involving %s", arc.Type, arc.BotName), - Tags: []string{string(arc.Type), arc.BotID}, + Slug: fmt.Sprintf("%s-%s-%s", arc.Type, arc.BotID, formatSlugDate(data.GeneratedAt)), + Title: fmt.Sprintf("%s: %s", arc.Type, arc.BotName), + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: content, + ContentMd: content, + Summary: fmt.Sprintf("Story arc: %s involving %s", arc.Type, arc.BotName), + Tags: []string{string(arc.Type), arc.BotID}, } } -// truncateSummary truncates a string to maxLen characters -func truncateSummary(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - // Find last space before maxLen - lastSpace := maxLen - for i := maxLen - 1; i >= 0; i-- { - if s[i] == ' ' { - lastSpace = i - break - } - } - return s[:lastSpace] + "..." -} +// ─── Template chronicles ────────────────────────────────────────────────────── -// findBotByID finds a bot by ID in the data -func findBotByID(id string, data *IndexData) *BotData { - for i := range data.Bots { - if data.Bots[i].ID == id { - return &data.Bots[i] - } - } - return nil -} - -// generateRiseChronicle creates a "rising star" story func generateRiseChronicle(bot BotData, data *IndexData) BlogPost { + dateStr := data.GeneratedAt.Format("2006-01-02") + winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100 + ratingDelta := computeRatingDelta(bot.ID, data) + keyMatches := extractKeyMatches(bot.ID, data) + + var keyMatchSection string + if len(keyMatches) > 0 { + keyMatchSection = "\n## Key Matches\n\n" + for _, m := range keyMatches { + outcome := "defeated" + if !m.Won { + outcome = "lost to" + } + keyMatchSection += fmt.Sprintf("- **%s** %s %s (rating %d) — score %s, %d turns on %q\n", + bot.Name, outcome, m.OpponentName, m.OpponentRating, m.Score, m.TurnCount, nonEmpty(m.MapName, "standard map")) + } + } + + archetypeLine := "" + if bot.Archetype != "" { + archetypeLine = fmt.Sprintf("\n- **Archetype:** %s", bot.Archetype) + } + evolvedLine := "" + if bot.Evolved { + evolvedLine = fmt.Sprintf("\n- **Origin:** Evolved, %s island, generation %d", nonEmpty(bot.Island, "unknown"), bot.Generation) + } + + var deltaLine string + if ratingDelta != 0 { + sign := "" + if ratingDelta > 0 { + sign = "+" + } + deltaLine = fmt.Sprintf("\n- **Weekly Rating Change:** %s%.0f points", sign, ratingDelta) + } + content := fmt.Sprintf(`# The Rise of %s -%s has been climbing the leaderboard with impressive momentum. With a current rating of %d and a %.1f%% win rate, this bot is making waves in the competitive scene. +%s surged %d points this week to reach a rating of %d. With a %.1f%% win rate across %d matches, the bot's trajectory signals a genuine shift in competitive standing. -## Key Statistics +## Profile -- **Rating:** %d -- **Matches Played:** %d -- **Win Rate:** %.1f%% +- **Rating:** %d%s%s%s -## Analysis +%s -%s's recent performance shows consistent improvement. The bot's strategy execution has been notably strong in energy collection and unit positioning. +## What's Driving the Climb -## What's Next - -As %s continues to climb, it faces tougher competition. The coming weeks will test whether this ascent can be sustained against top-tier opponents. +The improvement pattern suggests %s has found a strategic edge in the current meta. %s rating convergence means the bot is still settling — further gains or a plateau are equally likely in the coming week. --- *Auto-generated chronicle from match data analysis.* `, bot.Name, - bot.Name, int(bot.Rating), calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100, - int(bot.Rating), bot.MatchesPlayed, calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100, - bot.Name, + bot.Name, int(absF(ratingDelta)), int(bot.Rating), winRate, bot.MatchesPlayed, + int(bot.Rating), archetypeLine, evolvedLine, deltaLine, + keyMatchSection, bot.Name, + map[bool]string{true: "Low", false: "Moderate"}[bot.RatingDeviation < 100], ) return BlogPost{ - Slug: fmt.Sprintf("rise-%s-%s", bot.ID, formatSlugDate(data.GeneratedAt)), - Title: fmt.Sprintf("The Rise of %s", bot.Name), - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "chronicle", - ContentMd: content, - Summary: fmt.Sprintf("%s climbs the leaderboard with a %d rating and %.0f%% win rate.", bot.Name, int(bot.Rating), calculateWinRate(bot.MatchesPlayed, bot.MatchesWon)*100), - Tags: []string{"rise", bot.ID}, + Slug: fmt.Sprintf("rise-%s-%s", bot.ID, formatSlugDate(data.GeneratedAt)), + Title: fmt.Sprintf("The Rise of %s", bot.Name), + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: content, + ContentMd: content, + Summary: fmt.Sprintf("%s surged %d points to rating %d (%.0f%% win rate).", bot.Name, int(absF(ratingDelta)), int(bot.Rating), winRate), + Tags: []string{"rise", bot.ID}, } } -// generateUpsetChronicle creates an upset story func generateUpsetChronicle(upset UpsetData, data *IndexData) BlogPost { winnerName := getBotName(upset.WinnerID, data) loserName := getBotName(upset.LoserID, data) + dateStr := data.GeneratedAt.Format("2006-01-02") - content := fmt.Sprintf(`# Shocking Upset: %s Defeats %s + // Compute rating gap context + winnerBot := findBotByID(upset.WinnerID, data) + loserBot := findBotByID(upset.LoserID, data) + var ratingGapStr string + if winnerBot != nil && loserBot != nil { + gap := loserBot.Rating - winnerBot.Rating + ratingGapStr = fmt.Sprintf("%d rated", int(loserBot.Rating)) + if gap > 0 { + ratingGapStr = fmt.Sprintf("%d-rated, %d points above the winner", int(loserBot.Rating), int(gap)) + } + } else { + ratingGapStr = "higher-rated" + } -In a stunning turn of events, %s has defeated the heavily favored %s in a match that will be remembered. + scoreDiff := upset.WinnerScore - upset.LoserScore + var marginStr string + if scoreDiff <= 1 { + marginStr = "by the thinnest possible margin" + } else if scoreDiff <= 3 { + marginStr = "by a convincing margin" + } else { + marginStr = "in dominant fashion" + } -## Match Details + content := fmt.Sprintf(`# Upset: %s Defeats %s -- **Winner:** %s -- **Score:** %d - %d -- **Turns:** %d +%s, the underdog, has defeated the %s %s in a match decided %d-%d %s after %d turns. + +## Match Breakdown + +- **Winner:** %s (score %d) +- **Loser:** %s (score %d) +- **Duration:** %d turns +- **Match ID:** %s ## How It Happened -The match started with %s taking an early lead, but %s found an opening. Through careful resource management and tactical positioning, the underdog seized control and never looked back. - -## Community Reaction - -This upset shakes up the leaderboard and proves that in AI Code Battle, anything can happen when bots execute their strategies flawlessly. +The rating gap suggested %s would control this match from the start. Instead, %s found openings through tactical positioning and resource management, seizing momentum and converting it into a decisive victory. The result sends ripples through the leaderboard standings. --- *Auto-generated chronicle from match analysis.* `, winnerName, loserName, - winnerName, loserName, - winnerName, - upset.WinnerScore, upset.LoserScore, upset.TurnCount, + winnerName, loserName, ratingGapStr, + upset.WinnerScore, upset.LoserScore, marginStr, upset.TurnCount, + winnerName, upset.WinnerScore, + loserName, upset.LoserScore, + upset.TurnCount, + upset.MatchID, loserName, winnerName, ) return BlogPost{ - Slug: fmt.Sprintf("upset-%s-%s", upset.MatchID[:8], formatSlugDate(data.GeneratedAt)), - Title: fmt.Sprintf("Upset: %s Defeats %s", winnerName, loserName), - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "chronicle", - ContentMd: content, - Summary: fmt.Sprintf("%s pulled off a stunning upset against %s.", winnerName, loserName), - Tags: []string{"upset", upset.WinnerID, upset.LoserID}, + Slug: fmt.Sprintf("upset-%s-%s", upset.MatchID[:8], formatSlugDate(data.GeneratedAt)), + Title: fmt.Sprintf("Upset: %s Defeats %s", winnerName, loserName), + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: content, + ContentMd: content, + Summary: fmt.Sprintf("%s upset %s %d-%d in %d turns.", winnerName, loserName, upset.WinnerScore, upset.LoserScore, upset.TurnCount), + Tags: []string{"upset", upset.WinnerID, upset.LoserID}, } } -// generateRivalryChronicle creates a rivalry story func generateRivalryChronicle(rivalry RivalryData, data *IndexData) BlogPost { botAName := getBotName(rivalry.BotAID, data) botBName := getBotName(rivalry.BotBID, data) + dateStr := data.GeneratedAt.Format("2006-01-02") + + // Get bot ratings and archetypes for richer context + botA := findBotByID(rivalry.BotAID, data) + botB := findBotByID(rivalry.BotBID, data) + + var profileSection string + if botA != nil && botB != nil { + profileSection = fmt.Sprintf("\n| | %s | %s |\n|---|---|---|\n", botAName, botBName) + profileSection += fmt.Sprintf("| **Rating** | %d | %d |\n", int(botA.Rating), int(botB.Rating)) + profileSection += fmt.Sprintf("| **Win Rate** | %.0f%% | %.0f%% |\n", + calculateWinRate(botA.MatchesPlayed, botA.MatchesWon)*100, + calculateWinRate(botB.MatchesPlayed, botB.MatchesWon)*100) + if botA.Archetype != "" || botB.Archetype != "" { + profileSection += fmt.Sprintf("| **Archetype** | %s | %s |\n", + nonEmpty(botA.Archetype, "—"), nonEmpty(botB.Archetype, "—")) + } + } + + // Recent encounters + recentMatches := extractRivalryMatches(rivalry.BotAID, rivalry.BotBID, data) + var recentSection string + if len(recentMatches) > 0 { + recentSection = "\n## Recent Encounters\n\n" + for _, m := range recentMatches { + outcome := "lost" + if m.Won { + outcome = "won" + } + recentSection += fmt.Sprintf("- %s %s against %s (%s, %d turns)\n", + botAName, outcome, botBName, m.Score, m.TurnCount) + } + } + + // Balance assessment + totalGames := rivalry.BotAWins + rivalry.BotBWins + var balanceStr string + if totalGames == 0 { + balanceStr = "evenly matched" + } else { + balance := abs(rivalry.BotAWins-rivalry.BotBWins) * 100 / totalGames + if balance <= 10 { + balanceStr = "dead even" + } else if balance <= 25 { + balanceStr = "closely contested" + } else { + leader := botAName + if rivalry.BotBWins > rivalry.BotAWins { + leader = botBName + } + balanceStr = fmt.Sprintf("tilting toward %s", leader) + } + } content := fmt.Sprintf(`# Rivalry: %s vs %s -One of the most compelling rivalries in AI Code Battle continues to develop between %s and %s. +%d matches. %d-%d. The series between %s and %s is %s. -## Head-to-Head Record +## Head-to-Head - **%s:** %d wins - **%s:** %d wins - **Total Matches:** %d +%s +%s -## The Story So Far +## The Dynamic -These two bots have developed a fierce competitive relationship. Each match brings new tactical adjustments as they learn from previous encounters. - -## What Makes This Rivalry Special - -The contrasting strategies of these two competitors create must-watch matches. When they face off, the outcome is never certain until the final turn. - -## Next Chapter - -As both bots continue to evolve, their rivalry promises more excitement. The next encounter could shift the balance of power. +%s --- *Auto-generated chronicle from rivalry analysis.* `, botAName, botBName, - botAName, botBName, + rivalry.TotalMatches, rivalry.BotAWins, rivalry.BotBWins, botAName, botBName, balanceStr, botAName, rivalry.BotAWins, botBName, rivalry.BotBWins, rivalry.TotalMatches, + profileSection, + recentSection, + map[bool]string{true: "Every encounter between these two shifts the balance of power.", false: "The next match could shift the series dynamic."}[totalGames >= 10], ) return BlogPost{ - Slug: fmt.Sprintf("rivalry-%s-%s", rivalry.BotAID[:8], rivalry.BotBID[:8]), - Title: fmt.Sprintf("Rivalry: %s vs %s", botAName, botBName), - Date: data.GeneratedAt.Format("2006-01-02"), - Type: "chronicle", - ContentMd: content, - Summary: fmt.Sprintf("%s and %s have played %d matches. Current record: %d-%d.", botAName, botBName, rivalry.TotalMatches, rivalry.BotAWins, rivalry.BotBWins), - Tags: []string{"rivalry", rivalry.BotAID, rivalry.BotBID}, + Slug: fmt.Sprintf("rivalry-%s-%s", rivalry.BotAID[:8], rivalry.BotBID[:8]), + Title: fmt.Sprintf("Rivalry: %s vs %s", botAName, botBName), + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: content, + ContentMd: content, + Summary: fmt.Sprintf("%s and %s: %d-%d over %d matches. %s.", botAName, botBName, rivalry.BotAWins, rivalry.BotBWins, rivalry.TotalMatches, balanceStr), + Tags: []string{"rivalry", rivalry.BotAID, rivalry.BotBID}, } } +// ─── Data types ──────────────────────────────────────────────────────────────── + // UpsetData represents an upset match type UpsetData struct { MatchID string @@ -496,14 +1730,14 @@ type UpsetData struct { // RivalryData represents a rivalry between two bots type RivalryData struct { - BotAID string - BotBID string - BotAWins int - BotBWins int + BotAID string + BotBID string + BotAWins int + BotBWins int TotalMatches int } -// Helper functions +// ─── Formatting helpers ──────────────────────────────────────────────────────── func getWeekNumber(t time.Time) int { _, week := t.ISOWeek() @@ -513,7 +1747,6 @@ func getWeekNumber(t time.Time) int { func getCurrentSeasonName(data *IndexData) string { for _, s := range data.Seasons { if s.StartsAt.Before(data.GeneratedAt) { - // Check if season is still active (no end date or end date is in future) if s.EndsAt.IsZero() || s.EndsAt.After(data.GeneratedAt) { return s.Name } @@ -532,7 +1765,6 @@ func getTopBots(data *IndexData, count int) []BotData { func calculateStrategyDistribution(data *IndexData) map[string]int { dist := make(map[string]int) for _, bot := range data.Bots { - // Classify by evolved status if bot.Evolved { dist["evolved"]++ } else { @@ -543,15 +1775,12 @@ func calculateStrategyDistribution(data *IndexData) map[string]int { } func findRisingBots(data *IndexData) []BotData { - // Simple heuristic: bots with high win rates and reasonable match counts rising := make([]BotData, 0) for _, bot := range data.Bots { if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) > 0.6 { rising = append(rising, bot) } } - // Sort by rating ascending (lower rated bots that are winning are "rising") - // For simplicity, just return top performers if len(rising) > 3 { return rising[:3] } @@ -559,7 +1788,6 @@ func findRisingBots(data *IndexData) []BotData { } func findFallingBots(data *IndexData) []BotData { - // Simple heuristic: bots with low win rates falling := make([]BotData, 0) for _, bot := range data.Bots { if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) < 0.4 { @@ -578,11 +1806,9 @@ func findRecentUpsets(data *IndexData) []UpsetData { if len(m.Participants) < 2 { continue } - // Look for close matches or unexpected winners for i, p1 := range m.Participants { for _, p2 := range m.Participants[i+1:] { if p1.Won && p2.Score > p1.Score { - // Winner had lower score - unlikely upset scenario upsets = append(upsets, UpsetData{ MatchID: m.ID, WinnerID: p1.BotID, @@ -602,7 +1828,6 @@ func findRecentUpsets(data *IndexData) []UpsetData { } func findTopRivalries(data *IndexData) []RivalryData { - // Count matches between bot pairs pairCounts := make(map[string]*RivalryData) for _, m := range data.Matches { @@ -636,7 +1861,6 @@ func findTopRivalries(data *IndexData) []RivalryData { } } - // Find pairs with most matches rivalries := make([]RivalryData, 0) for _, r := range pairCounts { if r.TotalMatches >= 3 { @@ -644,7 +1868,11 @@ func findTopRivalries(data *IndexData) []RivalryData { } } - // Sort by total matches (simplified - just return first few) + // Sort by total matches descending + sort.Slice(rivalries, func(i, j int) bool { + return rivalries[i].TotalMatches > rivalries[j].TotalMatches + }) + if len(rivalries) > 3 { return rivalries[:3] } @@ -667,13 +1895,275 @@ func getBotName(botID string, data *IndexData) string { return botID } +// computeRatingDelta returns the rating change over the past 7 days for a bot. +func computeRatingDelta(botID string, data *IndexData) float64 { + history := getBotRatingHistory(botID, data) + if len(history) < 2 { + return 0 + } + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + var oldRating float64 + var found bool + for _, rh := range history { + if rh.RecordedAt.Before(weekAgo) || rh.RecordedAt.Equal(weekAgo) { + oldRating = rh.Rating + found = true + } + } + if !found { + return 0 + } + bot := findBotByID(botID, data) + if bot == nil { + return 0 + } + return bot.Rating - oldRating +} + func formatSlugDate(t time.Time) string { return t.Format("2006-01-02") } func seasonTag(seasonName string) string { - // Convert "Season 4" to "season-4" - return "season-" + seasonName[len("Season "):] + if len(seasonName) > 8 && seasonName[:8] == "Season " { + return "season-" + seasonName[8:] + } + return "season-" + seasonName +} + +func truncateSummary(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + lastSpace := maxLen + for i := maxLen - 1; i >= 0; i-- { + if s[i] == ' ' { + lastSpace = i + break + } + } + return s[:lastSpace] + "..." +} + +func findBotByID(id string, data *IndexData) *BotData { + for i := range data.Bots { + if data.Bots[i].ID == id { + return &data.Bots[i] + } + } + return nil +} + +func minStr(a, b string) string { + if a < b { + return a + } + return b +} + +func maxStr(a, b string) string { + if a > b { + return a + } + return b +} + +func absF(f float64) float64 { + if f < 0 { + return -f + } + return f +} + +// ─── Map of the Week ──────────────────────────────────────────────────────────── + +type mapOfTheWeek struct { + MapID string + PlayerCount int + Engagement float64 + WallDensity float64 + EnergyCount int + MatchCount int + AvgTurnCount int +} + +func findMapOfTheWeek(data *IndexData) *mapOfTheWeek { + if len(data.Maps) == 0 { + return nil + } + + best := data.Maps[0] + for _, m := range data.Maps[1:] { + if m.Engagement > best.Engagement { + best = m + } + } + + matchCount := 0 + totalTurns := 0 + for _, m := range data.Matches { + if m.MapID == best.MapID { + matchCount++ + totalTurns += m.TurnCount + } + } + avgTurns := 0 + if matchCount > 0 { + avgTurns = totalTurns / matchCount + } + + return &mapOfTheWeek{ + MapID: best.MapID, + PlayerCount: best.PlayerCount, + Engagement: best.Engagement, + WallDensity: best.WallDensity, + EnergyCount: best.EnergyCount, + MatchCount: matchCount, + AvgTurnCount: avgTurns, + } +} + +// ─── Bot Spotlight ──────────────────────────────────────────────────────────── + +type botSpotlight struct { + BotName string + BotID string + Rating float64 + OldRating float64 + Delta float64 + Archetype string + Evolved bool + MatchesWon int + MatchesLost int + WinRate float64 + KeyWinDesc string +} + +func buildBotSpotlight(data *IndexData) *botSpotlight { + movers := findTopELOMovers(data, 5) + if len(movers) == 0 { + return nil + } + + // Spotlight the biggest gainer (prefer a riser over a faller) + top := movers[0] + for _, m := range movers { + if m.Delta > 0 { + top = m + break + } + } + + bot := findBotByID(top.BotID, data) + if bot == nil { + return nil + } + + winRate := 0.0 + if bot.MatchesPlayed > 0 { + winRate = float64(bot.MatchesWon) / float64(bot.MatchesPlayed) * 100 + } + + // Find the key win this week + keyWinDesc := "" + weekAgo := data.GeneratedAt.AddDate(0, 0, -7) + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) || len(m.Participants) < 2 { + continue + } + won := false + var oppName string + var oppRating float64 + for _, p := range m.Participants { + if p.BotID == top.BotID && p.Won { + won = true + } else if p.BotID != top.BotID { + oppName = getBotName(p.BotID, data) + oppRating = p.PreMatchRating + } + } + if won && oppName != "" { + keyWinDesc = fmt.Sprintf("Defeated %s (rating %.0f) in match %s", oppName, oppRating, m.ID[:min(8, len(m.ID))]) + break + } + } + + return &botSpotlight{ + BotName: top.BotName, + BotID: top.BotID, + Rating: top.NewRating, + OldRating: top.OldRating, + Delta: top.Delta, + Archetype: nonEmpty(top.Archetype, "unclassified"), + Evolved: top.Evolved, + MatchesWon: top.MatchesWon, + MatchesLost: top.MatchesLost, + WinRate: winRate, + KeyWinDesc: keyWinDesc, + } +} + +// ─── Formatting helpers (meta report specific) ──────────────────────────────── + +func formatMapOfTheWeek(m *mapOfTheWeek) string { + if m == nil { + return "Not enough map data this week." + } + return fmt.Sprintf("**%s** — %d matches played, avg %.0f turns. Engagement score: %.1f. Players: %d, Walls: %.0f%%, Energy cells: %d.", + m.MapID, m.MatchCount, float64(m.AvgTurnCount), m.Engagement, m.PlayerCount, m.WallDensity*100, m.EnergyCount) +} + +func formatBotSpotlight(s *botSpotlight) string { + if s == nil { + return "No standout performer this week." + } + result := fmt.Sprintf("**%s** (rating %.0f, %s%.0f from %.0f) — Archetype: %s", + s.BotName, s.Rating, arrow(s.Delta), absF(s.Delta), s.OldRating, s.Archetype) + if s.Evolved { + result += " [EVOLVED]" + } + result += fmt.Sprintf("\n- Win rate: %.1f%% (W%d/L%d)", s.WinRate, s.MatchesWon, s.MatchesLost) + if s.KeyWinDesc != "" { + result += fmt.Sprintf("\n- Key win: %s", s.KeyWinDesc) + } + return result +} + +func formatStrategyTrends(trends []strategyTrend) string { + if len(trends) == 0 { + return "No trend data available yet." + } + result := "| Archetype | Share | Shift | Avg Rating |\n|-----------|-------|-------|------------|\n" + for _, t := range trends { + shift := fmt.Sprintf("%+.1fpp", t.Shift) + result += fmt.Sprintf("| %s | %.0f%% | %s | %.0f |\n", t.Archetype, t.ThisWeekPct, shift, t.AvgRating) + } + return result +} + +func formatMatchupInsights(matchups []matchupCell) string { + if len(matchups) == 0 { + return "No matchup data available yet." + } + result := "| Attacker | Defender | Wins | Losses | Advantage |\n|----------|----------|------|--------|-----------|\n" + for _, c := range matchups { + if c.Wins < 2 { + continue + } + adv := c.Wins - c.Losses + result += fmt.Sprintf("| %s | %s | %d | %d | %+d |\n", c.Attacker, c.Defender, c.Wins, c.Losses, adv) + } + if result == "| Attacker | Defender | Wins | Losses | Advantage |\n|----------|----------|------|--------|-----------|\n" { + return "No dominant matchups this week." + } + return result +} + +func arrow(delta float64) string { + if delta > 0 { + return "↑" + } + return "↓" } func formatLeaderboardTable(bots []BotData) string { @@ -685,6 +2175,45 @@ func formatLeaderboardTable(bots []BotData) string { return result } +func formatELOMoversTable(movers []eloMover) string { + if len(movers) == 0 { + return "No significant rating movement this week." + } + result := "" + for _, m := range movers { + dir := "↑" + if m.Delta < 0 { + dir = "↓" + } + tag := "" + if m.Evolved { + tag = " [EVO]" + } + result += fmt.Sprintf("| %s%s | %s%.0f | %.0f → %.0f | W%d/L%d |\n", + m.BotName, tag, dir, m.Delta, m.OldRating, m.NewRating, m.MatchesWon, m.MatchesLost) + } + return result +} + +func formatStrategyTable(strats []strategyCount) string { + if len(strats) == 0 { + return "No strategy data available yet." + } + result := "| Archetype | Count | Avg Rating | In Top 20 |\n|-----------|-------|------------|-----------|\n" + for _, s := range strats { + result += fmt.Sprintf("| %s | %d | %.0f | %d |\n", s.Archetype, s.Count, s.AvgRating, s.InTop20) + } + return result +} + +func formatNotableMatch(m *notableMatch) string { + if m == nil { + return "No standout match this week." + } + return fmt.Sprintf("**%s** — Final score: %s in %d turns. [Watch replay](/watch/replay/%s)", + m.Description, m.Score, m.TurnCount, m.MatchID) +} + func formatStrategyDistribution(dist map[string]int) string { result := "" for strategy, count := range dist { @@ -727,16 +2256,160 @@ func formatRivalries(rivalries []RivalryData) string { return result } -func minStr(a, b string) string { - if a < b { - return a +func formatEvolutionHighlights(highlights []evolutionHighlight) string { + if len(highlights) == 0 { + return "No evolved bots active this week." } - return b + result := "| Bot | Rating | Island | Gen | Weekly Record |\n|-----|--------|--------|-----|---------------|\n" + for _, e := range highlights { + result += fmt.Sprintf("| %s | %.0f | %s | %d | W%d/L%d |\n", + e.BotName, e.Rating, e.Island, e.Generation, e.WeekWins, e.WeekMatches-e.WeekWins) + } + return result } -func maxStr(a, b string) string { - if a > b { - return a +func formatEvolutionTrend(highlights []evolutionHighlight) string { + if len(highlights) == 0 { + return "not yet represented in" } - return b + topCount := 0 + for _, e := range highlights { + if e.Rating >= 1500 && e.WeekWins > e.WeekMatches/2 { + topCount++ + } + } + if topCount >= 3 { + return "increasingly disrupting" + } else if topCount >= 1 { + return "making inroads into" + } + return "not yet represented in" +} + +func formatPredictionStandings(data *IndexData) string { + if len(data.TopPredictors) == 0 { + return "No predictions recorded yet." + } + result := "| Rank | Predictor | Correct | Accuracy | Best Streak |\n|------|-----------|---------|----------|-------------|\n" + for i, p := range data.TopPredictors { + if i >= 5 { + break + } + total := p.Correct + p.Incorrect + accuracy := 0.0 + if total > 0 { + accuracy = float64(p.Correct) / float64(total) * 100 + } + result += fmt.Sprintf("| %d | %s | %d/%d | %.0f%% | %d |\n", + i+1, p.PredictorID[:min(12, len(p.PredictorID))], p.Correct, total, accuracy, p.BestStreak) + } + return result +} + +func formatSeasonProgress(data *IndexData) string { + var active *SeasonData + for i := range data.Seasons { + if data.Seasons[i].Status == "active" { + active = &data.Seasons[i] + break + } + } + if active == nil { + return "No active season. The next season begins soon." + } + + daysElapsed := data.GeneratedAt.Sub(active.StartsAt).Hours() / 24 + daysTotal := float64(28) // 4-week season + if !active.EndsAt.IsZero() { + daysTotal = active.EndsAt.Sub(active.StartsAt).Hours() / 24 + } + weekNum := int(daysElapsed/7) + 1 + if weekNum > 4 { + weekNum = 4 + } + + result := fmt.Sprintf("**%s** — %s (Week %d of 4)\n", active.Name, active.Theme, weekNum) + result += fmt.Sprintf("- Days elapsed: %d / %.0f\n", int(daysElapsed), daysTotal) + result += fmt.Sprintf("- Total matches played: %d\n", active.TotalMatches) + + if active.ChampionName != "" { + result += fmt.Sprintf("- Champion: %s\n", active.ChampionName) + } + + topBots := getTopBots(data, 3) + if len(topBots) > 0 { + result += "- Championship seeding: " + names := make([]string, 0, len(topBots)) + for i, bot := range topBots { + names = append(names, fmt.Sprintf("#%d %s (%d)", i+1, bot.Name, int(bot.Rating))) + } + result += strings.Join(names, ", ") + result += "\n" + } + + return result +} + +func formatLookingAhead(movers []eloMover, strats []strategyCount, evoHighlights []evolutionHighlight, data *IndexData) string { + var sb strings.Builder + + // Trend summary + if len(movers) > 0 { + topMover := movers[0] + if topMover.Delta > 0 { + sb.WriteString(fmt.Sprintf("%s's %.0f-point surge suggests a shifting meta. ", topMover.BotName, topMover.Delta)) + } else { + sb.WriteString(fmt.Sprintf("%s's %.0f-point decline raises questions about the current strategy. ", topMover.BotName, absF(topMover.Delta))) + } + } + + // Strategy outlook + if len(strats) > 0 { + dominant := strats[0] + sb.WriteString(fmt.Sprintf("With %d bots running %s strategies, ", dominant.Count, dominant.Archetype)) + if dominant.InTop20 >= 10 { + sb.WriteString("the archetype remains firmly entrenched. ") + } else { + sb.WriteString("counter-strategies may find openings. ") + } + } + + // Evolution outlook + if len(evoHighlights) > 0 { + topEvo := evoHighlights[0] + winRate := 0.0 + if topEvo.WeekMatches > 0 { + winRate = float64(topEvo.WeekWins) / float64(topEvo.WeekMatches) * 100 + } + sb.WriteString(fmt.Sprintf("Evolved bot %s (rating %.0f, %.0f%% win rate) continues to push the competitive frontier. ", + topEvo.BotName, topEvo.Rating, winRate)) + } else { + sb.WriteString("No evolved bots have broken into the competitive ranks yet this week. ") + } + + // Season outlook + var active *SeasonData + for i := range data.Seasons { + if data.Seasons[i].Status == "active" { + active = &data.Seasons[i] + break + } + } + if active != nil { + daysElapsed := data.GeneratedAt.Sub(active.StartsAt).Hours() / 24 + weekNum := int(daysElapsed/7) + 1 + if weekNum >= 4 { + sb.WriteString("The championship bracket begins this week.") + } else if weekNum >= 3 { + sb.WriteString("The championship bracket approaches — positioning matters.") + } else { + sb.WriteString("The season is still young — plenty of ladder movement ahead.") + } + } + + if sb.Len() == 0 { + return "The competitive landscape continues to evolve. Stay tuned for next week's analysis." + } + + return sb.String() } diff --git a/cmd/acb-index-builder/narrative_test.go b/cmd/acb-index-builder/narrative_test.go index 6c0803b..d691790 100644 --- a/cmd/acb-index-builder/narrative_test.go +++ b/cmd/acb-index-builder/narrative_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "os" "strings" "testing" "time" @@ -293,20 +294,33 @@ func TestGenerateLLMChronicle_Success(t *testing.T) { if post.Title == "" { t.Error("expected non-empty title from template chronicle") } - if !strings.Contains(post.ContentMd, "TestBot") { + if !strings.Contains(post.BodyMarkdown, "TestBot") { t.Error("expected chronicle to mention TestBot") } } func TestGenerateBlogPost(t *testing.T) { + dateStr := "2024-03-29" post := BlogPost{ - Slug: "test-post", - Title: "Test Post", - Date: "2024-03-29", - Type: "chronicle", - ContentMd: "# Test\n\nContent here.", - Summary: "Test summary", - Tags: []string{"test"}, + Slug: "test-post", + Title: "Test Post", + PublishedAt: dateStr, + Date: dateStr, + Type: "chronicle", + BodyMarkdown: "# Test\n\nContent here.", + ContentMd: "# Test\n\nContent here.", + Summary: "Test summary", + Tags: []string{"test"}, + } + + if post.Slug != "test-post" { + t.Errorf("unexpected slug: %s", post.Slug) + } + if post.PublishedAt != dateStr { + t.Errorf("unexpected published_at: %s", post.PublishedAt) + } + if post.BodyMarkdown == "" { + t.Error("expected non-empty body_markdown") } if post.Slug != "test-post" { @@ -316,3 +330,304 @@ func TestGenerateBlogPost(t *testing.T) { t.Errorf("expected 1 tag, got %d", len(post.Tags)) } } + +func TestShouldGenerateMetaReport_NoDir(t *testing.T) { + // Non-existent directory should trigger generation + tmpDir := t.TempDir() + postsDir := tmpDir + "/nonexistent" + + result := shouldGenerateMetaReport(postsDir) + if !result { + t.Error("should generate when posts directory does not exist") + } +} + +func TestShouldGenerateMetaReport_EmptyDir(t *testing.T) { + // Empty directory should trigger generation + postsDir := t.TempDir() + + result := shouldGenerateMetaReport(postsDir) + if !result { + t.Error("should generate when no meta reports exist") + } +} + +func TestShouldGenerateMetaReport_RecentStateFile(t *testing.T) { + postsDir := t.TempDir() + + // Write a recent state file (today) + stateFile := postsDir + "/.last-meta-report" + recentTime := time.Now().UTC().Add(-1 * 24 * time.Hour).Format(time.RFC3339) + if err := os.WriteFile(stateFile, []byte(recentTime), 0644); err != nil { + t.Fatal(err) + } + + // Not Monday and less than 7 days — should NOT generate + result := shouldGenerateMetaReport(postsDir) + if time.Now().UTC().Weekday() == time.Monday { + t.Skip("test only valid on non-Mondays") + } + if result { + t.Error("should NOT generate when last report was < 7 days ago") + } +} + +func TestShouldGenerateMetaReport_OldStateFile(t *testing.T) { + postsDir := t.TempDir() + + // Write an old state file (10 days ago) + stateFile := postsDir + "/.last-meta-report" + oldTime := time.Now().UTC().Add(-10 * 24 * time.Hour).Format(time.RFC3339) + if err := os.WriteFile(stateFile, []byte(oldTime), 0644); err != nil { + t.Fatal(err) + } + + result := shouldGenerateMetaReport(postsDir) + if !result { + t.Error("should generate when last report was > 7 days ago") + } +} + +func TestShouldGenerateMetaReport_FallbackToFileScan(t *testing.T) { + postsDir := t.TempDir() + + // Create a meta report file (no state file — tests backward compat fallback) + metaFile := postsDir + "/meta-week-13-2024-03-25.json" + if err := os.WriteFile(metaFile, []byte(`{"slug":"test"}`), 0644); err != nil { + t.Fatal(err) + } + // Set its mod time to 8 days ago + oldTime := time.Now().UTC().Add(-8 * 24 * time.Hour) + if err := os.Chtimes(metaFile, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + result := shouldGenerateMetaReport(postsDir) + if !result { + t.Error("should generate when last meta file is > 7 days old") + } +} + +func TestRecordMetaReportGenerated(t *testing.T) { + postsDir := t.TempDir() + + recordMetaReportGenerated(postsDir) + + stateFile := postsDir + "/.last-meta-report" + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("state file not created: %v", err) + } + + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))) + if err != nil { + t.Fatalf("state file contains invalid timestamp: %v", err) + } + + // Should be within the last few seconds + if time.Since(parsed) > 5*time.Second { + t.Errorf("state file timestamp too old: %v", parsed) + } +} + +func TestBuildSpotlightPrompt(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "TopBot", Rating: 1800, MatchesPlayed: 50, MatchesWon: 35, Archetype: "swarm"}, + {ID: "bot2", Name: "SecondBot", Rating: 1700, MatchesPlayed: 40, MatchesWon: 20, Archetype: "hunter"}, + }, + Matches: []MatchData{ + {ID: "m1", PlayedAt: time.Date(2024, 3, 28, 12, 0, 0, 0, time.UTC)}, + }, + } + + movers := []eloMover{ + {BotName: "RisingBot", OldRating: 1200, NewRating: 1450, Delta: 250, Archetype: "gatherer", MatchesWon: 8, MatchesLost: 2}, + } + strats := []strategyCount{ + {Archetype: "swarm", Count: 10, AvgRating: 1600, InTop20: 5}, + } + bestMatch := ¬ableMatch{ + MatchID: "m_best", + Description: "TopBot vs SecondBot", + Score: "3-2", + TurnCount: 287, + } + + rivalries := []RivalryData{ + {BotAID: "bot1", BotBID: "bot2", BotAWins: 5, BotBWins: 4, TotalMatches: 9}, + } + prompt := buildSpotlightPrompt(data, movers, strats, bestMatch, nil, data.Bots[:2], rivalries) + + if !strings.Contains(prompt, "Counter-Strategy Spotlight") { + t.Error("prompt should mention Counter-Strategy Spotlight") + } + if !strings.Contains(prompt, "TopBot vs SecondBot") { + t.Error("prompt should contain rivalry matchup") + } + if !strings.Contains(prompt, "TopBot") { + t.Error("prompt should contain top bot name") + } + if !strings.Contains(prompt, "RisingBot") { + t.Error("prompt should contain ELO mover name") + } + if !strings.Contains(prompt, "swarm") { + t.Error("prompt should contain strategy archetype") + } + if !strings.Contains(prompt, "m_best") { + t.Error("prompt should reference best match") + } +} + +func TestBuildEvolutionDeepDivePrompt(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "evo1", Name: "evo-go-g31", Rating: 1580, Evolved: true}, + }, + TopPredictors: []PredictorStats{ + {PredictorID: "p1", Correct: 15, Incorrect: 3, BestStreak: 10}, + }, + } + + evoHighlights := []evolutionHighlight{ + {BotName: "evo-go-g31", Rating: 1580, Island: "go", Generation: 31, WeekMatches: 10, WeekWins: 7, Archetype: "hybrid"}, + } + rivalries := []RivalryData{ + {BotAID: "evo1", BotBID: "bot2", BotAWins: 5, BotBWins: 4, TotalMatches: 9}, + } + + prompt := buildEvolutionDeepDivePrompt(data, evoHighlights, rivalries, data.TopPredictors, nil) + + if !strings.Contains(prompt, "Evolution Deep Dive") { + t.Error("prompt should mention Evolution Deep Dive") + } + if !strings.Contains(prompt, "evo-go-g31") { + t.Error("prompt should contain evolved bot name") + } + if !strings.Contains(prompt, "go") { + t.Error("prompt should contain island name") + } +} + +func TestSpliceLLMContent(t *testing.T) { + template := `# Week 13 Meta Report + +## Top 5 Leaderboard + +| Rank | Bot | Rating | +|------|-----|--------| +| 1 | Bot1 | 1800 | + +## Evolution Highlights + +No evolved bots active this week. + +## Looking Ahead + +The meta continues to evolve.` + + result := spliceLLMContent(template, "Swarm tactics are rising.", "evo-go-g31 shows promise.") + + if !strings.Contains(result, "## Counter-Strategy Spotlight") { + t.Error("should contain Counter-Strategy Spotlight section") + } + if !strings.Contains(result, "Swarm tactics are rising.") { + t.Error("should contain spotlight content") + } + if !strings.Contains(result, "### Evolution Deep Dive") { + t.Error("should contain Evolution Deep Dive section") + } + if !strings.Contains(result, "evo-go-g31 shows promise.") { + t.Error("should contain evolution narrative") + } + // Verify ordering: spotlight before Evolution Highlights, deep dive before Looking Ahead + spotlightIdx := strings.Index(result, "## Counter-Strategy Spotlight") + evoIdx := strings.Index(result, "## Evolution Highlights") + deepDiveIdx := strings.Index(result, "### Evolution Deep Dive") + lookingAheadIdx := strings.Index(result, "## Looking Ahead") + + if spotlightIdx >= evoIdx { + t.Error("Counter-Strategy Spotlight should appear before Evolution Highlights") + } + if deepDiveIdx >= lookingAheadIdx { + t.Error("Evolution Deep Dive should appear before Looking Ahead") + } +} + +func TestSpliceLLMContent_SpotlightOnly(t *testing.T) { + template := `# Report + +## Looking Ahead + +The end.` + + result := spliceLLMContent(template, "Analysis text.", "") + + if !strings.Contains(result, "## Counter-Strategy Spotlight") { + t.Error("should contain spotlight section") + } + if strings.Contains(result, "### Evolution Deep Dive") { + t.Error("should NOT contain deep dive when evoNarrative is empty") + } +} + +func TestSpliceLLMContent_NoInsertionPoints(t *testing.T) { + template := "# Simple Report\n\nSome content." + + result := spliceLLMContent(template, "Extra analysis.", "Evo details.") + + if !strings.Contains(result, "## Counter-Strategy Spotlight") { + t.Error("should append spotlight when no insertion point found") + } + if !strings.Contains(result, "### Evolution Deep Dive") { + t.Error("should append deep dive when no insertion point found") + } +} + +func TestExtractFirstSentence(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Swarm tactics dominate the meta. Other bots struggle.", "Swarm tactics dominate the meta."}, + {"Short.", "Short."}, + {"No sentence end", "No sentence end"}, + {"Multiple? Yes! Indeed.", "Multiple?"}, + } + + for _, tc := range tests { + result := extractFirstSentence(tc.input) + if result != tc.expected { + t.Errorf("extractFirstSentence(%q) = %q, want %q", tc.input, result, tc.expected) + } + } +} + +func TestCountWeeklyMatches(t *testing.T) { + now := time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC) + data := &IndexData{ + GeneratedAt: now, + Matches: []MatchData{ + {ID: "m1", PlayedAt: now.Add(-1 * 24 * time.Hour)}, + {ID: "m2", PlayedAt: now.Add(-3 * 24 * time.Hour)}, + {ID: "m3", PlayedAt: now.Add(-10 * 24 * time.Hour)}, // outside week + {ID: "m4", PlayedAt: now.Add(-5 * 24 * time.Hour)}, + }, + } + + count := countWeeklyMatches(data) + if count != 3 { + t.Errorf("countWeeklyMatches: got %d, want 3", count) + } +} + +func TestNonEmpty(t *testing.T) { + if nonEmpty("", "fallback") != "fallback" { + t.Error("empty string should return fallback") + } + if nonEmpty("value", "fallback") != "value" { + t.Error("non-empty string should return itself") + } +}