feat(worker): implement map engagement scoring per plan §14.6
Update the map engagement scoring formula to match plan §14.6:
- score = win_prob_crossings * 3.0 + critical_moments * 2.0 +
resource_contest_turns * 1.5 + survival_turns * 0.5
New metrics computed from replay data:
- resource_contest_turns: turns where energy is contested by multiple players
- survival_turns: turns where all players have at least one bot alive
The old formula used map_coverage_pct, closeness, and turn_pct which
did not match the specification.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e88c108010
commit
df7a3e38c7
3 changed files with 368 additions and 31 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, 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue