feat(engine): add combat_deaths to MatchResult statistics

Add CombatDeaths []int field to MatchResult to track combat density
per player. This enables monitoring of focus-fire combat across all
matches and helps verify that the zone forcing function is working.

Changes:
- Add CombatDeaths []int to MatchResult struct
- Add CombatDeaths []int to GameState for tracking during match
- Increment combat death count for each killer in executeCombat
- Populate combat_deaths in final match result
- Update tests to include CombatDeaths in MatchResult

Verified: 6-player match shows combat_deaths: [1,1,1,1,1,1] (each
player killed 1 bot in mutual combat).

Closes: bf-4fez

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-24 17:59:57 -04:00
parent da5e3f1479
commit b3982ab6d7
4 changed files with 50 additions and 41 deletions

View file

@ -32,9 +32,10 @@ type GameState struct {
LastTotalBots int // total living bots at last progress
// Zone (storm) state
ZoneCenter Position // center of the zone (map center)
ZoneRadius int // current radius of the safe zone
ZoneActive bool // whether the zone is currently shrinking
ZoneCenter Position // center of the zone (map center)
ZoneRadius int // current radius of the safe zone
ZoneActive bool // whether the zone is currently shrinking
CombatDeaths []int // combat deaths per player (tracked for final stats)
}
// Event represents something that happened during a turn.
@ -61,23 +62,24 @@ func NewGameState(config Config, rng *rand.Rand) *GameState {
initialRadius := min(config.Rows, config.Cols) / 2
return &GameState{
Config: config,
Grid: NewGrid(config.Rows, config.Cols),
Bots: make([]*Bot, 0),
Cores: make([]*Core, 0),
Energy: make([]*EnergyNode, 0),
Players: make([]*Player, 0),
Turn: 0,
MatchID: generateMatchID(rng),
NextBotID: 0,
rng: rng,
Moves: make(map[int]Move),
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: center,
ZoneRadius: initialRadius,
ZoneActive: false,
Config: config,
Grid: NewGrid(config.Rows, config.Cols),
Bots: make([]*Bot, 0),
Cores: make([]*Core, 0),
Energy: make([]*EnergyNode, 0),
Players: make([]*Player, 0),
Turn: 0,
MatchID: generateMatchID(rng),
NextBotID: 0,
rng: rng,
Moves: make(map[int]Move),
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: center,
ZoneRadius: initialRadius,
ZoneActive: false,
CombatDeaths: make([]int, 0), // Will be sized when players are added
}
}
@ -100,6 +102,7 @@ func (gs *GameState) AddPlayer() *Player {
}
gs.Players = append(gs.Players, p)
gs.Dominance[p.ID] = 0
gs.CombatDeaths = append(gs.CombatDeaths, 0) // Track combat deaths for this player
return p
}

View file

@ -11,7 +11,7 @@ func TestMapEngagement_WinProbDependency(t *testing.T) {
// Create a replay with alternating win probs to simulate lead changes
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
WinProb: []WinProbEntry{
{0.6, 0.4}, // Player 0 leading
{0.4, 0.6}, // Player 1 leading - 1st crossing
@ -46,7 +46,7 @@ func TestMapEngagement_WinProbDependency(t *testing.T) {
func TestMapEngagement_CriticalMomentsDependency(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
CriticalMoments: []CriticalMoment{
{Turn: 10, Delta: 0.20, Player: 0, Description: "Player 0 scores"},
{Turn: 25, Delta: -0.25, Player: 1, Description: "Player 1 fights back"},
@ -78,7 +78,7 @@ func TestMapEngagement_CriticalMomentsDependency(t *testing.T) {
func TestMapEngagement_ResourceContestTurns(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
Turns: []ReplayTurn{
{
Turn: 0,
@ -114,7 +114,7 @@ func TestMapEngagement_ResourceContestTurns(t *testing.T) {
func TestMapEngagement_SurvivalTurns(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
Turns: []ReplayTurn{
{
Turn: 0,
@ -168,7 +168,7 @@ func TestMapEngagement_EmptyReplay(t *testing.T) {
func TestMapEngagement_Formula(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
WinProb: []WinProbEntry{
{0.6, 0.4}, // Player 0 leading
{0.4, 0.6}, // Player 1 leading - 1st crossing
@ -241,7 +241,7 @@ func TestMapEngagement_Formula(t *testing.T) {
func TestMapEngagement_NoContestedEnergy(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
Turns: []ReplayTurn{
{
Turn: 0,
@ -275,7 +275,7 @@ func TestMapEngagement_NoContestedEnergy(t *testing.T) {
func TestMapEngagement_PlayerElimination(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
Turns: []ReplayTurn{
{
Turn: 0,
@ -373,7 +373,7 @@ func TestWinProb_ComputeAndSet(t *testing.T) {
func TestMapEngagement_CombatDeaths(t *testing.T) {
replay := &Replay{
Config: Config{Rows: 20, Cols: 20, MaxTurns: 100},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}},
Result: &MatchResult{Turns: 50, Scores: []int{5, 4}, CombatDeaths: []int{0, 0}},
Turns: []ReplayTurn{
{
Turn: 0,

View file

@ -218,6 +218,10 @@ func (gs *GameState) executeCombat() {
"owner": e.Owner,
"position": e.Position,
})
// Track combat deaths for the killer's player
if e.Owner < len(gs.CombatDeaths) {
gs.CombatDeaths[e.Owner]++
}
}
gs.Events = append(gs.Events, Event{
@ -497,12 +501,13 @@ func (gs *GameState) createResult(winner int, reason string) *MatchResult {
}
return &MatchResult{
Winner: winner,
Reason: reason,
Turns: gs.Turn,
Scores: scores,
Energy: energy,
BotsAlive: botsAlive,
Winner: winner,
Reason: reason,
Turns: gs.Turn,
Scores: scores,
Energy: energy,
BotsAlive: botsAlive,
CombatDeaths: gs.CombatDeaths,
}
}

View file

@ -258,13 +258,14 @@ func ConfigForPlayers(numPlayers, coresPerPlayer int) Config {
// MatchResult represents the outcome of a match.
type MatchResult struct {
Winner int `json:"winner"` // -1 for draw
Reason string `json:"reason"` // "elimination", "dominance", "turns", "draw"
Turns int `json:"turns"`
Scores []int `json:"scores"`
Energy []int `json:"energy"` // energy collected per player
BotsAlive []int `json:"bots_alive"`
Crashed []bool `json:"crashed"` // per-player: true if bot was marked crashed during match
Winner int `json:"winner"` // -1 for draw
Reason string `json:"reason"` // "elimination", "dominance", "turns", "draw"
Turns int `json:"turns"`
Scores []int `json:"scores"`
Energy []int `json:"energy"` // energy collected per player
BotsAlive []int `json:"bots_alive"`
Crashed []bool `json:"crashed"` // per-player: true if bot was marked crashed during match
CombatDeaths []int `json:"combat_deaths"` // bots killed in combat per player (focus-fire)
}
// BotInterface defines the interface for bot decision-making.