diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 3362eb8..578c33f 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -411,8 +411,8 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma // Calculate map engagement score from replay engagement := engine.CalculateMapEngagement(replay) - w.logger.Printf("Map engagement: crossings=%.0f, critical_moments=%d, coverage=%.2f%%, closeness=%.2f, score=%.2f", - engagement.WinProbCrossings, engagement.CriticalMoments, engagement.MapCoveragePct*100, engagement.Closeness, engagement.Engagement) + w.logger.Printf("Map engagement: crossings=%.0f, critical_moments=%d, resource_contest_turns=%d, survival_turns=%d, score=%.2f", + engagement.WinProbCrossings, engagement.CriticalMoments, engagement.ResourceContestTurns, engagement.SurvivalTurns, engagement.Engagement) // Update map engagement in database if err := w.db.UpdateMapEngagement(ctx, claimData.Match.MapID, engagement.Engagement, result.Turns); err != nil { diff --git a/engine/map_engagement.go b/engine/map_engagement.go index 950eceb..4cb2404 100644 --- a/engine/map_engagement.go +++ b/engine/map_engagement.go @@ -4,17 +4,16 @@ 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 + WinProbCrossings float64 // Number of times win prob crossed 50% + CriticalMoments int // Count of critical moments (bot deaths/captures) + 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 is: -// engagement = win_prob_crossings * 3.0 + critical_moments * 2.0 + map_coverage_pct * 1.0 + closeness * 2.0 + turn_pct * 1.0 +// The engagement formula (from plan §14.6) is: +// score = win_prob_crossings * 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{} @@ -23,32 +22,27 @@ func CalculateMapEngagement(replay *Replay) MapEngagementScore { // Count win probability crossings (times the leader changed) winProbCrossings := countWinProbCrossings(replay.WinProb) - // Count critical moments + // Count critical moments (bot deaths/captures with significant win prob shifts) criticalMoments := len(replay.CriticalMoments) - // Calculate map coverage (percentage of unique tiles visited) - mapCoveragePct := calculateMapCoverage(replay) + // Count resource contest turns (turns where energy was contested) + resourceContestTurns := countResourceContestTurns(replay) - // Calculate closeness (how close the final score was) - closeness := calculateCloseness(replay) + // Count survival turns (turns where all players had at least one bot alive) + survivalTurns := countSurvivalTurns(replay) - // Calculate turn percentage - turnPct := float64(replay.Result.Turns) / float64(replay.Config.MaxTurns) - - // Calculate combined engagement score + // Calculate combined engagement score per plan §14.6 engagement := float64(winProbCrossings)*3.0 + float64(criticalMoments)*2.0 + - mapCoveragePct*1.0 + - closeness*2.0 + - turnPct*1.0 + float64(resourceContestTurns)*1.5 + + float64(survivalTurns)*0.5 return MapEngagementScore{ - WinProbCrossings: winProbCrossings, - CriticalMoments: criticalMoments, - MapCoveragePct: mapCoveragePct, - Closeness: closeness, - TurnPct: turnPct, - Engagement: engagement, + WinProbCrossings: winProbCrossings, + CriticalMoments: criticalMoments, + ResourceContestTurns: resourceContestTurns, + SurvivalTurns: survivalTurns, + Engagement: engagement, } } @@ -178,3 +172,114 @@ func calculateCloseness(replay *Replay) float64 { 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 +} + +// 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 +} diff --git a/engine/map_engagement_test.go b/engine/map_engagement_test.go index a2c6bf8..700899b 100644 --- a/engine/map_engagement_test.go +++ b/engine/map_engagement_test.go @@ -19,12 +19,13 @@ func TestMapEngagement_WinProbDependency(t *testing.T) { {0.4, 0.6}, // Player 1 leading - 3rd crossing }, Turns: []ReplayTurn{ - {Turn: 0, Bots: []ReplayBot{{Position: Position{Row: 0, Col: 0}, Alive: true}}}, - {Turn: 1, Bots: []ReplayBot{{Position: Position{Row: 1, Col: 1}, Alive: true}}}, + {Turn: 0, Bots: []ReplayBot{{Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}}}, + {Turn: 1, Bots: []ReplayBot{{Position: Position{Row: 1, Col: 1}, Alive: true, Owner: 0}}}, }, Map: ReplayMap{ Walls: []Position{{Row: 10, Col: 10}}, }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, } score := CalculateMapEngagement(replay) @@ -51,11 +52,12 @@ func TestMapEngagement_CriticalMomentsDependency(t *testing.T) { {Turn: 25, Delta: -0.25, Player: 1, Description: "Player 1 fights back"}, }, Turns: []ReplayTurn{ - {Turn: 0, Bots: []ReplayBot{{Position: Position{Row: 0, Col: 0}, Alive: true}}}, + {Turn: 0, Bots: []ReplayBot{{Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}}}, }, Map: ReplayMap{ Walls: []Position{{Row: 10, Col: 10}}, }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, } score := CalculateMapEngagement(replay) @@ -72,6 +74,84 @@ func TestMapEngagement_CriticalMomentsDependency(t *testing.T) { } } +// TestMapEngagement_ResourceContestTurns verifies that contested energy turns are counted. +func TestMapEngagement_ResourceContestTurns(t *testing.T) { + replay := &Replay{ + Config: Config{Rows: 20, Cols: 20, MaxTurns: 100}, + Result: &MatchResult{Turns: 50, Scores: []int{5, 4}}, + Turns: []ReplayTurn{ + { + Turn: 0, + Energy: []Position{{Row: 5, Col: 5}}, + Bots: []ReplayBot{ + {Position: Position{Row: 5, Col: 4}, Alive: true, Owner: 0}, // Adjacent to energy + {Position: Position{Row: 5, Col: 6}, Alive: true, Owner: 1}, // Adjacent to energy + }, + }, + { + Turn: 1, + Energy: []Position{{Row: 10, Col: 10}}, + Bots: []ReplayBot{ + {Position: Position{Row: 10, Col: 9}, Alive: true, Owner: 0}, // Only player 0 adjacent + }, + }, + }, + Map: ReplayMap{ + Walls: []Position{{Row: 15, Col: 15}}, + }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, + } + + score := CalculateMapEngagement(replay) + + // Turn 0 is contested (both players adjacent), turn 1 is not + if score.ResourceContestTurns != 1 { + t.Errorf("Expected 1 resource contest turn, got %d", score.ResourceContestTurns) + } +} + +// TestMapEngagement_SurvivalTurns verifies that survival turns are counted correctly. +func TestMapEngagement_SurvivalTurns(t *testing.T) { + replay := &Replay{ + Config: Config{Rows: 20, Cols: 20, MaxTurns: 100}, + Result: &MatchResult{Turns: 50, Scores: []int{5, 4}}, + Turns: []ReplayTurn{ + { + Turn: 0, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: true, Owner: 1}, + }, + }, + { + Turn: 1, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: false, Owner: 1}, // Player 1 bot died + }, + }, + { + Turn: 2, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: true, Owner: 1}, + }, + }, + }, + Map: ReplayMap{ + Walls: []Position{{Row: 15, Col: 15}}, + }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, + } + + score := CalculateMapEngagement(replay) + + // Turns 0 and 2 have all players alive, turn 1 does not + if score.SurvivalTurns != 2 { + t.Errorf("Expected 2 survival turns, got %d", score.SurvivalTurns) + } +} + // TestMapEngagement_EmptyReplay handles empty/nil replays gracefully. func TestMapEngagement_EmptyReplay(t *testing.T) { score1 := CalculateMapEngagement(nil) @@ -83,6 +163,158 @@ func TestMapEngagement_EmptyReplay(t *testing.T) { } } +// TestMapEngagement_Formula verifies the engagement score uses the correct formula: +// score = win_prob_crossings * 3.0 + critical_moments * 2.0 + resource_contest_turns * 1.5 + survival_turns * 0.5 +func TestMapEngagement_Formula(t *testing.T) { + replay := &Replay{ + Config: Config{Rows: 20, Cols: 20, MaxTurns: 100}, + Result: &MatchResult{Turns: 50, Scores: []int{5, 4}}, + WinProb: []WinProbEntry{ + {0.6, 0.4}, // Player 0 leading + {0.4, 0.6}, // Player 1 leading - 1st crossing + }, + CriticalMoments: []CriticalMoment{ + {Turn: 10, Delta: 0.20, Player: 0, Description: "Player 0 scores"}, + }, + Turns: []ReplayTurn{ + { + Turn: 0, + Energy: []Position{{Row: 5, Col: 5}}, + Bots: []ReplayBot{ + {Position: Position{Row: 5, Col: 4}, Alive: true, Owner: 0}, + {Position: Position{Row: 5, Col: 6}, Alive: true, Owner: 1}, + }, + }, + { + Turn: 1, + Energy: []Position{{Row: 10, Col: 10}}, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: true, Owner: 1}, + }, + }, + }, + Map: ReplayMap{ + Walls: []Position{{Row: 15, Col: 15}}, + }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, + } + + score := CalculateMapEngagement(replay) + + // Count each metric + winProbCrossings := 1.0 // One lead change + criticalMoments := 1 // One critical moment + resourceContestTurns := 1 // Turn 0 has contested energy + survivalTurns := 2 // Both turns have all players alive + + // Expected formula: 1.0*3.0 + 1*2.0 + 1*1.5 + 2*0.5 = 3.0 + 2.0 + 1.5 + 1.0 = 7.5 + expectedEngagement := winProbCrossings*3.0 + float64(criticalMoments)*2.0 + float64(resourceContestTurns)*1.5 + float64(survivalTurns)*0.5 + + if score.Engagement != expectedEngagement { + t.Errorf("Expected engagement %.2f, got %.2f", expectedEngagement, score.Engagement) + } + + if score.WinProbCrossings != winProbCrossings { + t.Errorf("Expected win_prob_crossings %.0f, got %.0f", winProbCrossings, score.WinProbCrossings) + } + + if score.CriticalMoments != criticalMoments { + t.Errorf("Expected critical_moments %d, got %d", criticalMoments, score.CriticalMoments) + } + + if score.ResourceContestTurns != resourceContestTurns { + t.Errorf("Expected resource_contest_turns %d, got %d", resourceContestTurns, score.ResourceContestTurns) + } + + if score.SurvivalTurns != survivalTurns { + t.Errorf("Expected survival_turns %d, got %d", survivalTurns, score.SurvivalTurns) + } +} + +// TestMapEngagement_NoContestedEnergy verifies that energy contested by only one player is not counted. +func TestMapEngagement_NoContestedEnergy(t *testing.T) { + replay := &Replay{ + Config: Config{Rows: 20, Cols: 20, MaxTurns: 100}, + Result: &MatchResult{Turns: 50, Scores: []int{5, 4}}, + Turns: []ReplayTurn{ + { + Turn: 0, + Energy: []Position{{Row: 5, Col: 5}}, + Bots: []ReplayBot{ + {Position: Position{Row: 5, Col: 4}, Alive: true, Owner: 0}, // Only player 0 adjacent + }, + }, + { + Turn: 1, + Energy: []Position{}, // No energy + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + }, + }, + }, + Map: ReplayMap{ + Walls: []Position{{Row: 15, Col: 15}}, + }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, + } + + score := CalculateMapEngagement(replay) + + if score.ResourceContestTurns != 0 { + t.Errorf("Expected 0 resource contest turns, got %d", score.ResourceContestTurns) + } +} + +// TestMapEngagement_PlayerElimination verifies survival turns count decreases when a player is eliminated. +func TestMapEngagement_PlayerElimination(t *testing.T) { + replay := &Replay{ + Config: Config{Rows: 20, Cols: 20, MaxTurns: 100}, + Result: &MatchResult{Turns: 50, Scores: []int{5, 4}}, + Turns: []ReplayTurn{ + { + Turn: 0, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: true, Owner: 1}, + }, + }, + { + Turn: 1, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: true, Owner: 1}, + }, + }, + { + Turn: 2, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: false, Owner: 1}, // Eliminated + }, + }, + { + Turn: 3, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + {Position: Position{Row: 10, Col: 10}, Alive: false, Owner: 1}, // Still dead + }, + }, + }, + Map: ReplayMap{ + Walls: []Position{{Row: 15, Col: 15}}, + }, + Players: []ReplayPlayer{{ID: 0}, {ID: 1}}, + } + + score := CalculateMapEngagement(replay) + + // Only turns 0 and 1 have all players alive + if score.SurvivalTurns != 2 { + t.Errorf("Expected 2 survival turns, got %d", score.SurvivalTurns) + } +} + // TestWinProb_ComputeAndSet verifies that ComputeWinProbability produces // valid results and SetWinProbability correctly stores them. func TestWinProb_ComputeAndSet(t *testing.T) {