ai-code-battle/cmd/acb-evolver/internal/replay/analyzer.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

437 lines
12 KiB
Go

// Package replay analyzes match replays to extract strategic insights
// for the LLM evolution prompt.
//
// The analyzer processes completed match replays and produces:
// - Key moments: significant events that changed the match trajectory
// - Strategies: winning tactics employed by the victor
// - Weaknesses: exploitable patterns in the loser's play
package replay
import (
"github.com/aicodebattle/acb/engine"
)
// Analysis holds the extracted insights from a single match replay.
type Analysis struct {
// MatchID is the unique identifier of the analyzed match.
MatchID string
// WinnerName is the name of the winning player (empty for draws).
WinnerName string
// LoserName is the name of the losing player (empty for draws).
LoserName string
// Condition is the win condition: "elimination", "dominance", "turns", or "draw".
Condition string
// TurnCount is the total number of turns played.
TurnCount int
// Scores holds the final scores for each player slot.
Scores []int
// KeyMoments are notable events that influenced the outcome.
KeyMoments []string
// Strategies lists the successful tactics used by the winner.
Strategies []string
// Weaknesses lists the exploitable patterns in the loser's play.
Weaknesses []string
}
// Analyzer processes replays and extracts strategic insights.
type Analyzer struct{}
// NewAnalyzer creates a new replay analyzer.
func NewAnalyzer() *Analyzer {
return &Analyzer{}
}
// Analyze processes a replay and returns a structured analysis.
func (a *Analyzer) Analyze(replay *engine.Replay) *Analysis {
if replay == nil {
return nil
}
analysis := &Analysis{
MatchID: replay.MatchID,
TurnCount: len(replay.Turns),
Scores: make([]int, 0),
Condition: "",
}
// Extract result information
if replay.Result != nil {
analysis.Condition = replay.Result.Reason
if len(replay.Result.Scores) > 0 {
analysis.Scores = replay.Result.Scores
}
}
// Identify winner and loser
if len(replay.Players) >= 2 && replay.Result != nil && replay.Result.Winner >= 0 {
winnerIdx := replay.Result.Winner
if winnerIdx < len(replay.Players) {
analysis.WinnerName = replay.Players[winnerIdx].Name
}
// Loser is the other player (for 2-player matches)
if len(replay.Players) == 2 {
loserIdx := 1 - winnerIdx
if replay.Result.Winner >= 0 {
analysis.LoserName = replay.Players[loserIdx].Name
}
}
}
// Analyze the replay for strategic insights
a.analyzeKeyMoments(replay, analysis)
a.analyzeStrategies(replay, analysis)
a.analyzeWeaknesses(replay, analysis)
return analysis
}
// analyzeKeyMoments identifies significant events that shaped the match.
func (a *Analyzer) analyzeKeyMoments(replay *engine.Replay, analysis *Analysis) {
var moments []string
winnerID := -1
if replay.Result != nil {
winnerID = replay.Result.Winner
}
// Track key metrics over time to detect pivotal moments
prevBotCounts := make(map[int]int)
prevScores := make(map[int]int)
coreFlips := make(map[int]int) // track core ownership changes by player
for turnIdx, turn := range replay.Turns {
turnNum := turn.Turn
// Count bots per player
botCounts := make(map[int]int)
for _, bot := range turn.Bots {
if bot.Alive {
botCounts[bot.Owner]++
}
}
// Detect bot count changes (spawn/death events)
for playerID, count := range botCounts {
prevCount := prevBotCounts[playerID]
if turnNum > 0 && prevCount > 0 {
diff := count - prevCount
if diff <= -3 {
moments = append(moments, formatMoment(turnNum, playerID, replay.Players,
"lost %d bots in rapid succession", -diff))
} else if diff >= 3 {
moments = append(moments, formatMoment(turnNum, playerID, replay.Players,
"spawned %d bots in rapid succession", diff))
}
}
prevBotCounts[playerID] = count
}
// Process events for notable occurrences
for _, event := range turn.Events {
switch event.Type {
case "core_captured":
details, ok := event.Details.(map[string]interface{})
if !ok {
continue
}
// Track core ownership changes
if attacker, ok := details["attacker_id"].(float64); ok {
playerID := int(attacker)
coreFlips[playerID]++
if coreFlips[playerID] == 1 {
moments = append(moments, formatMoment(turnNum, playerID, replay.Players,
"captured first enemy core"))
}
}
case "combat_death":
if turnNum < 50 {
moments = append(moments, formatMoment(turnNum, -1, replay.Players,
"early combat casualty"))
}
}
}
// Detect score swings
for playerID, score := range turn.Scores {
prevScore := prevScores[playerID]
if turnNum > 0 && prevScore > 0 {
diff := score - prevScore
if diff >= 20 {
moments = append(moments, formatMoment(turnNum, playerID, replay.Players,
"gained %d score in single turn", diff))
}
}
prevScores[playerID] = score
}
// Limit key moments to avoid prompt bloat
if len(moments) >= 5 && turnIdx < len(replay.Turns)-10 {
break
}
}
// Add final score summary if there's a clear winner
if winnerID >= 0 && len(analysis.Scores) >= 2 {
scoreDiff := analysis.Scores[winnerID]
if len(analysis.Scores) > 1 {
loserID := 1 - winnerID
if winnerID == 1 {
loserID = 0
}
if loserID < len(analysis.Scores) {
scoreDiff = analysis.Scores[winnerID] - analysis.Scores[loserID]
}
}
if scoreDiff > 100 {
moments = append(moments, "Final score advantage: dominant victory")
} else if scoreDiff > 50 {
moments = append(moments, "Final score advantage: clear victory")
} else if scoreDiff > 20 {
moments = append(moments, "Final score advantage: narrow victory")
}
}
analysis.KeyMoments = dedupeMoments(moments)
}
// analyzeStrategies identifies winning tactics from the replay.
func (a *Analyzer) analyzeStrategies(replay *engine.Replay, analysis *Analysis) {
if replay.Result == nil || replay.Result.Winner < 0 {
return
}
winnerID := replay.Result.Winner
var strategies []string
// Analyze early game (first 50 turns)
earlyBots := make(map[int]int)
earlyEnergy := make(map[int]int)
for _, turn := range replay.Turns {
if turn.Turn > 50 {
break
}
for _, bot := range turn.Bots {
if bot.Alive {
earlyBots[bot.Owner]++
}
}
earlyEnergy[turn.Turn%10] = len(turn.Energy)
}
// Detect aggressive early expansion
if earlyBots[winnerID] > earlyBots[1-winnerID]*2 && winnerID < len(earlyBots) {
strategies = append(strategies, "aggressive early expansion")
}
// Analyze core control
coreCaptures := make(map[int]int)
for _, turn := range replay.Turns {
for _, core := range turn.Cores {
if core.Owner == winnerID && core.Active {
coreCaptures[winnerID]++
}
}
}
if coreCaptures[winnerID] > 1 {
strategies = append(strategies, "multi-core control")
}
// Detect win condition patterns
switch replay.Result.Reason {
case "elimination":
strategies = append(strategies, "complete elimination of opponent")
case "dominance":
strategies = append(strategies, "sustained bot superiority")
case "turns":
strategies = append(strategies, "score accumulation strategy")
}
// Analyze spawn patterns (energy management)
spawnRate := 0
if len(replay.Turns) > 100 {
botGrowth := 0
for _, turn := range replay.Turns[80:100] {
for _, bot := range turn.Bots {
if bot.Alive && bot.Owner == winnerID {
botGrowth++
}
}
}
spawnRate = botGrowth / 20
}
if spawnRate >= 3 {
strategies = append(strategies, "high spawn tempo")
} else if spawnRate >= 1 {
strategies = append(strategies, "controlled spawn rate")
}
// Detect energy focus vs combat focus
energyCollected := 0
combatDeaths := 0
for _, turn := range replay.Turns {
for _, event := range turn.Events {
if event.Type == "energy_collected" {
energyCollected++
} else if event.Type == "combat_death" {
combatDeaths++
}
}
}
if energyCollected > combatDeaths*2 {
strategies = append(strategies, "energy-focused economy")
} else if combatDeaths > energyCollected {
strategies = append(strategies, "aggressive combat pressure")
}
analysis.Strategies = dedupe(strategies)
}
// analyzeWeaknesses identifies exploitable patterns in the loser's play.
func (a *Analyzer) analyzeWeaknesses(replay *engine.Replay, analysis *Analysis) {
if replay.Result == nil || replay.Result.Winner < 0 || len(replay.Players) < 2 {
return
}
loserID := 1 - replay.Result.Winner
var weaknesses []string
// Analyze bot count trends
botCounts := make(map[int][]int)
for _, turn := range replay.Turns {
count := 0
for _, bot := range turn.Bots {
if bot.Alive && bot.Owner == loserID {
count++
}
}
botCounts[loserID] = append(botCounts[loserID], count)
}
// Detect bot shortage issues
if len(botCounts[loserID]) > 50 {
lateBots := botCounts[loserID][len(botCounts[loserID])-1]
if lateBots < 3 {
weaknesses = append(weaknesses, "insufficient bot production")
}
}
// Detect passive play
spawnEvents := 0
for i, turn := range replay.Turns {
if i == 0 {
continue
}
prevCount := 0
currCount := 0
for _, bot := range replay.Turns[i-1].Bots {
if bot.Alive && bot.Owner == loserID {
prevCount++
}
}
for _, bot := range turn.Bots {
if bot.Alive && bot.Owner == loserID {
currCount++
}
}
if currCount > prevCount {
spawnEvents++
}
}
if spawnEvents < 5 && len(replay.Turns) > 100 {
weaknesses = append(weaknesses, "passive spawn behavior")
}
// Detect core defense issues
coreLosses := 0
for _, turn := range replay.Turns {
for _, event := range turn.Events {
if event.Type == "core_captured" {
details, ok := event.Details.(map[string]interface{})
if ok {
if victim, ok := details["victim_id"].(float64); ok && int(victim) == loserID {
coreLosses++
}
}
}
}
}
if coreLosses > 0 {
weaknesses = append(weaknesses, "weak core defense")
}
// Detect energy inefficiency
energyCollected := 0
for _, turn := range replay.Turns {
for _, event := range turn.Events {
if event.Type == "energy_collected" {
energyCollected++
}
}
}
if energyCollected < 10 && len(replay.Turns) > 100 {
weaknesses = append(weaknesses, "poor energy collection")
}
// Detect early elimination vulnerability
if replay.Result.Reason == "elimination" && len(replay.Turns) < 100 {
weaknesses = append(weaknesses, "vulnerable to early aggression")
}
// Detect score gap accumulation
if len(analysis.Scores) > loserID && len(analysis.Scores) > replay.Result.Winner {
scoreGap := analysis.Scores[replay.Result.Winner] - analysis.Scores[loserID]
if scoreGap > 100 {
weaknesses = append(weaknesses, "failed to contest score")
}
}
analysis.Weaknesses = dedupe(weaknesses)
}
// formatMoment creates a formatted key moment string.
func formatMoment(turn, playerID int, players []engine.ReplayPlayer, format string, args ...interface{}) string {
playerName := ""
if playerID >= 0 && playerID < len(players) {
playerName = players[playerID].Name
}
return formatPlayerMoment(turn, playerName, format, args...)
}
// formatPlayerMoment formats a moment with player name context.
func formatPlayerMoment(turn int, playerName, format string, args ...interface{}) string {
args = append([]interface{}{turn}, args...)
if playerName != "" {
return playerName + " (turn %d): " + format
}
return "Turn %d: " + format
}
// dedupeMoments removes duplicate or similar moments.
func dedupeMoments(moments []string) []string {
seen := make(map[string]bool)
var result []string
for _, m := range moments {
if !seen[m] {
seen[m] = true
result = append(result, m)
}
}
// Limit to 5 most relevant moments
if len(result) > 5 {
result = result[:5]
}
return result
}
// dedupe removes duplicate strings from a slice.
func dedupe(items []string) []string {
seen := make(map[string]bool)
var result []string
for _, item := range items {
if !seen[item] {
seen[item] = true
result = append(result, item)
}
}
return result
}