ai-code-battle/engine/turn.go
jedarden c56cc8bae6 fix(matchmaker): multi-match crash cooldown (3 strikes / 30 min) per §4.5 + §6.1
Add crash_strikes and cooldown_until columns to bots table. Worker
increments strikes on crash (cooldown at 3), resets on success.
Matchmaker excludes cooldown bots from pairing, series scheduling,
and championship brackets. Fix erroneous cooldown filter on series
table in finalizeCompletedSeries (column only exists on bots).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:22:12 -04:00

476 lines
11 KiB
Go

package engine
import "sort"
// TurnPhase represents a phase of turn execution.
type TurnPhase int
const (
PhaseMove TurnPhase = iota
PhaseCombat
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: 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
}
}
// 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
for _, b := range gs.Bots {
if dead[b.ID] {
gs.KillBot(b, "combat")
}
}
}
// 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,
}
}
// 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
})
}