diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 578c33f..173df0f 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, resource_contest_turns=%d, survival_turns=%d, score=%.2f", - engagement.WinProbCrossings, engagement.CriticalMoments, engagement.ResourceContestTurns, engagement.SurvivalTurns, engagement.Engagement) + w.logger.Printf("Map engagement: crossings=%.0f, combat_deaths=%d, critical_moments=%d, resource_contest_turns=%d, survival_turns=%d, score=%.2f", + engagement.WinProbCrossings, engagement.CombatDeaths, 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 { @@ -424,8 +424,8 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma } // computeCombatTurns counts the number of distinct turns in a replay where at -// least one bot was killed by an enemy (reason == "combat"). Deaths from -// self-collision or other causes are excluded. +// least one bot died from focus-fire combat (EventCombatDeath). Deaths from +// self-collision, zone, or other causes are excluded. func computeCombatTurns(replay *engine.Replay) int { if replay == nil { return 0 @@ -433,15 +433,7 @@ func computeCombatTurns(replay *engine.Replay) int { combatTurnSet := make(map[int]struct{}) for _, turn := range replay.Turns { for _, event := range turn.Events { - if event.Type != engine.EventBotDied { - continue - } - details, ok := event.Details.(map[string]interface{}) - if !ok { - continue - } - reason, _ := details["reason"].(string) - if reason == "combat" { + if event.Type == engine.EventCombatDeath { combatTurnSet[turn.Turn] = struct{}{} break // one combat death is enough to count this turn } diff --git a/engine/map_engagement.go b/engine/map_engagement.go index 4cb2404..fd455f6 100644 --- a/engine/map_engagement.go +++ b/engine/map_engagement.go @@ -6,14 +6,16 @@ import "math" 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) is: -// score = win_prob_crossings * 3.0 + critical_moments * 2.0 + resource_contest_turns * 1.5 + survival_turns * 0.5 +// 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{} @@ -22,6 +24,9 @@ func CalculateMapEngagement(replay *Replay) 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) @@ -32,7 +37,9 @@ func CalculateMapEngagement(replay *Replay) MapEngagementScore { 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 @@ -40,6 +47,7 @@ func CalculateMapEngagement(replay *Replay) MapEngagementScore { return MapEngagementScore{ WinProbCrossings: winProbCrossings, CriticalMoments: criticalMoments, + CombatDeaths: combatDeaths, ResourceContestTurns: resourceContestTurns, SurvivalTurns: survivalTurns, Engagement: engagement, @@ -261,6 +269,24 @@ func allPlayersAlive(turn ReplayTurn, numPlayers int) bool { 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) diff --git a/engine/map_engagement_test.go b/engine/map_engagement_test.go index 700899b..19a87eb 100644 --- a/engine/map_engagement_test.go +++ b/engine/map_engagement_test.go @@ -204,12 +204,13 @@ func TestMapEngagement_Formula(t *testing.T) { // Count each metric winProbCrossings := 1.0 // One lead change + combatDeaths := 0 // No combat deaths in this replay 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 + // Expected formula: 1.0*3.0 + 0*3.0 + 1*2.0 + 1*1.5 + 2*0.5 = 3.0 + 0 + 2.0 + 1.5 + 1.0 = 7.5 + expectedEngagement := winProbCrossings*3.0 + float64(combatDeaths)*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) @@ -219,6 +220,10 @@ func TestMapEngagement_Formula(t *testing.T) { t.Errorf("Expected win_prob_crossings %.0f, got %.0f", winProbCrossings, score.WinProbCrossings) } + if score.CombatDeaths != combatDeaths { + t.Errorf("Expected combat_deaths %d, got %d", combatDeaths, score.CombatDeaths) + } + if score.CriticalMoments != criticalMoments { t.Errorf("Expected critical_moments %d, got %d", criticalMoments, score.CriticalMoments) } @@ -363,3 +368,59 @@ func TestWinProb_ComputeAndSet(t *testing.T) { t.Errorf("Replay has %d critical moments, want %d", len(replay.CriticalMoments), len(moments)) } } + +// TestMapEngagement_CombatDeaths verifies that focus-fire combat deaths are counted correctly. +func TestMapEngagement_CombatDeaths(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, + Events: []Event{ + {Type: EventCombatDeath, Turn: 0, Details: map[string]interface{}{"bot_id": 1, "owner": 0}}, + }, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 1}, + }, + }, + { + Turn: 1, + Events: []Event{ + {Type: EventCombatDeath, Turn: 1, Details: map[string]interface{}{"bot_id": 2, "owner": 1}}, + {Type: EventCombatDeath, Turn: 1, Details: map[string]interface{}{"bot_id": 3, "owner": 1}}, + }, + Bots: []ReplayBot{ + {Position: Position{Row: 0, Col: 0}, Alive: true, Owner: 0}, + }, + }, + { + Turn: 2, + Events: []Event{ + {Type: EventBotDied, Turn: 2, Details: map[string]interface{}{"bot_id": 4, "owner": 0, "reason": "zone"}}, + }, + 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) + + // Should count 3 combat deaths (1 on turn 0, 2 on turn 1) + // The zone death on turn 2 should NOT be counted + if score.CombatDeaths != 3 { + t.Errorf("Expected 3 combat deaths, got %d", score.CombatDeaths) + } + + // Combat deaths should contribute 3.0 * 3 = 9.0 to engagement + expectedCombatContribution := 3.0 * 3.0 + if score.Engagement < expectedCombatContribution { + t.Errorf("Expected engagement >= %.2f from combat deaths, got %.2f", expectedCombatContribution, score.Engagement) + } +}