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"` 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 type BlogIndex struct { UpdatedAt string `json:"updated_at"` Posts []BlogEntry `json:"posts"` } // BlogEntry is a lightweight entry for the blog index type BlogEntry struct { 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"` } // WeeklyChronicle represents the weekly aggregated chronicle file // per plan §15.5 - written to data/blog/chronicles-YYYY-WW.json type WeeklyChronicle struct { Year int `json:"year"` WeekNumber int `json:"week_number"` GeneratedAt string `json:"generated_at"` SeasonName string `json:"season_name"` StoryArcs []StoryArc `json:"story_arcs"` Narrative string `json:"narrative"` MatchCount int `json:"match_count"` BotCount int `json:"bot_count"` TopBotName string `json:"top_bot_name"` TopBotRating float64 `json:"top_bot_rating"` } // 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") if err := os.MkdirAll(postsDir, 0755); err != nil { return fmt.Errorf("create blog dirs: %w", err) } posts := make([]BlogPost, 0) // 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 chronicles := generateLLMChronicles(context.Background(), data, llmClient) posts = append(posts, chronicles...) // Generate weekly aggregated chronicles file if err := generateWeeklyChronicleFile(context.Background(), data, llmClient, blogDir); err != nil { slog.Error("Failed to generate weekly chronicles file", "error", err) // Non-fatal - continue with rest of build } // Write individual post files entries := make([]BlogEntry, 0, len(posts)) for _, post := range posts { postPath := filepath.Join(postsDir, post.Slug+".json") if err := writeJSON(postPath, post); err != nil { return fmt.Errorf("write post %s: %w", post.Slug, err) } entries = append(entries, BlogEntry{ Slug: post.Slug, Title: post.Title, PublishedAt: post.PublishedAt, Date: post.Date, Type: post.Type, Summary: post.Summary, Tags: post.Tags, }) } // Write blog index index := BlogIndex{ UpdatedAt: data.GeneratedAt.Format(time.RFC3339), Posts: entries, } return writeJSON(filepath.Join(blogDir, "index.json"), index) } // 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") // Gather all data sections topBots := getTopBots(data, 5) 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 ## Overview This week's competitive landscape analysis covers %d active bots across %d completed matches. ## Top 5 Leaderboard | Rank | Bot | Rating | Win Rate | |------|-----|--------|----------| %s ## 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 ## Rising Stars %s ## Falling Behind %s ## Notable Upsets %s ## Top Rivalries %s ## Evolution Highlights %s ## Prediction Standings %s ## Season Progress %s ## Looking Ahead %s --- *Generated automatically by AI Code Battle index builder.* `, weekNum, seasonName, len(data.Bots), len(data.Matches), formatLeaderboardTable(topBots), 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), 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. // Per plan §15.1, the prompt uses sports-journalism framing with structured match context // including rivalry dynamics, ELO deltas, critical moments, and season standings. func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyCount, bestMatch *notableMatch, evoHighlights []evolutionHighlight, topBots []BotData, rivalries []RivalryData) string { var sb strings.Builder // §15.1 instruction: sports-journalism prompt with structured contextual match data sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for the weekly meta report. ") sb.WriteString("You are a sports journalist covering an emergent bot league. ") sb.WriteString("Identify under-represented archetypes that could exploit weaknesses in the dominant meta. ") sb.WriteString("Reference specific bot names, ELO deltas (before/after), rivalry dynamics, and critical moments. ") sb.WriteString("Be dramatic but factual. Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n") // Season standings context with championship positioning seasonName := getCurrentSeasonName(data) sb.WriteString(fmt.Sprintf("Season: %s\n", seasonName)) sb.WriteString(fmt.Sprintf("Active bots: %d, Matches this week: %d\n", len(data.Bots), countWeeklyMatches(data))) sb.WriteString(formatSeasonChampionshipContext(data)) sb.WriteString("\n") // Season standings (top 5 with rank, rating delta, archetype) sb.WriteString("Season standings (top 5):\n") for i, bot := range topBots { if i >= 5 { break } winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100 delta := computeRatingDelta(bot.ID, data) deltaStr := "" if delta != 0 { deltaStr = fmt.Sprintf(", weekly %+0.f", delta) } sb.WriteString(fmt.Sprintf(" #%d %s (ELO %d%s, %.0f%% win rate, archetype: %s)\n", i+1, bot.Name, int(bot.Rating), deltaStr, winRate, nonEmpty(bot.Archetype, "unclassified"))) } // ELO movers with before/after deltas (§15.1 spec) sb.WriteString("\nTop 5 ELO movers this week:\n") for _, m := range movers { dir := "climbed" if m.Delta < 0 { dir = "dropped" } sb.WriteString(fmt.Sprintf(" %s %s %.0f points (ELO %.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 ELO %.0f, %d in top 20)\n", s.Archetype, s.Count, s.AvgRating, s.InTop20)) } // Matchup matrix: archetype-vs-archetype win/loss data (§15.1 head-to-head stats) matchups := calculateMatchupMatrix(data) if len(matchups) > 0 { sb.WriteString("\nHead-to-head matchup 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 := "stable" if t.Shift > 2 { arrow = "rising" } else if t.Shift < -2 { arrow = "declining" } sb.WriteString(fmt.Sprintf(" %s: %.1f%% of top 20 (was %.1f%%, %s %+.1fpp), avg ELO %.0f\n", t.Archetype, t.ThisWeekPct, t.LastWeekPct, arrow, t.Shift, t.AvgRating)) } } // Most-watched match with critical moments context (§13.2) if bestMatch != nil { sb.WriteString(fmt.Sprintf("\nMatch of the week: %s — score %s in %d turns [match %s]\n", bestMatch.Description, bestMatch.Score, bestMatch.TurnCount, bestMatch.MatchID)) // Include pre-match ELO and §13.2 critical moment summary for participants for _, m := range data.Matches { if m.ID == bestMatch.MatchID && len(m.Participants) >= 2 { for _, p := range m.Participants { sb.WriteString(fmt.Sprintf(" %s: pre-match ELO %.0f\n", getBotName(p.BotID, data), p.PreMatchRating)) } // §13.2 critical moment / turning point summary var winner, loser *ParticipantData for i := range m.Participants { if m.Participants[i].Won { winner = &m.Participants[i] } else { loser = &m.Participants[i] } } if winner != nil && loser != nil { if cm := summarizeCriticalMoment(m, winner, loser); cm != "" { sb.WriteString(fmt.Sprintf(" Turning point: %s\n", cm)) } } break } } } // Rivalry context with ELO deltas and head-to-head records (§15.1) if len(rivalries) > 0 { sb.WriteString("\nActive rivalries (head-to-head):\n") for i, r := range rivalries { if i >= 5 { break } botAName := r.BotAID botBName := r.BotBID var botARating, botBRating float64 var botADelta, botBDelta float64 for _, b := range data.Bots { if b.ID == r.BotAID { botAName = b.Name botARating = b.Rating botADelta = computeRatingDelta(b.ID, data) } if b.ID == r.BotBID { botBName = b.Name botBRating = b.Rating botBDelta = computeRatingDelta(b.ID, data) } } sb.WriteString(fmt.Sprintf(" %s (ELO %.0f, weekly %+0.f) vs %s (ELO %.0f, weekly %+0.f): %d-%d over %d matches\n", botAName, botARating, botADelta, botBName, botBRating, botBDelta, r.BotAWins, r.BotBWins, r.TotalMatches)) } } return sb.String() } // buildEvolutionDeepDivePrompt creates the LLM prompt for the Evolution Deep Dive section. // Per plan §15.1, includes rivalry context, ELO trajectory, lineage data, and season standings. 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 for the weekly meta report. ") sb.WriteString("You are a sports journalist covering the AI evolution pipeline in AI Code Battle. ") sb.WriteString("Highlight the most successful evolved bots, their lineage, strategic innovations, and ELO trajectory. ") sb.WriteString("Reference specific bot names, ELO before/after, lineage details, and rivalry context. Do not use emojis.\n\n") sb.WriteString(fmt.Sprintf("Season: %s\n\n", getCurrentSeasonName(data))) // Evolved bot profiles with ELO trajectory sb.WriteString("Evolved bot performance this week:\n") for _, e := range evoHighlights { winRate := 0.0 if e.WeekMatches > 0 { winRate = float64(e.WeekWins) / float64(e.WeekMatches) * 100 } rank := getBotRank(e.BotID, data) rankStr := "" if rank > 0 { rankStr = fmt.Sprintf(", ranked #%d", rank) } sb.WriteString(fmt.Sprintf(" %s: ELO %.0f%s, island=%s, gen=%d, weekly W%d/L%d (%.0f%% win rate), archetype=%s\n", e.BotName, e.Rating, rankStr, e.Island, e.Generation, e.WeekWins, e.WeekMatches-e.WeekWins, winRate, nonEmpty(e.Archetype, "evolved"))) // Include lineage if available bot := findBotByID(e.BotID, data) if bot != nil && len(bot.ParentIDs) > 0 { sb.WriteString(fmt.Sprintf(" Lineage: parents %s\n", strings.Join(bot.ParentIDs, ", "))) } } // 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: %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 ELO: %.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 island — %s (%s)\n", act.Time, act.Candidate, act.Island, act.Result, act.Reason)) count++ } } } // Active rivalries involving evolved bots with ELO context if len(rivalries) > 0 { sb.WriteString("\nRivalries involving evolved bots:\n") for i, r := range rivalries { if i >= 3 { break } botAName := getBotName(r.BotAID, data) botBName := getBotName(r.BotBID, data) var botARating, botBRating float64 for _, b := range data.Bots { if b.ID == r.BotAID { botARating = b.Rating } if b.ID == r.BotBID { botBRating = b.Rating } } sb.WriteString(fmt.Sprintf(" %s (ELO %.0f) vs %s (ELO %.0f): %d-%d over %d matches\n", botAName, botARating, botBName, botBRating, 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. // Per plan §15.1, includes ELO trends, rivalry dynamics, season championship positioning. 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 the weekly meta report. ") sb.WriteString("You are a sports journalist covering AI Code Battle. ") sb.WriteString("Predict what strategies will rise or fall next week based on ELO trends, matchup data, rivalry dynamics, and the evolution pipeline. ") sb.WriteString("Reference specific bots, ELO before/after, and rivalry stakes. Do not use emojis.\n\n") sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data))) // Season championship positioning for i := range data.Seasons { if data.Seasons[i].Status == "active" { s := data.Seasons[i] daysElapsed := data.GeneratedAt.Sub(s.StartsAt).Hours() / 24 weekNum := int(daysElapsed/7) + 1 if weekNum > 4 { weekNum = 4 } sb.WriteString(fmt.Sprintf("Season progress: Week %d of 4", weekNum)) if weekNum >= 3 { sb.WriteString(" — championship bracket approaching") } sb.WriteString("\n") break } } if len(movers) > 0 { sb.WriteString("\nTop ELO movers (with before/after):\n") for _, m := range movers { dir := "surged" if m.Delta < 0 { dir = "dropped" } sb.WriteString(fmt.Sprintf(" %s %s %.0f points (ELO %.0f → %.0f) [%s]\n", m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, nonEmpty(m.Archetype, "unclassified"))) } } if len(trends) > 0 { sb.WriteString("\nStrategy trends:\n") for _, t := range trends { sb.WriteString(fmt.Sprintf(" %s: %.1f%% of top 20 (shift %+.1fpp)\n", t.Archetype, t.ThisWeekPct, t.Shift)) } } if len(matchups) > 0 { sb.WriteString("\nKey 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, %.1f%% promotion rate, highest evolved ELO %.0f\n", liveData.Totals.GenerationsTotal, liveData.Totals.PromotionRate7d, liveData.Totals.HighestEvolved)) } 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) if len(data.Bots) > 0 { rising := findRisingBots(data) if len(rising) > 0 { chronicles = append(chronicles, generateRiseChronicle(rising[0], data)) } } upsets := findRecentUpsets(data) if len(upsets) > 0 { chronicles = append(chronicles, generateUpsetChronicle(upsets[0], data)) } rivalries := findTopRivalries(data) if len(rivalries) > 0 { chronicles = append(chronicles, generateRivalryChronicle(rivalries[0], data)) } return chronicles } // generateLLMChronicles creates chronicles using the narrative engine and LLM func generateLLMChronicles(ctx context.Context, data *IndexData, llmClient *LLMClient) []BlogPost { chronicles := make([]BlogPost, 0) arcs := detectStoryArcs(data) maxChronicles := 5 if len(arcs) < maxChronicles { maxChronicles = len(arcs) } for i := 0; i < maxChronicles; i++ { arc := arcs[i] var post BlogPost var err error if llmClient != nil && llmClient.baseURL != "" { post, err = generateLLMChronicle(ctx, arc, data, llmClient) if err != nil { post = generateTemplateChronicle(arc, data) } } else { post = generateTemplateChronicle(arc, data) } chronicles = append(chronicles, post) } return chronicles } // generateLLMChronicle creates a chronicle using LLM narrative generation func generateLLMChronicle(ctx context.Context, arc StoryArc, data *IndexData, llmClient *LLMClient) (BlogPost, error) { seasonName := getCurrentSeasonName(data) seasonTheme := getCurrentSeasonTheme(data) req := NarrativeRequest{ ArcType: arc.Type, BotName: arc.BotName, BotID: arc.BotID, SeasonName: seasonName, SeasonTheme: seasonTheme, RatingStart: arc.RatingStart, RatingEnd: arc.RatingEnd, KeyMatches: arc.KeyMatches, Archetype: arc.Archetype, Origin: arc.Origin, ParentIDs: arc.ParentIDs, Generation: arc.Generation, BotBName: arc.BotBName, BotRank: getBotRank(arc.BotID, data), CommunityHint: arc.CommunityHint, HeadToHead: buildHeadToHeadFromArc(arc, data), } if arc.Type == ArcRivalry { req.BotAWins = arc.BotAWins req.BotBWins = arc.BotBWins req.TotalMatches = arc.TotalMatches } headline, narrative, err := llmClient.GenerateNarrative(ctx, req) if err != nil { return BlogPost{}, err } slug := fmt.Sprintf("%s-%s-%s", arc.Type, arc.BotID, formatSlugDate(data.GeneratedAt)) if arc.Type == ArcRivalry { slug = fmt.Sprintf("rivalry-%s-%s", arc.BotID[:8], arc.BotBID[:8]) } else if arc.Type == ArcUpset { slug = fmt.Sprintf("upset-%s-%s", arc.MatchID[:8], formatSlugDate(data.GeneratedAt)) } tags := []string{string(arc.Type)} if arc.BotID != "" { tags = append(tags, arc.BotID) } if arc.BotBID != "" { tags = append(tags, arc.BotBID) } dateStr := data.GeneratedAt.Format("2006-01-02") content := "# " + headline + "\n\n" + narrative return BlogPost{ Slug: slug, Title: headline, PublishedAt: dateStr, Date: dateStr, Type: "chronicle", BodyMarkdown: content, ContentMd: content, Summary: truncateSummary(narrative, 150), Tags: tags, }, nil } // generateTemplateChronicle creates a chronicle using templates (fallback) func generateTemplateChronicle(arc StoryArc, data *IndexData) BlogPost { switch arc.Type { case ArcRise: bot := findBotByID(arc.BotID, data) if bot != nil { return generateRiseChronicle(*bot, data) } case ArcUpset: upset := UpsetData{ MatchID: arc.MatchID, WinnerID: arc.BotID, LoserID: arc.BotBID, WinnerScore: arc.RatingStart, LoserScore: arc.RatingEnd, } return generateUpsetChronicle(upset, data) case ArcRivalry: rivalry := RivalryData{ BotAID: arc.BotID, BotBID: arc.BotBID, BotAWins: arc.BotAWins, BotBWins: arc.BotBWins, TotalMatches: arc.TotalMatches, } return generateRivalryChronicle(rivalry, data) } 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), 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}, } } // ─── Template chronicles ────────────────────────────────────────────────────── 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 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. ## Profile - **Rating:** %d%s%s%s %s ## What's Driving the Climb 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(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), 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}, } } func generateUpsetChronicle(upset UpsetData, data *IndexData) BlogPost { winnerName := getBotName(upset.WinnerID, data) loserName := getBotName(upset.LoserID, data) dateStr := data.GeneratedAt.Format("2006-01-02") // 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" } 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" } content := fmt.Sprintf(`# Upset: %s Defeats %s %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 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, 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), 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}, } } 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 %d matches. %d-%d. The series between %s and %s is %s. ## Head-to-Head - **%s:** %d wins - **%s:** %d wins - **Total Matches:** %d %s %s ## The Dynamic %s --- *Auto-generated chronicle from rivalry analysis.* `, 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), 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 WinnerID string LoserID string WinnerScore int LoserScore int TurnCount int } // RivalryData represents a rivalry between two bots type RivalryData struct { BotAID string BotBID string BotAWins int BotBWins int TotalMatches int } // ─── Formatting helpers ──────────────────────────────────────────────────────── func getWeekNumber(t time.Time) int { _, week := t.ISOWeek() return week } func getCurrentSeasonName(data *IndexData) string { for _, s := range data.Seasons { if s.StartsAt.Before(data.GeneratedAt) { if s.EndsAt.IsZero() || s.EndsAt.After(data.GeneratedAt) { return s.Name } } } return "Season 1" } func getCurrentSeasonTheme(data *IndexData) string { for _, s := range data.Seasons { if s.StartsAt.Before(data.GeneratedAt) { if s.EndsAt.IsZero() || s.EndsAt.After(data.GeneratedAt) { return s.Theme } } } return "" } func getTopBots(data *IndexData, count int) []BotData { if len(data.Bots) < count { return data.Bots } return data.Bots[:count] } func calculateStrategyDistribution(data *IndexData) map[string]int { dist := make(map[string]int) for _, bot := range data.Bots { if bot.Evolved { dist["evolved"]++ } else { dist["human-authored"]++ } } return dist } func findRisingBots(data *IndexData) []BotData { rising := make([]BotData, 0) for _, bot := range data.Bots { if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) > 0.6 { rising = append(rising, bot) } } if len(rising) > 3 { return rising[:3] } return rising } func findFallingBots(data *IndexData) []BotData { falling := make([]BotData, 0) for _, bot := range data.Bots { if bot.MatchesPlayed >= 5 && calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) < 0.4 { falling = append(falling, bot) } } if len(falling) > 3 { return falling[:3] } return falling } func findRecentUpsets(data *IndexData) []UpsetData { upsets := make([]UpsetData, 0) for _, m := range data.Matches { if len(m.Participants) < 2 { continue } for i, p1 := range m.Participants { for _, p2 := range m.Participants[i+1:] { if p1.Won && p2.Score > p1.Score { upsets = append(upsets, UpsetData{ MatchID: m.ID, WinnerID: p1.BotID, LoserID: p2.BotID, WinnerScore: p1.Score, LoserScore: p2.Score, TurnCount: m.TurnCount, }) } } } } if len(upsets) > 3 { return upsets[:3] } return upsets } func findTopRivalries(data *IndexData) []RivalryData { pairCounts := make(map[string]*RivalryData) for _, m := range data.Matches { if len(m.Participants) < 2 { continue } for i, p1 := range m.Participants { for _, p2 := range m.Participants[i+1:] { key := fmt.Sprintf("%s-%s", minStr(p1.BotID, p2.BotID), maxStr(p1.BotID, p2.BotID)) if pairCounts[key] == nil { pairCounts[key] = &RivalryData{ BotAID: minStr(p1.BotID, p2.BotID), BotBID: maxStr(p1.BotID, p2.BotID), } } pairCounts[key].TotalMatches++ if p1.Won { if p1.BotID == pairCounts[key].BotAID { pairCounts[key].BotAWins++ } else { pairCounts[key].BotBWins++ } } else if p2.Won { if p2.BotID == pairCounts[key].BotAID { pairCounts[key].BotAWins++ } else { pairCounts[key].BotBWins++ } } } } } rivalries := make([]RivalryData, 0) for _, r := range pairCounts { if r.TotalMatches >= 3 { rivalries = append(rivalries, *r) } } // 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] } return rivalries } func calculateWinRate(played, won int) float64 { if played == 0 { return 0 } return float64(won) / float64(played) } func getBotName(botID string, data *IndexData) string { for _, bot := range data.Bots { if bot.ID == botID { return bot.Name } } 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 { 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) ──────────────────────────────── // formatSeasonChampionshipContext returns a human-readable summary of season progress // and championship bracket positioning for LLM prompts. func formatSeasonChampionshipContext(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 "" } daysElapsed := data.GeneratedAt.Sub(active.StartsAt).Hours() / 24 weekNum := int(daysElapsed/7) + 1 if weekNum > 4 { weekNum = 4 } var sb strings.Builder theme := "" if active.Theme != "" { theme = fmt.Sprintf(" (%s)", active.Theme) } sb.WriteString(fmt.Sprintf("Season progress: Week %d of 4%s", weekNum, theme)) if weekNum >= 3 { sb.WriteString(". Championship bracket approaching — top 8 qualify") } else { sb.WriteString(". Early season — seeding phase") } // Championship seed line topBots := getTopBots(data, 3) if len(topBots) > 0 { sb.WriteString(". Current championship seeds: ") names := make([]string, 0, len(topBots)) for i, bot := range topBots { names = append(names, fmt.Sprintf("#%d %s (ELO %d)", i+1, bot.Name, int(bot.Rating))) } sb.WriteString(strings.Join(names, ", ")) } return sb.String() } 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 { result := "" for i, bot := range bots { winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100 result += fmt.Sprintf("| %d | %s | %d | %.1f%% |\n", i+1, bot.Name, int(bot.Rating), winRate) } 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 { result += fmt.Sprintf("- **%s:** %d bots\n", strategy, count) } return result } func formatBotList(bots []BotData) string { if len(bots) == 0 { return "No significant movement this week." } result := "" for _, bot := range bots { winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100 result += fmt.Sprintf("- **%s** (Rating: %d, Win Rate: %.1f%%)\n", bot.Name, int(bot.Rating), winRate) } return result } func formatUpsets(upsets []UpsetData) string { if len(upsets) == 0 { return "No major upsets this week." } result := "" for _, u := range upsets { result += fmt.Sprintf("- Match %s: Close contest with score %d-%d\n", u.MatchID[:8], u.WinnerScore, u.LoserScore) } return result } func formatRivalries(rivalries []RivalryData) string { if len(rivalries) == 0 { return "No emerging rivalries this week." } result := "" for _, r := range rivalries { result += fmt.Sprintf("- %s vs %s: %d-%d record\n", r.BotAID[:8], r.BotBID[:8], r.BotAWins, r.BotBWins) } return result } func formatEvolutionHighlights(highlights []evolutionHighlight) string { if len(highlights) == 0 { return "No evolved bots active this week." } 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 formatEvolutionTrend(highlights []evolutionHighlight) string { if len(highlights) == 0 { return "not yet represented in" } 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() } // ─── Weekly Chronicles File Generation ──────────────────────────────────────────── // generateWeeklyChronicleFile creates the weekly aggregated chronicles file // at data/blog/chronicles-YYYY-WW.json per plan §15.5. func generateWeeklyChronicleFile(ctx context.Context, data *IndexData, llmClient *LLMClient, blogDir string) error { now := data.GeneratedAt year, weekNum := now.ISOWeek() // Detect story arcs for the week arcs := detectStoryArcs(data) // Gather context data topMovers := findTopELOMovers(data, 5) strategies := calculateDominantStrategies(data) bestMatch := findMostWatchedMatch(data) dominantStrat := "" if len(strategies) > 0 { dominantStrat = strategies[0].Archetype } topBotName := "" topBotRating := 0.0 if len(data.Bots) > 0 { topBotName = data.Bots[0].Name topBotRating = data.Bots[0].Rating } // Count matches this week weekAgo := now.AddDate(0, 0, -7) matchCount := 0 for _, m := range data.Matches { if m.PlayedAt.After(weekAgo) { matchCount++ } } // Build the request req := WeeklyChroniclesRequest{ Year: year, WeekNumber: weekNum, SeasonName: getCurrentSeasonName(data), StoryArcs: arcs, MatchCount: matchCount, BotCount: len(data.Bots), TopBotName: topBotName, TopBotRating: topBotRating, TopMovers: topMovers, DominantStrat: dominantStrat, BestMatch: bestMatch, } // Generate narrative (with LLM if available, otherwise use template) var narrative string if llmClient != nil && llmClient.baseURL != "" { generated, err := llmClient.GenerateWeeklyChronicles(ctx, req) if err != nil { slog.Warn("LLM weekly chronicles generation failed, using template", "error", err) narrative = buildTemplateWeeklyChronicle(req) } else { narrative = generated } } else { narrative = buildTemplateWeeklyChronicle(req) } // Build the weekly chronicle struct chronicle := WeeklyChronicle{ Year: year, WeekNumber: weekNum, GeneratedAt: now.Format(time.RFC3339), SeasonName: req.SeasonName, StoryArcs: arcs, Narrative: narrative, MatchCount: matchCount, BotCount: len(data.Bots), TopBotName: topBotName, TopBotRating: topBotRating, } // Write to data/blog/chronicles-YYYY-WW.json filename := fmt.Sprintf("chronicles-%d-%02d.json", year, weekNum) chroniclePath := filepath.Join(blogDir, filename) if err := writeJSON(chroniclePath, chronicle); err != nil { return fmt.Errorf("write weekly chronicle file: %w", err) } slog.Info("Generated weekly chronicles file", "filename", filename, "year", year, "week", weekNum, "story_arcs", len(arcs), "narrative_length", len(narrative)) return nil } // buildTemplateWeeklyChronicle creates a template-based weekly narrative // when LLM generation is unavailable. func buildTemplateWeeklyChronicle(req WeeklyChroniclesRequest) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("# Week %d Chronicles\n\n", req.WeekNumber)) sb.WriteString(fmt.Sprintf("## %s\n\n", req.SeasonName)) // Lead paragraph if req.TopBotName != "" { sb.WriteString(fmt.Sprintf("**%s** sits atop the leaderboard at %.0f ELO. ", req.TopBotName, req.TopBotRating)) } sb.WriteString(fmt.Sprintf("This week saw %d matches across %d active bots.\n\n", req.MatchCount, req.BotCount)) // Top movers section if len(req.TopMovers) > 0 { sb.WriteString("## ELO Movement\n\n") for _, m := range req.TopMovers { dir := "rose" if m.Delta < 0 { dir = "fell" } sb.WriteString(fmt.Sprintf("- **%s** %s %.0f points (%.0f → %.0f)", m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating)) if m.Archetype != "" { sb.WriteString(fmt.Sprintf(" [%s]", m.Archetype)) } sb.WriteString("\n") } sb.WriteString("\n") } // Story arcs by type riseArcs := filterArcsByType(req.StoryArcs, ArcRise) fallArcs := filterArcsByType(req.StoryArcs, ArcFall) rivalryArcs := filterArcsByType(req.StoryArcs, ArcRivalry) upsetArcs := filterArcsByType(req.StoryArcs, ArcUpset) evoArcs := filterArcsByType(req.StoryArcs, ArcEvolutionMilestone) if len(riseArcs) > 0 { sb.WriteString("## Rising Stars\n\n") for _, arc := range riseArcs { delta := arc.RatingEnd - arc.RatingStart sb.WriteString(fmt.Sprintf("**%s** climbed %d points this week, moving from %d to %d ELO", arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) if arc.Archetype != "" { sb.WriteString(fmt.Sprintf(" on %s strategy", arc.Archetype)) } sb.WriteString(".\n") } sb.WriteString("\n") } if len(fallArcs) > 0 { sb.WriteString("## Falling Behind\n\n") for _, arc := range fallArcs { delta := arc.RatingStart - arc.RatingEnd sb.WriteString(fmt.Sprintf("**%s** dropped %d points (%d → %d ELO).\n", arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) } sb.WriteString("\n") } if len(rivalryArcs) > 0 { sb.WriteString("## Intensifying Rivalries\n\n") for _, arc := range rivalryArcs { sb.WriteString(fmt.Sprintf("**%s vs %s**: %d-%d record over %d matches.\n", arc.BotName, arc.BotBName, arc.BotAWins, arc.BotBWins, arc.TotalMatches)) } sb.WriteString("\n") } if len(upsetArcs) > 0 { sb.WriteString("## Upsets of the Week\n\n") for _, arc := range upsetArcs { gap := arc.RatingEnd - arc.RatingStart sb.WriteString(fmt.Sprintf("**%s** upset **%s** despite a %d-point ELO disadvantage.\n", arc.BotName, arc.BotBName, gap)) } sb.WriteString("\n") } if len(evoArcs) > 0 { sb.WriteString("## Evolution Milestones\n\n") for _, arc := range evoArcs { sb.WriteString(fmt.Sprintf("**%s** (generation %d", arc.BotName, arc.Generation)) if arc.Archetype != "" { sb.WriteString(fmt.Sprintf(", %s", arc.Archetype)) } sb.WriteString(fmt.Sprintf(") reached %.0f ELO.\n", float64(arc.RatingEnd))) } sb.WriteString("\n") } if req.BestMatch != nil { sb.WriteString("## Match of the Week\n\n") sb.WriteString(fmt.Sprintf("**%s** — final score %s in %d turns.\n", req.BestMatch.Description, req.BestMatch.Score, req.BestMatch.TurnCount)) } sb.WriteString("\n---\n\n*Generated automatically by AI Code Battle.*") return sb.String() } // WeeklyChroniclesRequest contains context for generating a weekly chronicle type WeeklyChroniclesRequest struct { Year int WeekNumber int SeasonName string StoryArcs []StoryArc MatchCount int BotCount int TopBotName string TopBotRating float64 TopMovers []eloMover DominantStrat string BestMatch *notableMatch }