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:
parent
18ac1ff2b4
commit
af46a1da97
3 changed files with 96 additions and 17 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue