diff --git a/engine/game.go b/engine/game.go index 35651a4..3007e42 100644 --- a/engine/game.go +++ b/engine/game.go @@ -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 } diff --git a/engine/map_engagement_test.go b/engine/map_engagement_test.go index e5a9388..52b4864 100644 --- a/engine/map_engagement_test.go +++ b/engine/map_engagement_test.go @@ -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, diff --git a/engine/turn.go b/engine/turn.go index 3777fc9..ca0729f 100644 --- a/engine/turn.go +++ b/engine/turn.go @@ -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, } } diff --git a/engine/types.go b/engine/types.go index 532546e..53a5632 100644 --- a/engine/types.go +++ b/engine/types.go @@ -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.