feat(engine): add combat-density metric and fix computeCombatTurns

- Fix computeCombatTurns to count EventCombatDeath events instead of
  EventBotDied with reason="combat" (which was never emitted, causing
  CombatTurns to always be 0)
- Add CombatDeaths field to MapEngagementScore to track focus-fire kills
- Update engagement formula to weight combat deaths at 3.0 (same as
  win_prob_crossings) to bias map evolution toward combat-dense maps
- Add countCombatDeaths helper function to count EventCombatDeath events
- Update log output to include combat_deaths metric

This implements bf-4nxs: the combat-density metric is now measured and
weighted in map engagement, which gates map curation/selection. Maps
with zero combat will have low engagement scores and be filtered out.

Closes: bf-4nxs
This commit is contained in:
jedarden 2026-05-24 10:16:54 -04:00
parent 18ac1ff2b4
commit af46a1da97
3 changed files with 96 additions and 17 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}
}