Tab/space alignment consistency from running gofmt on all packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
312 lines
8.4 KiB
Go
312 lines
8.4 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 (bot deaths/captures)
|
|
CombatDeaths int // Count of focus-fire combat deaths (EventCombatDeath)
|
|
ResourceContestTurns int // Turns where energy was contested (multiple players adjacent)
|
|
SurvivalTurns int // Turns where all players had at least one bot alive
|
|
Engagement float64 // Combined engagement score
|
|
}
|
|
|
|
// CalculateMapEngagement computes the engagement score for a map based on replay data.
|
|
// The engagement formula (from plan §14.6, extended for combat density) is:
|
|
// score = win_prob_crossings * 3.0 + combat_deaths * 3.0 + critical_moments * 2.0 +
|
|
//
|
|
// resource_contest_turns * 1.5 + survival_turns * 0.5
|
|
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 combat deaths (focus-fire kills)
|
|
combatDeaths := countCombatDeaths(replay)
|
|
|
|
// Count critical moments (bot deaths/captures with significant win prob shifts)
|
|
criticalMoments := len(replay.CriticalMoments)
|
|
|
|
// Count resource contest turns (turns where energy was contested)
|
|
resourceContestTurns := countResourceContestTurns(replay)
|
|
|
|
// Count survival turns (turns where all players had at least one bot alive)
|
|
survivalTurns := countSurvivalTurns(replay)
|
|
|
|
// Calculate combined engagement score per plan §14.6
|
|
// Combat deaths are weighted heavily (3.0) to bias map evolution toward combat-dense maps
|
|
engagement := float64(winProbCrossings)*3.0 +
|
|
float64(combatDeaths)*3.0 +
|
|
float64(criticalMoments)*2.0 +
|
|
float64(resourceContestTurns)*1.5 +
|
|
float64(survivalTurns)*0.5
|
|
|
|
return MapEngagementScore{
|
|
WinProbCrossings: winProbCrossings,
|
|
CriticalMoments: criticalMoments,
|
|
CombatDeaths: combatDeaths,
|
|
ResourceContestTurns: resourceContestTurns,
|
|
SurvivalTurns: survivalTurns,
|
|
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
|
|
}
|
|
|
|
// countResourceContestTurns counts turns where energy was contested by multiple players.
|
|
// A turn is contested if at least one energy tile has bots from two or more different players
|
|
// adjacent to it (meaning they could both collect it, but contested energy is destroyed).
|
|
func countResourceContestTurns(replay *Replay) int {
|
|
if replay == nil || len(replay.Turns) == 0 {
|
|
return 0
|
|
}
|
|
|
|
contestedTurns := 0
|
|
|
|
for _, turn := range replay.Turns {
|
|
if isEnergyContested(turn) {
|
|
contestedTurns++
|
|
}
|
|
}
|
|
|
|
return contestedTurns
|
|
}
|
|
|
|
// isEnergyContested checks if any energy in this turn is contested by multiple players.
|
|
// Energy is contested when two or more players have bots adjacent to the same energy node.
|
|
func isEnergyContested(turn ReplayTurn) bool {
|
|
if len(turn.Energy) == 0 {
|
|
return false
|
|
}
|
|
|
|
// For each energy tile, check which players have adjacent bots
|
|
for _, energyPos := range turn.Energy {
|
|
playersAdjacent := make(map[int]struct{})
|
|
|
|
for _, bot := range turn.Bots {
|
|
if !bot.Alive {
|
|
continue
|
|
}
|
|
|
|
// Check if bot is adjacent to this energy (including being on it)
|
|
dist := toroidalDistance(bot.Position, energyPos, int(turn.Bots[0].Position.Row), int(turn.Bots[0].Position.Col))
|
|
if dist <= 1.5 { // Adjacent or on the tile (using sqrt(2) ~ 1.41 for diagonal)
|
|
playersAdjacent[bot.Owner] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// If 2+ players are adjacent to this energy, it's contested
|
|
if len(playersAdjacent) >= 2 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// countSurvivalTurns counts turns where all players had at least one bot alive.
|
|
// This indicates the match was still competitive with all participants active.
|
|
func countSurvivalTurns(replay *Replay) int {
|
|
if replay == nil || len(replay.Turns) == 0 {
|
|
return 0
|
|
}
|
|
|
|
numPlayers := len(replay.Players)
|
|
if numPlayers == 0 {
|
|
return 0
|
|
}
|
|
|
|
survivalTurns := 0
|
|
|
|
for _, turn := range replay.Turns {
|
|
if allPlayersAlive(turn, numPlayers) {
|
|
survivalTurns++
|
|
}
|
|
}
|
|
|
|
return survivalTurns
|
|
}
|
|
|
|
// allPlayersAlive checks if every player has at least one living bot.
|
|
func allPlayersAlive(turn ReplayTurn, numPlayers int) bool {
|
|
playersWithBots := make(map[int]struct{})
|
|
|
|
for _, bot := range turn.Bots {
|
|
if bot.Alive {
|
|
playersWithBots[bot.Owner] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// All players must have at least one living bot
|
|
return len(playersWithBots) == numPlayers
|
|
}
|
|
|
|
// countCombatDeaths counts the total number of focus-fire combat deaths (EventCombatDeath)
|
|
// across all turns in the replay. This is the key combat-density metric.
|
|
func countCombatDeaths(replay *Replay) int {
|
|
if replay == nil || len(replay.Turns) == 0 {
|
|
return 0
|
|
}
|
|
|
|
combatDeaths := 0
|
|
for _, turn := range replay.Turns {
|
|
for _, event := range turn.Events {
|
|
if event.Type == EventCombatDeath {
|
|
combatDeaths++
|
|
}
|
|
}
|
|
}
|
|
return combatDeaths
|
|
}
|
|
|
|
// toroidalDistance computes the toroidal distance between two positions.
|
|
func toroidalDistance(a, b Position, rows, cols int) float64 {
|
|
dr := float64(a.Row - b.Row)
|
|
dc := float64(a.Col - b.Col)
|
|
|
|
// Handle toroidal wrapping
|
|
if dr < 0 {
|
|
dr = -dr
|
|
}
|
|
if dr > float64(rows)/2 {
|
|
dr = float64(rows) - dr
|
|
}
|
|
|
|
if dc < 0 {
|
|
dc = -dc
|
|
}
|
|
if dc > float64(cols)/2 {
|
|
dc = float64(cols) - dc
|
|
}
|
|
|
|
return dr*dr + dc*dc // Return squared distance for comparison
|
|
}
|