ai-code-battle/cmd/acb-index-builder/narrative.go
jedarden ea04f4debb style: apply gofmt alignment fixes across codebase
Tab/space alignment consistency from running gofmt on all packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:40:33 -04:00

1197 lines
37 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// StoryArcType represents the type of narrative arc
type StoryArcType string
const (
ArcRise StoryArcType = "rise"
ArcFall StoryArcType = "fall"
ArcRivalry StoryArcType = "rivalry"
ArcUpset StoryArcType = "upset"
ArcEvolutionMilestone StoryArcType = "evolution"
ArcComeback StoryArcType = "comeback"
ArcSeasonRecap StoryArcType = "season-recap"
)
// StoryArc represents a detected narrative arc
type StoryArc struct {
Type StoryArcType `json:"type"`
BotID string `json:"bot_id,omitempty"`
BotName string `json:"bot_name,omitempty"`
BotBID string `json:"bot_b_id,omitempty"`
BotBName string `json:"bot_b_name,omitempty"`
RatingStart int `json:"rating_start,omitempty"`
RatingEnd int `json:"rating_end,omitempty"`
MatchID string `json:"match_id,omitempty"`
SeasonName string `json:"season_name,omitempty"`
// Context for LLM prompt
KeyMatches []KeyMatch `json:"key_matches,omitempty"`
Archetype string `json:"archetype,omitempty"`
Origin string `json:"origin,omitempty"`
ParentIDs []string `json:"parent_ids,omitempty"`
Generation int `json:"generation,omitempty"`
CommunityHint string `json:"community_hint,omitempty"`
// Rivalry-specific fields
BotAWins int `json:"bot_a_wins,omitempty"`
BotBWins int `json:"bot_b_wins,omitempty"`
TotalMatches int `json:"total_matches,omitempty"`
}
// KeyMatch represents a key match for narrative context
type KeyMatch struct {
MatchID string `json:"match_id"`
OpponentID string `json:"opponent_id"`
OpponentName string `json:"opponent_name"`
OpponentRating int `json:"opponent_rating"`
OpponentRank int `json:"opponent_rank,omitempty"`
MapName string `json:"map_name,omitempty"`
Score string `json:"score"`
TurnCount int `json:"turn_count"`
Won bool `json:"won"`
EndCondition string `json:"end_condition,omitempty"`
CriticalMoment string `json:"critical_moment,omitempty"` // §13.2 turning point summary
}
// HeadToHeadRecord represents the head-to-head record between two bots
type HeadToHeadRecord struct {
OpponentName string `json:"opponent_name"`
OpponentRank int `json:"opponent_rank,omitempty"`
Wins int `json:"wins"`
Losses int `json:"losses"`
TotalMatches int `json:"total_matches"`
}
// LLMClient handles narrative generation via LLM
type LLMClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewLLMClient creates a new LLM client for narrative generation
func NewLLMClient(baseURL, apiKey string) *LLMClient {
return &LLMClient{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// NarrativeRequest contains context for generating a narrative
type NarrativeRequest struct {
ArcType StoryArcType
BotName string
BotID string
SeasonName string
SeasonTheme string
RatingStart int
RatingEnd int
KeyMatches []KeyMatch
Archetype string
Origin string
ParentIDs []string
Generation int
// Enriched context per §15.5
BotRank int
CommunityHint string
HeadToHead []HeadToHeadRecord
// Rivalry-specific fields
BotBName string
BotAWins int
BotBWins int
TotalMatches int
}
// GenerateNarrative generates a 200-word sports-journalism narrative
func (c *LLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest) (headline, narrative string, err error) {
prompt := buildNarrativePrompt(req)
response, err := c.chatCompletion(ctx, prompt)
if err != nil {
return "", "", fmt.Errorf("llm request: %w", err)
}
// Parse response - first line is headline, rest is narrative
lines := strings.Split(strings.TrimSpace(response), "\n")
if len(lines) < 2 {
return "AI Code Battle Chronicle", response, nil
}
headline = strings.TrimPrefix(lines[0], "# ")
headline = strings.TrimSpace(headline)
narrative = strings.Join(lines[1:], "\n")
narrative = strings.TrimSpace(narrative)
return headline, narrative, nil
}
// buildNarrativePrompt constructs a sports-journalism prompt per plan §15.5,
// injecting rivalry context, ELO before/after, critical moments from §13.2,
// season standings, and head-to-head stats.
func buildNarrativePrompt(req NarrativeRequest) string {
var sb strings.Builder
// §15.5 instruction: sports-journalism narrative with structured contextual match data
sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. ")
sb.WriteString("You are a sports journalist covering an emergent bot league — write with the energy and specificity of esports commentary. ")
sb.WriteString("Be dramatic but factual. Reference specific matches by ID, ELO before/after deltas, rivalry context, head-to-head records, critical turning points, and season standings. ")
sb.WriteString("Weave the data into a compelling story — quote scores, cite map names, describe the strategic moments that defined the outcome. ")
sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n")
// Season and standings context
seasonLabel := req.SeasonName
if req.SeasonTheme != "" {
seasonLabel = fmt.Sprintf("%s (%s)", req.SeasonName, req.SeasonTheme)
}
switch req.ArcType {
case ArcRise:
sb.WriteString("Arc type: Rise\n")
sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
if req.BotRank > 0 {
sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank))
}
delta := req.RatingEnd - req.RatingStart
sb.WriteString(fmt.Sprintf("ELO: %d → %d (delta %+d) over 7 days\n", req.RatingStart, req.RatingEnd, delta))
if req.Archetype != "" {
sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype))
}
if req.Origin != "" {
sb.WriteString(fmt.Sprintf("Origin: %s\n", req.Origin))
}
if req.Generation > 0 && len(req.ParentIDs) > 0 {
sb.WriteString(fmt.Sprintf("Lineage: generation %d, parents: %s\n", req.Generation, strings.Join(req.ParentIDs, ", ")))
}
if req.CommunityHint != "" {
sb.WriteString(fmt.Sprintf("Community tactical hint: %s\n", req.CommunityHint))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Critical moments (turning points in the climb):\n")
for _, m := range req.KeyMatches {
outcome := "Lost to"
if m.Won {
outcome = "Beat"
}
rankStr := ""
if m.OpponentRank > 0 {
rankStr = fmt.Sprintf(", #%d", m.OpponentRank)
}
condStr := ""
if m.EndCondition != "" {
condStr = fmt.Sprintf(" [%s]", m.EndCondition)
}
sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d%s) on \"%s\" — score %s, %d turns%s. Match ID: %s\n",
outcome, m.OpponentName, m.OpponentRating, rankStr, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, condStr, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
}
if len(req.HeadToHead) > 0 {
sb.WriteString("Head-to-head records (season context):\n")
for _, h := range req.HeadToHead {
rankStr := ""
if h.OpponentRank > 0 {
rankStr = fmt.Sprintf(", ranked #%d", h.OpponentRank)
}
sb.WriteString(fmt.Sprintf(" vs %s%s: %dW-%dL (%d matches)\n", h.OpponentName, rankStr, h.Wins, h.Losses, h.TotalMatches))
}
}
case ArcFall:
sb.WriteString("Arc type: Fall\n")
sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
if req.BotRank > 0 {
sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank))
}
delta := req.RatingStart - req.RatingEnd
sb.WriteString(fmt.Sprintf("ELO: %d → %d (dropped %d points) over 7 days\n", req.RatingStart, req.RatingEnd, delta))
if req.Archetype != "" {
sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Critical losses (turning points in the decline):\n")
for _, m := range req.KeyMatches {
outcome := "Lost to"
if m.Won {
outcome = "Beat"
}
rankStr := ""
if m.OpponentRank > 0 {
rankStr = fmt.Sprintf(", #%d", m.OpponentRank)
}
condStr := ""
if m.EndCondition != "" {
condStr = fmt.Sprintf(" [%s]", m.EndCondition)
}
sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d%s) on \"%s\" — score %s, %d turns%s. Match ID: %s\n",
outcome, m.OpponentName, m.OpponentRating, rankStr, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, condStr, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
}
if len(req.HeadToHead) > 0 {
sb.WriteString("Head-to-head records (season context):\n")
for _, h := range req.HeadToHead {
rankStr := ""
if h.OpponentRank > 0 {
rankStr = fmt.Sprintf(", ranked #%d", h.OpponentRank)
}
sb.WriteString(fmt.Sprintf(" vs %s%s: %dW-%dL (%d matches)\n", h.OpponentName, rankStr, h.Wins, h.Losses, h.TotalMatches))
}
}
case ArcRivalry:
sb.WriteString("Arc type: Rivalry Intensifies\n")
sb.WriteString(fmt.Sprintf("Bot A: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Bot B: %s\n", req.BotBName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
sb.WriteString(fmt.Sprintf("Head-to-head: %d-%d over %d matches\n", req.BotAWins, req.BotBWins, req.TotalMatches))
if req.RatingStart > 0 || req.RatingEnd > 0 {
sb.WriteString(fmt.Sprintf("ELO context: %s at %d, %s at %d\n", req.BotName, req.RatingStart, req.BotBName, req.RatingEnd))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Recent encounters (critical moments):\n")
for _, m := range req.KeyMatches {
winner := req.BotBName
if m.Won {
winner = req.BotName
}
condStr := ""
if m.EndCondition != "" {
condStr = fmt.Sprintf(" [%s]", m.EndCondition)
}
sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" — score %s, %d turns, opponent ELO %d%s. Match ID: %s\n",
winner, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, m.OpponentRating, condStr, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
}
if len(req.HeadToHead) > 0 {
sb.WriteString("All-time head-to-head:\n")
for _, h := range req.HeadToHead {
sb.WriteString(fmt.Sprintf(" vs %s: %dW-%dL (%d matches)\n",
h.OpponentName, h.Wins, h.Losses, h.TotalMatches))
}
}
case ArcUpset:
sb.WriteString("Arc type: Upset of the Week\n")
sb.WriteString(fmt.Sprintf("Underdog: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Favorite: %s\n", req.BotBName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
eloDelta := req.RatingEnd - req.RatingStart
sb.WriteString(fmt.Sprintf("ELO gap: %d (underdog %d vs favorite %d)\n", eloDelta, req.RatingStart, req.RatingEnd))
if len(req.KeyMatches) > 0 {
m := req.KeyMatches[0]
sb.WriteString(fmt.Sprintf("Match: %s upset %s on \"%s\" — score %s, %d turns. Match ID: %s\n",
req.BotName, req.BotBName, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
case ArcEvolutionMilestone:
sb.WriteString("Arc type: Evolution Milestone\n")
sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
if req.BotRank > 0 {
sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank))
}
sb.WriteString(fmt.Sprintf("ELO: %d\n", req.RatingEnd))
if req.Archetype != "" {
sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype))
}
if req.Origin != "" {
sb.WriteString(fmt.Sprintf("Origin: %s\n", req.Origin))
}
if req.Generation > 0 {
sb.WriteString(fmt.Sprintf("generation %d\n", req.Generation))
}
if len(req.ParentIDs) > 0 {
sb.WriteString(fmt.Sprintf("Parents: %s\n", strings.Join(req.ParentIDs, ", ")))
}
if req.CommunityHint != "" {
sb.WriteString(fmt.Sprintf("Community tactical hint that influenced it: %s\n", req.CommunityHint))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Key matches in the breakthrough:\n")
for _, m := range req.KeyMatches {
outcome := "Lost to"
if m.Won {
outcome = "Beat"
}
sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d) — score %s, %d turns. Match ID: %s\n",
outcome, m.OpponentName, m.OpponentRating, m.Score, m.TurnCount, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
}
case ArcComeback:
sb.WriteString("Arc type: Comeback\n")
sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName))
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
if req.BotRank > 0 {
sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank))
}
sb.WriteString("Story: Climbed from bottom 25%% of leaderboard to top 25%%\n")
sb.WriteString(fmt.Sprintf("Current ELO: %d\n", req.RatingEnd))
if req.Archetype != "" {
sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Key matches in the comeback:\n")
for _, m := range req.KeyMatches {
outcome := "Lost to"
if m.Won {
outcome = "Beat"
}
rankStr := ""
if m.OpponentRank > 0 {
rankStr = fmt.Sprintf(", #%d", m.OpponentRank)
}
sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d%s) — score %s, %d turns. Match ID: %s\n",
outcome, m.OpponentName, m.OpponentRating, rankStr, m.Score, m.TurnCount, m.MatchID))
if m.CriticalMoment != "" {
sb.WriteString(fmt.Sprintf(" Turning point: %s\n", m.CriticalMoment))
}
}
}
if len(req.HeadToHead) > 0 {
sb.WriteString("Head-to-head records (season context):\n")
for _, h := range req.HeadToHead {
rankStr := ""
if h.OpponentRank > 0 {
rankStr = fmt.Sprintf(", ranked #%d", h.OpponentRank)
}
sb.WriteString(fmt.Sprintf(" vs %s%s: %dW-%dL (%d matches)\n", h.OpponentName, rankStr, h.Wins, h.Losses, h.TotalMatches))
}
}
case ArcSeasonRecap:
sb.WriteString("Arc type: Season Narrative\n")
sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel))
if req.BotName != "" {
sb.WriteString(fmt.Sprintf("Champion: %s\n", req.BotName))
}
}
return sb.String()
}
// systemPromptSportsJournalist frames the LLM as a sports journalist covering AI Code Battle.
// Per plan §15.1 and §15.5, this produces sports-journalism-style output with structured
// contextual match data including rivalry context, ELO deltas, critical moments, season stakes.
const systemPromptSportsJournalist = `You are a sports journalist covering an emergent bot league called AI Code Battle, where autonomous programs compete in grid-based strategy matches. Write with the energy and narrative instinct of esports journalism — dramatic but factual, specific but accessible.
Your coverage style:
- Reference bots by name, cite ELO ratings with before/after deltas, and describe strategic turning points the way a play-by-play commentator would.
- Weave in rivalry context, head-to-head records, season standings, and critical moments from match data.
- Describe ELO shifts the way a power rankings columnist describes team movement — "surged 200 points" not "increased."
- Use present tense. Keep paragraphs tight and punchy. Do not use emojis.
- When lineage or evolution data is provided, frame it like a scouting report — origin story, parent strategies, behavioral archetype.
- Always ground narrative in the specific match data, scores, and ratings provided — never fabricate match details.
- Keep narratives to 200 words.`
// chatCompletion sends a prompt to the LLM API and returns the completion text.
// Per §15.1/§15.5, uses systemPromptSportsJournalist to frame the output.
func (c *LLMClient) chatCompletion(ctx context.Context, prompt string) (string, error) {
reqBody := struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
}{
Model: "gpt-4o-mini",
Messages: []struct {
Role string `json:"role"`
Content string `json:"content"`
}{
{Role: "system", Content: systemPromptSportsJournalist},
{Role: "user", Content: prompt},
},
MaxTokens: 1024,
Temperature: 0.7,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", strings.NewReader(string(bodyBytes)))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("llm api returned status %d", resp.StatusCode)
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return strings.TrimSpace(chatResp.Choices[0].Message.Content), nil
}
// detectStoryArcs scans index data for active story arcs per §15.5.
func detectStoryArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
arcs = append(arcs, detectRiseArcs(data)...)
arcs = append(arcs, detectFallArcs(data)...)
arcs = append(arcs, detectRivalryArcs(data)...)
arcs = append(arcs, detectUpsetArcs(data)...)
arcs = append(arcs, detectEvolutionArcs(data)...)
arcs = append(arcs, detectComebackArcs(data)...)
return arcs
}
func detectRiseArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
for _, bot := range data.Bots {
history := getBotRatingHistory(bot.ID, data)
if len(history) < 2 {
continue
}
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
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 >= 200 {
arcs = append(arcs, StoryArc{
Type: ArcRise,
BotID: bot.ID,
BotName: bot.Name,
RatingStart: int(oldRating),
RatingEnd: int(bot.Rating),
Archetype: bot.Archetype,
Origin: buildOriginString(bot),
ParentIDs: bot.ParentIDs,
Generation: bot.Generation,
KeyMatches: extractKeyMatches(bot.ID, data),
})
}
}
return arcs
}
func detectFallArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
for _, bot := range data.Bots {
history := getBotRatingHistory(bot.ID, data)
if len(history) < 2 {
continue
}
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
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 := oldRating - bot.Rating
if delta >= 200 {
arcs = append(arcs, StoryArc{
Type: ArcFall,
BotID: bot.ID,
BotName: bot.Name,
RatingStart: int(oldRating),
RatingEnd: int(bot.Rating),
Archetype: bot.Archetype,
KeyMatches: extractKeyMatches(bot.ID, data),
})
}
}
return arcs
}
func detectRivalryArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
pairData := make(map[string]*struct {
botAID, botBID string
aWins, bWins int
total int
})
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 := minStr(p1.BotID, p2.BotID) + "-" + maxStr(p1.BotID, p2.BotID)
aID := minStr(p1.BotID, p2.BotID)
bID := maxStr(p1.BotID, p2.BotID)
if pairData[key] == nil {
pairData[key] = &struct {
botAID, botBID string
aWins, bWins int
total int
}{botAID: aID, botBID: bID}
}
pairData[key].total++
if p1.Won {
if p1.BotID == aID {
pairData[key].aWins++
} else {
pairData[key].bWins++
}
} else if p2.Won {
if p2.BotID == aID {
pairData[key].aWins++
} else {
pairData[key].bWins++
}
}
}
}
}
for _, pd := range pairData {
// Grudge matches: 10+ total meetings
if pd.total >= 10 && pd.aWins >= 2 && pd.bWins >= 2 {
arcs = append(arcs, StoryArc{
Type: ArcRivalry,
BotID: pd.botAID,
BotName: getBotName(pd.botAID, data),
BotBID: pd.botBID,
BotBName: getBotName(pd.botBID, data),
BotAWins: pd.aWins,
BotBWins: pd.bWins,
TotalMatches: pd.total,
KeyMatches: extractRivalryMatches(pd.botAID, pd.botBID, data),
})
}
}
return arcs
}
func detectUpsetArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
if len(data.Bots) < 10 {
return arcs
}
// Get current top 10 and bottom 10 bot IDs
top10 := make(map[string]bool, 10)
bottom10 := make(map[string]bool, 10)
for i := 0; i < 10 && i < len(data.Bots); i++ {
top10[data.Bots[i].ID] = true
}
for i := len(data.Bots) - 10; i < len(data.Bots); i++ {
if i >= 0 {
bottom10[data.Bots[i].ID] = true
}
}
// Find matches where bottom-10 beat top-10
for _, m := range data.Matches {
if len(m.Participants) < 2 {
continue
}
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 {
continue
}
// Check if winner was bottom-10 and loser was top-10
if bottom10[winner.BotID] && top10[loser.BotID] {
arcs = append(arcs, StoryArc{
Type: ArcUpset,
BotID: winner.BotID,
BotName: getBotName(winner.BotID, data),
BotBID: loser.BotID,
BotBName: getBotName(loser.BotID, data),
RatingStart: int(winner.PreMatchRating),
RatingEnd: int(loser.PreMatchRating),
MatchID: m.ID,
KeyMatches: []KeyMatch{{
MatchID: m.ID,
OpponentID: loser.BotID,
OpponentName: getBotName(loser.BotID, data),
OpponentRating: int(loser.PreMatchRating),
OpponentRank: getBotRank(loser.BotID, data),
MapName: m.MapName,
Score: fmt.Sprintf("%d-%d", winner.Score, loser.Score),
TurnCount: m.TurnCount,
Won: true,
EndCondition: m.EndCondition,
CriticalMoment: summarizeCriticalMoment(m, winner, loser),
}},
})
}
}
return arcs
}
func detectEvolutionArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
for _, bot := range data.Bots {
if !bot.Evolved {
continue
}
var previousATH float64
for _, rh := range getBotRatingHistory(bot.ID, data) {
if rh.Rating > previousATH && rh.RecordedAt.Before(data.GeneratedAt.AddDate(0, 0, -1)) {
previousATH = rh.Rating
}
}
if bot.Rating > previousATH+50 {
arcs = append(arcs, StoryArc{
Type: ArcEvolutionMilestone,
BotID: bot.ID,
BotName: bot.Name,
RatingEnd: int(bot.Rating),
Origin: fmt.Sprintf("evolved, %s island", bot.Island),
Generation: bot.Generation,
ParentIDs: bot.ParentIDs,
Archetype: bot.Archetype,
KeyMatches: extractKeyMatches(bot.ID, data),
})
}
rank := getBotRank(bot.ID, data)
if rank > 0 && rank <= 5 {
arcs = append(arcs, StoryArc{
Type: ArcEvolutionMilestone,
BotID: bot.ID,
BotName: bot.Name,
RatingEnd: int(bot.Rating),
Origin: fmt.Sprintf("evolved, %s island, generation %d", bot.Island, bot.Generation),
Generation: bot.Generation,
ParentIDs: bot.ParentIDs,
Archetype: bot.Archetype,
})
}
}
return arcs
}
func detectComebackArcs(data *IndexData) []StoryArc {
arcs := make([]StoryArc, 0)
if len(data.Bots) < 4 {
return arcs
}
// Calculate quartile thresholds
bottomQuartileCutoff := len(data.Bots) / 4
topQuartileStart := len(data.Bots) - (len(data.Bots) / 4)
for _, bot := range data.Bots {
currentRank := getBotRank(bot.ID, data)
if currentRank == 0 {
continue
}
// Check if bot is currently in top 25%
if currentRank < topQuartileStart {
// Look at rating history to see if bot was ever in bottom 25%
history := getBotRatingHistory(bot.ID, data)
if len(history) < 2 {
continue
}
// Check if bot was in bottom 25% at some point in the past 30 days
monthAgo := data.GeneratedAt.AddDate(0, 0, -30)
var wasInBottomQuartile bool
for _, rh := range history {
if rh.RecordedAt.Before(monthAgo) {
continue
}
// Estimate historical rank by comparing rating to others at that time
// This is an approximation - we count how many current bots had higher ratings then
lowerCount := 0
for _, otherBot := range data.Bots {
if otherBot.ID == bot.ID {
continue
}
otherHistory := getBotRatingHistory(otherBot.ID, data)
var otherRatingAtTime float64
for _, orh := range otherHistory {
if orh.RecordedAt.Before(rh.RecordedAt) || orh.RecordedAt.Equal(rh.RecordedAt) {
otherRatingAtTime = orh.Rating
}
}
if otherRatingAtTime > rh.Rating {
lowerCount++
}
}
historicalRank := lowerCount + 1
if historicalRank > bottomQuartileCutoff {
wasInBottomQuartile = true
break
}
}
if wasInBottomQuartile {
arcs = append(arcs, StoryArc{
Type: ArcComeback,
BotID: bot.ID,
BotName: bot.Name,
RatingStart: 0, // Was in bottom quartile
RatingEnd: int(bot.Rating),
KeyMatches: extractKeyMatches(bot.ID, data),
})
}
}
}
return arcs
}
func extractKeyMatches(botID string, data *IndexData) []KeyMatch {
matches := make([]KeyMatch, 0, 3)
for _, m := range data.Matches {
var botPart *ParticipantData
var oppPart *ParticipantData
for i := range m.Participants {
if m.Participants[i].BotID == botID {
botPart = &m.Participants[i]
} else {
oppPart = &m.Participants[i]
}
}
if botPart == nil || oppPart == nil {
continue
}
matches = append(matches, KeyMatch{
MatchID: m.ID,
OpponentID: oppPart.BotID,
OpponentName: getBotName(oppPart.BotID, data),
OpponentRating: int(oppPart.PreMatchRating),
OpponentRank: getBotRank(oppPart.BotID, data),
MapName: m.MapName,
Score: fmt.Sprintf("%d-%d", botPart.Score, oppPart.Score),
TurnCount: m.TurnCount,
Won: botPart.Won,
EndCondition: m.EndCondition,
CriticalMoment: summarizeCriticalMoment(m, botPart, oppPart),
})
if len(matches) >= 3 {
break
}
}
return matches
}
func extractRivalryMatches(botAID, botBID string, data *IndexData) []KeyMatch {
matches := make([]KeyMatch, 0, 5)
for _, m := range data.Matches {
var botAPart, botBPart *ParticipantData
for i := range m.Participants {
if m.Participants[i].BotID == botAID {
botAPart = &m.Participants[i]
} else if m.Participants[i].BotID == botBID {
botBPart = &m.Participants[i]
}
}
if botAPart == nil || botBPart == nil {
continue
}
matches = append(matches, KeyMatch{
MatchID: m.ID,
OpponentID: botBID,
OpponentName: getBotName(botBID, data),
OpponentRating: int(botBPart.PreMatchRating),
MapName: m.MapName,
Score: fmt.Sprintf("%d-%d", botAPart.Score, botBPart.Score),
TurnCount: m.TurnCount,
Won: botAPart.Won,
CriticalMoment: summarizeCriticalMoment(m, botAPart, botBPart),
})
if len(matches) >= 5 {
break
}
}
return matches
}
// summarizeCriticalMoment generates a brief turning-point description from
// match data per plan §13.2.
func summarizeCriticalMoment(m MatchData, winner, loser *ParticipantData) string {
scoreDelta := winner.Score - loser.Score
if scoreDelta < 0 {
scoreDelta = -scoreDelta
}
parts := make([]string, 0, 3)
if scoreDelta <= 1 {
parts = append(parts, "decided by a single point")
}
if winner.PreMatchRating > 0 && loser.PreMatchRating > 0 {
eloDelta := loser.PreMatchRating - winner.PreMatchRating
if eloDelta >= 150 {
parts = append(parts, fmt.Sprintf("upset by %.0f ELO points", eloDelta))
}
}
if m.EndCondition != "" && m.EndCondition != "turn_limit" {
parts = append(parts, m.EndCondition)
}
if m.TurnCount >= 400 {
parts = append(parts, "marathon match")
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ", ")
}
func getBotRank(botID string, data *IndexData) int {
for i, bot := range data.Bots {
if bot.ID == botID {
return i + 1
}
}
return 0
}
func buildHeadToHeadFromArc(arc StoryArc, data *IndexData) []HeadToHeadRecord {
if arc.BotID == "" {
return nil
}
type wl struct{ wins, losses int }
recordMap := make(map[string]*wl)
for _, m := range data.Matches {
var botIn, opponentIn bool
var opponentID string
for _, p := range m.Participants {
if p.BotID == arc.BotID {
botIn = true
} else {
opponentIn = true
opponentID = p.BotID
}
}
if !botIn || !opponentIn || opponentID == "" {
continue
}
r, ok := recordMap[opponentID]
if !ok {
r = &wl{}
recordMap[opponentID] = r
}
if m.WinnerID == arc.BotID {
r.wins++
} else if m.WinnerID == opponentID {
r.losses++
}
}
var records []HeadToHeadRecord
for oppID, r := range recordMap {
name := oppID
for _, b := range data.Bots {
if b.ID == oppID {
name = b.Name
break
}
}
records = append(records, HeadToHeadRecord{
OpponentName: name,
OpponentRank: getBotRank(oppID, data),
Wins: r.wins,
Losses: r.losses,
TotalMatches: r.wins + r.losses,
})
}
return records
}
func buildOriginString(bot BotData) string {
if !bot.Evolved {
return ""
}
return fmt.Sprintf("evolved, %s island", nonEmpty(bot.Island, "unknown"))
}
// getBotRatingHistory returns rating history entries for a specific bot
func getBotRatingHistory(botID string, data *IndexData) []RatingHistoryEntry {
entries := make([]RatingHistoryEntry, 0)
for _, rh := range data.RatingHistory {
if rh.BotID == botID {
entries = append(entries, rh)
}
}
return entries
}
// ─── Weekly Chronicles Generation ────────────────────────────────────────────────
// GenerateWeeklyChronicles creates a ~500-word aggregated narrative for the week
func (c *LLMClient) GenerateWeeklyChronicles(ctx context.Context, req WeeklyChroniclesRequest) (string, error) {
prompt := buildWeeklyChroniclesPrompt(req)
return c.chatCompletion(ctx, prompt)
}
// buildWeeklyChroniclesPrompt constructs a prompt for weekly aggregated narrative
func buildWeeklyChroniclesPrompt(req WeeklyChroniclesRequest) string {
var sb strings.Builder
sb.WriteString("Write a 500-word weekly chronicle for AI Code Battle. ")
sb.WriteString("You are a sports journalist covering an emergent bot league — write with the energy and narrative instinct of esports journalism. ")
sb.WriteString("Be dramatic but factual. Reference specific bots, ELO before/after deltas, rivalry context, head-to-head records, critical turning points, and season standings. ")
sb.WriteString("Weave the data into a compelling story — quote scores, cite match IDs, describe strategic moments. ")
sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n")
sb.WriteString(fmt.Sprintf("Week: %d of %d\n", req.WeekNumber, req.Year))
sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName))
sb.WriteString(fmt.Sprintf("Competitive landscape: %d active bots, %d matches this week\n", req.BotCount, req.MatchCount))
// Top bot and championship race
if req.TopBotName != "" {
sb.WriteString(fmt.Sprintf("\nCurrent #1: %s (ELO %.0f)\n", req.TopBotName, req.TopBotRating))
}
// Top ELO movers
if len(req.TopMovers) > 0 {
sb.WriteString("\nTop ELO movers this week:\n")
for _, m := range req.TopMovers {
dir := "surged"
if m.Delta < 0 {
dir = "dropped"
}
arch := nonEmpty(m.Archetype, "unclassified")
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, arch, m.MatchesWon, m.MatchesLost))
}
}
// Dominant strategy
if req.DominantStrat != "" {
sb.WriteString(fmt.Sprintf("\nDominant archetype: %s\n", req.DominantStrat))
}
// Story arcs by type
sb.WriteString("\nStory arcs this week:\n")
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)
comebackArcs := filterArcsByType(req.StoryArcs, ArcComeback)
if len(riseArcs) > 0 {
sb.WriteString("\n### Rising Stars\n")
for _, arc := range riseArcs {
delta := arc.RatingEnd - arc.RatingStart
sb.WriteString(fmt.Sprintf(" - %s: climbed %d points (%d → %d)", arc.BotName, delta, arc.RatingStart, arc.RatingEnd))
if arc.Archetype != "" {
sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype))
}
if arc.Origin != "" {
sb.WriteString(fmt.Sprintf(" (%s)", arc.Origin))
}
sb.WriteString("\n")
// Add key match context
if len(arc.KeyMatches) > 0 {
m := arc.KeyMatches[0]
outcome := "Beat"
if !m.Won {
outcome = "Lost to"
}
sb.WriteString(fmt.Sprintf(" Key match: %s %s (rating %d) — score %s, %d turns. Match ID: %s\n",
outcome, m.OpponentName, m.OpponentRating, m.Score, m.TurnCount, m.MatchID))
}
}
}
if len(fallArcs) > 0 {
sb.WriteString("\n### Falling Behind\n")
for _, arc := range fallArcs {
delta := arc.RatingStart - arc.RatingEnd
sb.WriteString(fmt.Sprintf(" - %s: dropped %d points (%d → %d)", arc.BotName, delta, arc.RatingStart, arc.RatingEnd))
if arc.Archetype != "" {
sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype))
}
sb.WriteString("\n")
}
}
if len(rivalryArcs) > 0 {
sb.WriteString("\n### Intensifying Rivalries\n")
for _, arc := range rivalryArcs {
sb.WriteString(fmt.Sprintf(" - %s vs %s: %d-%d over %d matches\n",
arc.BotName, arc.BotBName, arc.BotAWins, arc.BotBWins, arc.TotalMatches))
// Add recent encounters
if len(arc.KeyMatches) > 0 {
sb.WriteString(" Recent matches: ")
for i, m := range arc.KeyMatches {
if i > 0 {
sb.WriteString(", ")
}
winner := arc.BotBName
if m.Won {
winner = arc.BotName
}
sb.WriteString(fmt.Sprintf("%s won %d-%d", winner, m.TurnCount, m.TurnCount))
}
sb.WriteString("\n")
}
}
}
if len(upsetArcs) > 0 {
sb.WriteString("\n### Upsets of the Week\n")
for _, arc := range upsetArcs {
gap := arc.RatingEnd - arc.RatingStart
sb.WriteString(fmt.Sprintf(" - %s upset %s: %d-point ELO gap", arc.BotName, arc.BotBName, gap))
if len(arc.KeyMatches) > 0 {
m := arc.KeyMatches[0]
sb.WriteString(fmt.Sprintf(", score %s in %d turns. Match ID: %s\n", m.Score, m.TurnCount, m.MatchID))
} else {
sb.WriteString("\n")
}
}
}
if len(evoArcs) > 0 {
sb.WriteString("\n### Evolution Milestones\n")
for _, arc := range evoArcs {
sb.WriteString(fmt.Sprintf(" - %s: generation %d evolved bot", arc.BotName, arc.Generation))
if arc.Archetype != "" {
sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype))
}
if arc.Origin != "" {
sb.WriteString(fmt.Sprintf(" (%s)", arc.Origin))
}
sb.WriteString(fmt.Sprintf(", rating %.0f\n", float64(arc.RatingEnd)))
}
}
if len(comebackArcs) > 0 {
sb.WriteString("\n### Comebacks\n")
for _, arc := range comebackArcs {
sb.WriteString(fmt.Sprintf(" - %s: climbed from bottom 25%% to top 25%% (current ELO: %d)\n",
arc.BotName, arc.RatingEnd))
}
}
// Match of the week
if req.BestMatch != nil {
sb.WriteString(fmt.Sprintf("\n### Match of the Week\n"))
sb.WriteString(fmt.Sprintf("%s — score %s in %d turns. Match ID: %s\n",
req.BestMatch.Description, req.BestMatch.Score, req.BestMatch.TurnCount, req.BestMatch.MatchID))
}
sb.WriteString("\nWrite a compelling 500-word weekly chronicle that weaves these story arcs together. ")
sb.WriteString("Focus on the most dramatic narratives — the rise, the fall, the rivalries, the upsets. ")
sb.WriteString("Make it feel like a sports recap that makes readers want to watch the replays.")
return sb.String()
}
// filterArcsByType returns arcs of a specific type
func filterArcsByType(arcs []StoryArc, arcType StoryArcType) []StoryArc {
filtered := make([]StoryArc, 0)
for _, arc := range arcs {
if arc.Type == arcType {
filtered = append(filtered, arc)
}
}
return filtered
}