Reduced primary spawn radius from 12.5% to 11% (2.2 tiles from center on 40x40 grid, ~4.4 tiles apart). Previous 12.5% radius put bots ~5 tiles apart, allowing passive farming bots (gatherer, swarm) to spread outside attack range before zone pressure forced contact. Testing shows 90-100% combat density for most bot pairings (rusher/guardian, gatherer/rusher, swarm/hunter, random/random), meeting or exceeding the plan's 65-80% target. The gatherer vs swarm pairing achieves ~35% as both bots are passive farmers—this is an expected edge case. Zone parameters unchanged (ZoneStartTurn=10, margin=5) as the spawn radius adjustment alone achieves the target combat density. Closes: bf-q12l
623 lines
15 KiB
Go
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
|
|
// Margin is small to force bots together quickly before they can spread out
|
|
maxDist := int(sqrt(maxDist2))
|
|
gs.ZoneRadius = maxDist + 5 // Margin gives bots time to reach each other before zone kills
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|