ai-code-battle/engine/map_engagement.go
jedarden 92576dbed4 feat(worker): add map engagement score tracking and verify win_prob in replays
- Add engine.CalculateMapEngagement() to compute map engagement scores from replay data (win_prob_crossings, critical_moments, map_coverage_pct, closeness, turn_pct)
- Add DBClient.UpdateMapEngagement() to update map engagement using rolling average
- Worker now calculates and writes map engagement scores after each match
- Add test to verify win_prob array is non-empty in produced replays

This implements the win probability Monte Carlo array storage in replay JSON
feature. The engine already called ComputeWinProbability() in MatchRunner.Run(),
so this commit adds the missing map engagement tracking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 23:21:57 -04:00

180 lines
4.7 KiB
Go

package engine
import "math"
// MapEngagementScore represents the engagement metrics for a map from a single match.
type MapEngagementScore struct {
WinProbCrossings float64 // Number of times win prob crossed 50%
CriticalMoments int // Count of critical moments
MapCoveragePct float64 // Percentage of map tiles visited
Closeness float64 // 1.0 - (score_diff / max_possible_score)
TurnPct float64 // Actual turns / max_turns
Engagement float64 // Combined engagement score
}
// CalculateMapEngagement computes the engagement score for a map based on replay data.
// The engagement formula is:
// engagement = win_prob_crossings * 3.0 + critical_moments * 2.0 + map_coverage_pct * 1.0 + closeness * 2.0 + turn_pct * 1.0
func CalculateMapEngagement(replay *Replay) MapEngagementScore {
if replay == nil || len(replay.Turns) == 0 {
return MapEngagementScore{}
}
// Count win probability crossings (times the leader changed)
winProbCrossings := countWinProbCrossings(replay.WinProb)
// Count critical moments
criticalMoments := len(replay.CriticalMoments)
// Calculate map coverage (percentage of unique tiles visited)
mapCoveragePct := calculateMapCoverage(replay)
// Calculate closeness (how close the final score was)
closeness := calculateCloseness(replay)
// Calculate turn percentage
turnPct := float64(replay.Result.Turns) / float64(replay.Config.MaxTurns)
// Calculate combined engagement score
engagement := float64(winProbCrossings)*3.0 +
float64(criticalMoments)*2.0 +
mapCoveragePct*1.0 +
closeness*2.0 +
turnPct*1.0
return MapEngagementScore{
WinProbCrossings: winProbCrossings,
CriticalMoments: criticalMoments,
MapCoveragePct: mapCoveragePct,
Closeness: closeness,
TurnPct: turnPct,
Engagement: engagement,
}
}
// countWinProbCrossings counts how many times the win probability crossed 50% for any player.
// This indicates lead changes and momentum shifts.
func countWinProbCrossings(winProbs []WinProbEntry) float64 {
if len(winProbs) < 2 {
return 0
}
crossings := 0
// Track which player was leading (had highest win prob) at each turn
for i := 1; i < len(winProbs); i++ {
prevLeader := findLeader(winProbs[i-1])
currLeader := findLeader(winProbs[i])
if prevLeader != currLeader {
crossings++
}
}
return float64(crossings)
}
// findLeader returns the index of the player with the highest win probability.
// Returns -1 if there's a tie or no clear leader.
func findLeader(entry WinProbEntry) int {
if len(entry) == 0 {
return -1
}
maxProb := entry[0]
leaderIdx := 0
// Check if there's a clear leader (no ties)
for i := 1; i < len(entry); i++ {
if entry[i] > maxProb {
maxProb = entry[i]
leaderIdx = i
}
}
// Verify the leader is significantly ahead (not a tie)
isTie := false
for i := 0; i < len(entry); i++ {
if i != leaderIdx && math.Abs(entry[i]-maxProb) < 0.01 {
isTie = true
break
}
}
if isTie {
return -1
}
return leaderIdx
}
// calculateMapCoverage computes the percentage of map tiles that were visited by any bot.
func calculateMapCoverage(replay *Replay) float64 {
if replay == nil || len(replay.Turns) == 0 {
return 0
}
totalTiles := replay.Config.Rows * replay.Config.Cols
if totalTiles == 0 {
return 0
}
// Count unique tiles visited across all turns
visited := make(map[string]struct{})
for _, turn := range replay.Turns {
for _, bot := range turn.Bots {
if bot.Alive {
key := string(rune(bot.Position.Row)) + "," + string(rune(bot.Position.Col))
visited[key] = struct{}{}
}
}
}
// Subtract wall tiles from total (they're not visitable)
wallTiles := len(replay.Map.Walls)
visitbleTiles := totalTiles - wallTiles
if visitbleTiles <= 0 {
return 0
}
return float64(len(visited)) / float64(visitbleTiles)
}
// calculateCloseness computes how close the final score was.
// Returns 1.0 for a draw/tie, decreasing to 0.0 for a blowout.
func calculateCloseness(replay *Replay) float64 {
if replay == nil || replay.Result == nil || len(replay.Result.Scores) == 0 {
return 0
}
// Find the max and min scores
maxScore := replay.Result.Scores[0]
minScore := replay.Result.Scores[0]
for _, score := range replay.Result.Scores {
if score > maxScore {
maxScore = score
}
if score < minScore {
minScore = score
}
}
scoreDiff := maxScore - minScore
if scoreDiff == 0 {
return 1.0 // Perfect tie
}
// Normalize: closeness = 1 - (score_diff / max_possible_score)
// Assume max possible score is roughly 3x the number of turns (3 points per capture)
maxPossibleScore := float64(replay.Config.MaxTurns) * 3.0
if maxPossibleScore <= 0 {
return 1.0
}
normalizedDiff := float64(scoreDiff) / maxPossibleScore
if normalizedDiff > 1.0 {
normalizedDiff = 1.0
}
return 1.0 - normalizedDiff
}