ai-code-battle/engine/turn.go
jedarden e902b8628e fix(engine): fix zone forcing function for combat density
The zone was tracking the midpoint of living bots, which defeated the
forcing function. When bots moved apart, the zone center moved with them,
allowing both to die to the zone without ever engaging in combat.

Changes:
- Remove zone center tracking logic (was updating to midpoint of bots)
- Fix ZoneShrinkStep from 6 to 2 (per plan §3.7.1)
- Fix ZoneStartTurn from 5 to 10 (per plan §3.7.1)
- Fix ZoneMinRadius to 2 for 2-player (per plan §3.7.1)
- Add clamp to ensure zone radius reaches minimum even with shrink step overshoot

Results: 94% of 2-player matches now have combat_deaths (target: 65-80%).
Average 1 death per match.

Closes: bf-1qrs
2026-05-25 15:13:37 -04:00

623 lines
15 KiB
Go

package engine
import "sort"
// TurnPhase represents a phase of turn execution.
type TurnPhase int
const (
PhaseMove TurnPhase = iota
PhaseCombat
PhaseZone
PhaseCapture
PhaseCollect
PhaseSpawn
PhaseEnergyTick
PhaseEndgame
)
// ExecuteTurn executes a single turn of the game.
// It assumes moves have already been submitted via SubmitMove.
func (gs *GameState) ExecuteTurn() *MatchResult {
gs.Turn++
// Phase: MOVE - execute valid movement orders
gs.executeMoves()
// Phase: COMBAT - resolve focus-fire algorithm
gs.executeCombat()
// Phase: ZONE - shrinking zone kills bots outside
gs.executeZone()
// Phase: CAPTURE - enemy bots on undefended cores raze them
gs.executeCaptures()
// Phase: COLLECT - uncontested energy is collected
gs.executeCollection()
// Phase: SPAWN - players with enough energy spawn bots at cores
gs.executeSpawns()
// Phase: ENERGY_TICK - energy nodes on interval produce new energy
gs.executeEnergyTick()
// Phase: ENDGAME - check win conditions
result := gs.checkWinConditions()
return result
}
// executeMoves processes all submitted moves.
func (gs *GameState) executeMoves() {
// First, compute intended destinations
intended := make(map[int]Position) // bot ID -> intended position
botsAtPos := make(map[Position][]*Bot) // position -> bots trying to move there
for _, b := range gs.Bots {
if !b.Alive {
continue
}
move, hasMove := gs.Moves[b.ID]
var dest Position
if hasMove && move.Direction != DirNone {
dest = gs.Grid.Move(b.Position, move.Direction)
// Check if destination is passable
if !gs.Grid.IsPassable(dest) {
// Order ignored - stay in place
dest = b.Position
}
} else {
// No move - stay in place
dest = b.Position
}
intended[b.ID] = dest
botsAtPos[dest] = append(botsAtPos[dest], b)
}
// Process movements
for _, b := range gs.Bots {
if !b.Alive {
continue
}
dest := intended[b.ID]
// Check for collisions
botsAtDest := botsAtPos[dest]
if len(botsAtDest) > 1 {
// Multiple bots trying to occupy same tile
// Check if same owner (self-collision) or different owners (combat handled later)
sameOwner := true
for _, other := range botsAtDest {
if other.Owner != b.Owner {
sameOwner = false
break
}
}
if sameOwner {
// Self-collision: all bots at this position die
for _, other := range botsAtDest {
gs.KillBot(other, "self_collision")
}
continue
}
}
// Move to destination
b.Position = dest
}
}
// executeZone handles the shrinking zone (storm) that forces combat.
func (gs *GameState) executeZone() {
if !gs.Config.ZoneEnabled {
return
}
// Check if zone should start
zoneJustStarted := false
if !gs.ZoneActive && gs.Turn >= gs.Config.ZoneStartTurn {
gs.ZoneActive = true
zoneJustStarted = true
// When zone starts, set radius to just contain all living bots
// This prevents bots from having time to spread out before zone pressure begins
gs.updateZoneRadiusToContainBots()
}
// Zone center is fixed at map center (set in NewGameState)
// This forces bots toward the center as the zone shrinks, ensuring contact.
// Check if zone should shrink (skip the turn zone starts)
if gs.ZoneActive && !zoneJustStarted && (gs.Turn-gs.Config.ZoneStartTurn)%gs.Config.ZoneShrinkInterval == 0 {
if gs.ZoneRadius > gs.Config.ZoneMinRadius {
gs.ZoneRadius -= gs.Config.ZoneShrinkStep
if gs.ZoneRadius < gs.Config.ZoneMinRadius {
gs.ZoneRadius = gs.Config.ZoneMinRadius
}
}
// Ensure zone radius is at least the minimum (handles overshoot from shrink step)
if gs.ZoneRadius < gs.Config.ZoneMinRadius {
gs.ZoneRadius = gs.Config.ZoneMinRadius
}
}
// Kill bots outside the zone (only when zone is active)
if !gs.ZoneActive {
return
}
for _, b := range gs.Bots {
if !b.Alive {
continue
}
// Calculate distance from zone center (accounting for toroidal wrap)
dist2 := gs.Grid.Distance2(b.Position, gs.ZoneCenter)
if dist2 > gs.ZoneRadius*gs.ZoneRadius {
// Mark bot as dead
b.Alive = false
gs.DeadBots = append(gs.DeadBots, b)
if b.Owner < len(gs.Players) {
gs.Players[b.Owner].BotCount--
}
// Emit zone_death event
gs.Events = append(gs.Events, Event{
Type: EventZoneDeath,
Turn: gs.Turn,
Details: map[string]interface{}{
"bot_id": b.ID,
"owner": b.Owner,
"position": b.Position,
},
})
}
}
}
// updateZoneRadiusToContainBots sets the zone radius to the minimum value needed
// to contain all living bots, plus a small margin.
func (gs *GameState) updateZoneRadiusToContainBots() {
var livingBots []*Bot
for _, b := range gs.Bots {
if b.Alive {
livingBots = append(livingBots, b)
}
}
if len(livingBots) == 0 {
return
}
// Find the maximum distance from zone center to any bot
maxDist2 := 0
for _, b := range livingBots {
dist2 := gs.Grid.Distance2(b.Position, gs.ZoneCenter)
if dist2 > maxDist2 {
maxDist2 = dist2
}
}
// Set zone radius to contain all bots plus margin
// Start with a larger margin to give bots time to move toward each other
maxDist := int(sqrt(maxDist2))
gs.ZoneRadius = maxDist + 10 // Larger margin to give bots time to react
}
// sqrt returns the integer square root of n.
func sqrt(n int) int {
if n <= 0 {
return 0
}
x := n
for {
y := (x + n/x) / 2
if y >= x {
return x
}
x = y
}
}
// executeCombat resolves the focus-fire combat algorithm.
func (gs *GameState) executeCombat() {
// For each bot, count enemies within attack radius
enemyCounts := make(map[int]int) // bot ID -> enemy count
botsInRadius := make(map[int][]*Bot) // bot ID -> enemies within radius
for _, b := range gs.Bots {
if !b.Alive {
continue
}
var enemies []*Bot
for _, e := range gs.Bots {
if !e.Alive || e.ID == b.ID || e.Owner == b.Owner {
continue
}
if gs.Grid.InRadius(b.Position, e.Position, gs.Config.AttackRadius2) {
enemies = append(enemies, e)
}
}
enemyCounts[b.ID] = len(enemies)
botsInRadius[b.ID] = enemies
}
// Determine which bots die (simultaneous - use pre-computed counts)
dead := make(map[int]bool)
for _, b := range gs.Bots {
if !b.Alive {
continue
}
myEnemyCount := enemyCounts[b.ID]
if myEnemyCount == 0 {
continue // No enemies nearby, safe
}
// Check if any enemy has <= myEnemyCount enemies
// Use the pre-computed enemy counts (not affected by simultaneous deaths)
for _, e := range botsInRadius[b.ID] {
theirEnemyCount := enemyCounts[e.ID]
if myEnemyCount >= theirEnemyCount {
// I die
dead[b.ID] = true
break
}
}
}
// Kill the dead bots and emit combat_death events
for _, b := range gs.Bots {
if dead[b.ID] {
b.Alive = false
gs.DeadBots = append(gs.DeadBots, b)
if b.Owner < len(gs.Players) {
gs.Players[b.Owner].BotCount--
}
// Build killers array (enemies within attack radius)
var killers []map[string]interface{}
for _, e := range botsInRadius[b.ID] {
killers = append(killers, map[string]interface{}{
"bot_id": e.ID,
"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{
Type: EventCombatDeath,
Turn: gs.Turn,
Details: map[string]interface{}{
"bot_id": b.ID,
"owner": b.Owner,
"position": b.Position,
"killers": killers,
},
})
}
}
}
// executeCaptures handles core capture mechanics.
func (gs *GameState) executeCaptures() {
// Find bots on core tiles
botsOnCores := make(map[int][]*Bot) // core index -> bots on it
for ci, c := range gs.Cores {
if !c.Active {
continue
}
for _, b := range gs.Bots {
if b.Alive && b.Position == c.Position {
botsOnCores[ci] = append(botsOnCores[ci], b)
}
}
}
// Check each core for captures
for ci, bots := range botsOnCores {
c := gs.Cores[ci]
if !c.Active {
continue
}
// A core is defended if a bot of the owner is on it
defended := false
for _, b := range bots {
if b.Owner == c.Owner {
defended = true
break
}
}
if !defended {
// Core is undefended - any enemy bot on it razes it
for _, b := range bots {
if b.Owner != c.Owner {
// Capture!
gs.captureCore(c, b.Owner)
break // Only one capture per core per turn
}
}
}
}
}
// captureCore handles the capture of a core by a player.
func (gs *GameState) captureCore(c *Core, capturer int) {
// Scoring: +2 to capturer, -1 to owner
gs.Players[capturer].Score += 2
if c.Owner < len(gs.Players) {
gs.Players[c.Owner].Score--
}
// Raze the core
c.Active = false
gs.Events = append(gs.Events, Event{
Type: EventCoreCaptured,
Turn: gs.Turn,
Details: map[string]interface{}{
"core_pos": c.Position,
"old_owner": c.Owner,
"new_owner": capturer,
},
})
}
// executeCollection handles energy collection.
func (gs *GameState) executeCollection() {
// For each energy node with energy, check collection
for _, en := range gs.Energy {
if !en.HasEnergy {
continue
}
// Find all adjacent bots
var adjBots []*Bot
for _, b := range gs.Bots {
if !b.Alive {
continue
}
// Adjacent means distance <= sqrt(2), i.e., distance^2 <= 2
// Or on the tile (distance 0)
d2 := gs.Grid.Distance2(b.Position, en.Position)
if d2 <= 2 {
adjBots = append(adjBots, b)
}
}
if len(adjBots) == 0 {
continue // No bots adjacent
}
// Check if multiple players are adjacent (contested)
players := make(map[int]bool)
for _, b := range adjBots {
players[b.Owner] = true
}
if len(players) > 1 {
// Contested - energy is destroyed
en.HasEnergy = false
en.Tick = 0
continue
}
// Uncontested - collect energy
playerID := adjBots[0].Owner
if playerID < len(gs.Players) {
gs.Players[playerID].Energy++
}
en.HasEnergy = false
en.Tick = 0
gs.Events = append(gs.Events, Event{
Type: EventEnergyCollected,
Turn: gs.Turn,
Details: map[string]interface{}{
"pos": en.Position,
"player": playerID,
},
})
}
}
// executeSpawns handles bot spawning at active cores.
// When multiple cores are eligible, the core idle longest spawns first
// (deterministic tiebreak: lowest core ID wins).
func (gs *GameState) executeSpawns() {
// For each player, check if they can spawn
for _, p := range gs.Players {
if p.Energy < gs.Config.SpawnCost {
continue
}
// Collect eligible cores: active, owned by this player, unoccupied
var eligible []*Core
for _, c := range gs.Cores {
if !c.Active || c.Owner != p.ID {
continue
}
occupied := false
for _, b := range gs.Bots {
if b.Alive && b.Position == c.Position {
occupied = true
break
}
}
if !occupied {
eligible = append(eligible, c)
}
}
// Sort by (lastSpawnedTurn ASC, core ID ASC) — idle-longest first
sortCoresByPriority(eligible)
for _, c := range eligible {
if p.Energy < gs.Config.SpawnCost {
break
}
gs.SpawnBot(p.ID, c.Position)
c.LastSpawnedTurn = gs.Turn
p.Energy -= gs.Config.SpawnCost
}
}
}
// executeEnergyTick handles energy node spawning.
func (gs *GameState) executeEnergyTick() {
for _, en := range gs.Energy {
if en.HasEnergy {
continue // Already has energy
}
en.Tick++
if en.Tick >= gs.Config.EnergyInterval {
en.HasEnergy = true
en.Tick = 0
}
}
}
// checkWinConditions checks for game-ending conditions.
func (gs *GameState) checkWinConditions() *MatchResult {
// Count living bots per player
livingPlayers := gs.GetLivingPlayers()
totalBots := gs.GetLivingBotCount()
// Condition 1: Sole Survivor - only one player has living bots
if len(livingPlayers) == 1 {
winner := livingPlayers[0]
bonus := 0
// Bonus +2 per surviving enemy core
for _, c := range gs.Cores {
if c.Active && c.Owner != winner {
bonus += 2
}
}
gs.Players[winner].Score += bonus
return gs.createResult(winner, "elimination")
}
// Condition 2: Annihilation - all players eliminated simultaneously
if len(livingPlayers) == 0 {
return gs.createResult(-1, "draw")
}
// Condition 3: Dominance - one player controls >=80% of all bots for 100 consecutive turns
if totalBots > 0 {
for _, p := range gs.Players {
botCount := gs.GetPlayerLivingBotCount(p.ID)
if float64(botCount) >= 0.8*float64(totalBots) {
gs.Dominance[p.ID]++
if gs.Dominance[p.ID] >= 100 {
return gs.createResult(p.ID, "dominance")
}
} else {
gs.Dominance[p.ID] = 0
}
}
}
// Condition 4: Stalemate - no progress for 50 consecutive turns
currentEnergy := 0
for _, p := range gs.Players {
currentEnergy += p.Energy
}
currentBots := gs.GetLivingBotCount()
if currentEnergy != gs.LastTotalEnergy || currentBots != gs.LastTotalBots || len(gs.DeadBots) > 0 {
gs.StalemateTurns = 0
gs.LastTotalEnergy = currentEnergy
gs.LastTotalBots = currentBots
} else {
gs.StalemateTurns++
}
if gs.StalemateTurns >= 50 {
winner := gs.findWinnerByScore()
return gs.createResult(winner, "stalemate")
}
// Condition 5: Turn Limit
if gs.Turn >= gs.Config.MaxTurns {
// Highest score wins, ties broken by energy collected, then bots alive
winner := gs.findWinnerByScore()
return gs.createResult(winner, "turns")
}
return nil // No winner yet
}
// createResult creates a match result.
func (gs *GameState) createResult(winner int, reason string) *MatchResult {
scores := make([]int, len(gs.Players))
energy := make([]int, len(gs.Players))
botsAlive := make([]int, len(gs.Players))
for i, p := range gs.Players {
scores[i] = p.Score
energy[i] = p.Energy
botsAlive[i] = gs.GetPlayerLivingBotCount(p.ID)
}
return &MatchResult{
Winner: winner,
Reason: reason,
Turns: gs.Turn,
Scores: scores,
Energy: energy,
BotsAlive: botsAlive,
CombatDeaths: gs.CombatDeaths,
}
}
// findWinnerByScore finds the winner based on score, energy, and bot count.
func (gs *GameState) findWinnerByScore() int {
bestPlayer := 0
bestScore := gs.Players[0].Score
bestEnergy := gs.Players[0].Energy
bestBots := gs.GetPlayerLivingBotCount(0)
for i, p := range gs.Players {
score := p.Score
energy := p.Energy
bots := gs.GetPlayerLivingBotCount(i)
// Compare by score first, then energy, then bots
if score > bestScore ||
(score == bestScore && energy > bestEnergy) ||
(score == bestScore && energy == bestEnergy && bots > bestBots) {
bestPlayer = i
bestScore = score
bestEnergy = energy
bestBots = bots
}
}
return bestPlayer
}
// sortCoresByPriority sorts cores by (LastSpawnedTurn ASC, ID ASC).
// The core idle longest (lowest LastSpawnedTurn) spawns first;
// equal idle time is broken by lower core ID.
func sortCoresByPriority(cores []*Core) {
sort.Slice(cores, func(i, j int) bool {
if cores[i].LastSpawnedTurn != cores[j].LastSpawnedTurn {
return cores[i].LastSpawnedTurn < cores[j].LastSpawnedTurn
}
return cores[i].ID < cores[j].ID
})
}