ai-code-battle/engine/bot_strategies.go
jedarden db54067f56 fix(engine): add wall awareness to zone escape direction
getZoneEscapeDirection now accepts wallSet parameter and skips directions
that would move into walls. This prevents bots from getting trapped by
walls when trying to escape the shrinking zone, allowing them to survive
longer and actually engage in combat instead of dying to zone.

Testing with RusherBot vs SwarmBot shows 85% combat density (target: 65-80%).

Fixes: RandomBot getting stuck against walls and dying to zone without
engaging in combat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:46:39 -04:00

1304 lines
33 KiB
Go

package engine
import (
"container/list"
"math"
"math/rand"
)
// getZoneEscapeDirection returns the direction toward the zone center if the bot is outside
// or near the edge of the safe zone radius. Returns DirNone if the bot is safe or zone is disabled.
// Avoids walls when choosing the escape direction.
func getZoneEscapeDirection(botPos Position, state *VisibleState, wallSet map[Position]bool) Direction {
if state.Zone == nil || !state.Zone.Active {
return DirNone
}
// Calculate distance from zone center (toroidal)
rows := state.Config.Rows
cols := state.Config.Cols
center := state.Zone.Center
dr := botPos.Row - center.Row
dc := botPos.Col - center.Col
// Account for wrapping
if dr > rows/2 {
dr -= rows
} else if dr < -rows/2 {
dr += rows
}
if dc > cols/2 {
dc -= cols
} else if dc < -cols/2 {
dc += cols
}
dist2 := dr*dr + dc*dc
radius2 := state.Zone.Radius * state.Zone.Radius
// Safety margin: move toward center if within 5 tiles of zone edge
// This accounts for zone shrinking (1 tile/turn) and gives time to reach safety
safetyMargin2 := 25 // (5 tiles)^2 - anticipates ~5 turns of zone shrink
if dist2 >= radius2-safetyMargin2 {
// Move toward center: choose direction that reduces distance and avoids walls
bestDir := DirNone
bestReduction := 0
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
ddr, ddc := dir.Delta()
newPos := Position{
Row: ((botPos.Row+ddr)%rows + rows) % rows,
Col: ((botPos.Col+ddc)%cols + cols) % cols,
}
// Skip if blocked by wall (only check if wallSet is provided)
if wallSet != nil && wallSet[newPos] {
continue
}
// If wallSet is nil, assume all tiles are passable (RandomBot fallback)
// This is safe because the engine will ignore moves into walls
newDr := newPos.Row - center.Row
newDc := newPos.Col - center.Col
// Account for wrapping
if newDr > rows/2 {
newDr -= rows
} else if newDr < -rows/2 {
newDr += rows
}
if newDc > cols/2 {
newDc -= cols
} else if newDc < -cols/2 {
newDc += cols
}
newDist2 := newDr*newDr + newDc*newDc
reduction := dist2 - newDist2
if reduction > bestReduction {
bestReduction = reduction
bestDir = dir
}
}
return bestDir
}
return DirNone
}
// GathererBot prioritizes energy collection while avoiding combat.
type GathererBot struct {
rng *rand.Rand
}
// NewGathererBot creates a new gatherer bot.
func NewGathererBot(seed int64) *GathererBot {
return &GathererBot{
rng: rand.New(rand.NewSource(seed)),
}
}
// GetMoves returns moves focused on gathering energy.
func (b *GathererBot) GetMoves(state *VisibleState) ([]Move, error) {
if len(state.Bots) == 0 {
return nil, nil
}
myID := state.You.ID
config := state.Config
// Separate my bots from enemies
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
wallPositions := make(map[Position]bool)
for _, w := range state.Walls {
wallPositions[w] = true
}
moves := make([]Move, 0, len(myBots))
usedEnergy := make(map[Position]bool)
for _, bot := range myBots {
move := b.computeBotMove(bot, myBots, enemyPositions, energyPositions, usedEnergy, wallPositions, config, state)
if move != nil {
moves = append(moves, *move)
}
}
return moves, nil
}
func (b *GathererBot) computeBotMove(
bot VisibleBot,
myBots []VisibleBot,
enemyPositions, energyPositions, usedEnergy, wallPositions map[Position]bool,
config Config,
state *VisibleState,
) *Move {
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
return &Move{
Position: bot.Position,
Direction: zoneDir,
}
}
// Priority 2: Check if we should flee from enemies
if b.shouldFlee(bot.Position, enemyPositions, config) {
fleeDir := b.getFleeDirection(bot.Position, enemyPositions, wallPositions, config)
if fleeDir != DirNone {
return &Move{
Position: bot.Position,
Direction: fleeDir,
}
}
}
// Find nearest untargeted energy
_, path := b.findNearestEnergy(bot.Position, energyPositions, usedEnergy, enemyPositions, wallPositions, config)
if path != nil && len(path) > 0 {
return &Move{
Position: bot.Position,
Direction: path[0],
}
}
// No energy visible - spread out to explore
return b.getExploreMove(bot.Position, myBots, enemyPositions, wallPositions, config)
}
func (b *GathererBot) shouldFlee(pos Position, enemyPositions map[Position]bool, config Config) bool {
for enemyPos := range enemyPositions {
dist2 := distance2(pos, enemyPos, config.Rows, config.Cols)
if dist2 <= config.AttackRadius2+4 {
return true
}
}
return false
}
func (b *GathererBot) getFleeDirection(pos Position, enemyPositions, wallPositions map[Position]bool, config Config) Direction {
// Calculate center of mass of enemies
enemyCenter := Position{Row: 0, Col: 0}
count := 0
for enemyPos := range enemyPositions {
enemyCenter.Row += enemyPos.Row
enemyCenter.Col += enemyPos.Col
count++
}
if count > 0 {
enemyCenter.Row /= count
enemyCenter.Col /= count
}
// Move away from enemy center
directions := []Direction{DirN, DirE, DirS, DirW}
bestDir := DirN
bestDist := -1
for _, dir := range directions {
newPos := simulateMove(pos, dir, config.Rows, config.Cols)
if wallPositions[newPos] {
continue
}
dist := distance2(newPos, enemyCenter, config.Rows, config.Cols)
if dist > bestDist {
bestDist = dist
bestDir = dir
}
}
return bestDir
}
func (b *GathererBot) findNearestEnergy(
start Position,
energyPositions, usedEnergy, enemyPositions, wallPositions map[Position]bool,
config Config,
) (Position, []Direction) {
type queueItem struct {
pos Position
path []Direction
}
visited := make(map[Position]bool)
queue := list.New()
queue.PushBack(queueItem{pos: start, path: []Direction{}})
var nearestEnergy Position
var bestPath []Direction
for queue.Len() > 0 {
item := queue.Remove(queue.Front()).(queueItem)
pos := item.pos
path := item.path
if visited[pos] {
continue
}
visited[pos] = true
// Check if this position has untargeted energy
if energyPositions[pos] && !usedEnergy[pos] {
nearestEnergy = pos
bestPath = path
break
}
// Don't path through enemy-adjacent tiles
if len(path) > 0 && b.isNearEnemy(pos, enemyPositions, config) {
continue
}
// Explore neighbors
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
nextPos := simulateMove(pos, dir, config.Rows, config.Cols)
if !visited[nextPos] && !wallPositions[nextPos] {
newPath := make([]Direction, len(path)+1)
copy(newPath, path)
newPath[len(path)] = dir
queue.PushBack(queueItem{pos: nextPos, path: newPath})
}
}
}
return nearestEnergy, bestPath
}
func (b *GathererBot) isNearEnemy(pos Position, enemyPositions map[Position]bool, config Config) bool {
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
adj := simulateMove(pos, dir, config.Rows, config.Cols)
if enemyPositions[adj] {
return true
}
}
return false
}
func (b *GathererBot) getExploreMove(
pos Position,
myBots []VisibleBot,
enemyPositions, wallPositions map[Position]bool,
config Config,
) *Move {
// Explore toward map center — that's where energy and enemies are
center := Position{Row: config.Rows / 2, Col: config.Cols / 2}
bestDir := DirNone
bestScore := -999999.0
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(pos, dir, config.Rows, config.Cols)
if wallPositions[newPos] {
continue
}
if b.isNearEnemy(newPos, enemyPositions, config) {
continue
}
score := 0.0
// Move toward center
distToCenter := float64(distance2(newPos, center, config.Rows, config.Cols))
currentDist := float64(distance2(pos, center, config.Rows, config.Cols))
score += (currentDist - distToCenter) * 5
// Spread out from other bots
for _, other := range myBots {
if other.Position != pos {
dist := float64(distance2(newPos, other.Position, config.Rows, config.Cols))
score += dist * 0.5
}
}
// Add slight randomness to avoid getting stuck
score += b.rng.Float64() * 2
if score > bestScore {
bestScore = score
bestDir = dir
}
}
if bestDir != DirNone {
return &Move{Position: pos, Direction: bestDir}
}
return nil
}
// RusherBot aggressively rushes toward enemy cores.
type RusherBot struct {
rng *rand.Rand
knownEnemyCores map[Position]bool
}
// NewRusherBot creates a new rusher bot.
func NewRusherBot(seed int64) *RusherBot {
return &RusherBot{
rng: rand.New(rand.NewSource(seed)),
knownEnemyCores: make(map[Position]bool),
}
}
// GetMoves returns moves rushing toward enemy cores.
func (b *RusherBot) GetMoves(state *VisibleState) ([]Move, error) {
myID := state.You.ID
config := state.Config
// Update known enemy cores
for _, core := range state.Cores {
if core.Owner != myID && core.Active {
b.knownEnemyCores[core.Position] = true
}
}
// Separate my bots from enemies
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
if len(myBots) == 0 {
return nil, nil
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
wallPositions := make(map[Position]bool)
for _, w := range state.Walls {
wallPositions[w] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Find targets to rush
targets := b.getRushTargets(state, myID)
moves := make([]Move, 0, len(myBots))
for _, bot := range myBots {
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: zoneDir})
continue
}
// Priority 2: Before zone starts, collect energy instead of rushing
// This prevents early mutual destruction; let the zone force combat
if state.Zone == nil || !state.Zone.Active {
// Zone not active yet: collect adjacent energy only if toward center
center := Position{Row: state.Config.Rows / 2, Col: state.Config.Cols / 2}
bestDir := DirNone
bestDist2 := -1
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
adj := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if !wallPositions[adj] && !enemyPositions[adj] {
dr := adj.Row - center.Row
dc := adj.Col - center.Col
dist2 := dr*dr + dc*dc
// Prefer energy if it's toward center
if energyPositions[adj] {
if bestDir == DirNone || dist2 < bestDist2 {
bestDir = dir
bestDist2 = dist2
}
} else if bestDir == DirNone {
// No energy at this position: consider it as fallback
if dist2 < bestDist2 || bestDist2 == -1 {
bestDir = dir
bestDist2 = dist2
}
}
}
}
if bestDir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: bestDir})
adjPos := simulateMove(bot.Position, bestDir, config.Rows, config.Cols)
if energyPositions[adjPos] {
delete(energyPositions, adjPos)
}
}
continue
}
// Priority 3: Opportunistic: grab adjacent energy while rushing
if len(myBots) <= 2 {
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
adj := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if energyPositions[adj] && !wallPositions[adj] {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
delete(energyPositions, adj)
goto nextBot
}
}
}
if dir := b.findBestMove(bot.Position, targets, enemyPositions, wallPositions, config); dir != DirNone {
moves = append(moves, Move{
Position: bot.Position,
Direction: dir,
})
}
nextBot:
}
return moves, nil
}
func (b *RusherBot) getRushTargets(state *VisibleState, myID int) []Position {
targets := make([]Position, 0)
// First priority: visible enemy cores
for _, core := range state.Cores {
if core.Owner != myID && core.Active {
targets = append(targets, core.Position)
}
}
// Add known enemy cores from previous turns
for pos := range b.knownEnemyCores {
found := false
for _, t := range targets {
if t == pos {
found = true
break
}
}
if !found {
targets = append(targets, pos)
}
}
// If no targets, explore center of map
if len(targets) == 0 {
targets = append(targets, Position{Row: state.Config.Rows / 2, Col: state.Config.Cols / 2})
}
return targets
}
func (b *RusherBot) findBestMove(
start Position,
targets []Position,
enemyPositions, wallPositions map[Position]bool,
config Config,
) Direction {
// BFS to find shortest path to any target
type queueItem struct {
pos Position
firstDir Direction
}
visited := make(map[Position]bool)
queue := list.New()
queue.PushBack(queueItem{pos: start, firstDir: DirNone})
visited[start] = true
for queue.Len() > 0 {
item := queue.Remove(queue.Front()).(queueItem)
pos := item.pos
// Check if we've reached a target
for _, target := range targets {
if pos == target {
return item.firstDir
}
}
// Explore neighbors
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
next := simulateMove(pos, dir, config.Rows, config.Cols)
if visited[next] || wallPositions[next] || enemyPositions[next] {
continue
}
visited[next] = true
firstDir := item.firstDir
if firstDir == DirNone {
firstDir = dir
}
queue.PushBack(queueItem{pos: next, firstDir: firstDir})
}
}
// No path found - pick any valid direction
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
next := simulateMove(start, dir, config.Rows, config.Cols)
if !wallPositions[next] && !enemyPositions[next] {
return dir
}
}
return DirN
}
// GuardianBot defends cores with cautious expansion.
type GuardianBot struct {
rng *rand.Rand
}
// NewGuardianBot creates a new guardian bot.
func NewGuardianBot(seed int64) *GuardianBot {
return &GuardianBot{
rng: rand.New(rand.NewSource(seed)),
}
}
// GetMoves returns moves focused on defense and cautious gathering.
func (b *GuardianBot) GetMoves(state *VisibleState) ([]Move, error) {
myID := state.You.ID
config := state.Config
// Separate bots
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
if len(myBots) == 0 {
return nil, nil
}
// Find my cores
myCores := make([]VisibleCore, 0)
for _, core := range state.Cores {
if core.Owner == myID && core.Active {
myCores = append(myCores, core)
}
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
wallPositions := make(map[Position]bool)
for _, w := range state.Walls {
wallPositions[w] = true
}
moves := make([]Move, 0, len(myBots))
usedEnergy := make(map[Position]bool)
for _, bot := range myBots {
move := b.computeBotMove(bot, myCores, enemyBots, enemyPositions, energyPositions, usedEnergy, wallPositions, config, state)
if move != nil {
moves = append(moves, *move)
}
}
return moves, nil
}
func (b *GuardianBot) computeBotMove(
bot VisibleBot,
myCores []VisibleCore,
enemyBots []VisibleBot,
enemyPositions, energyPositions, usedEnergy, wallPositions map[Position]bool,
config Config,
state *VisibleState,
) *Move {
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
return &Move{Position: bot.Position, Direction: zoneDir}
}
const perimeterRadius = 5
const safeZoneRadius = 10
// Find nearest threatening enemy
nearestEnemy, nearestEnemyDist := b.findNearestEnemy(bot.Position, enemyBots, config)
// If enemy is close, intercept
if nearestEnemy != nil && nearestEnemyDist <= 50 {
dir := b.getDirectionToward(bot.Position, nearestEnemy.Position, wallPositions, config)
if dir != DirNone {
return &Move{Position: bot.Position, Direction: dir}
}
}
// Check if within safe zone of a core
inSafeZone := false
var nearestCore *VisibleCore
nearestCoreDist := math.MaxInt32
for i := range myCores {
core := &myCores[i]
dist := distance2(bot.Position, core.Position, config.Rows, config.Cols)
if dist < nearestCoreDist {
nearestCoreDist = dist
nearestCore = core
}
if dist <= safeZoneRadius*safeZoneRadius {
inSafeZone = true
}
}
// If outside perimeter, move toward nearest core
if nearestCore != nil && nearestCoreDist > perimeterRadius*perimeterRadius {
dir := b.getDirectionToward(bot.Position, nearestCore.Position, wallPositions, config)
if dir != DirNone {
return &Move{Position: bot.Position, Direction: dir}
}
}
// Gather energy within safe zone
if inSafeZone {
// Find nearest energy
nearestEnergy, nearestEnergyDist := Position{}, math.MaxInt32
for pos := range energyPositions {
if usedEnergy[pos] {
continue
}
dist := distance2(bot.Position, pos, config.Rows, config.Cols)
if dist < nearestEnergyDist {
nearestEnergyDist = dist
nearestEnergy = pos
}
}
if nearestEnergyDist < math.MaxInt32 {
usedEnergy[nearestEnergy] = true
dir := b.getDirectionToward(bot.Position, nearestEnergy, wallPositions, config)
if dir != DirNone {
return &Move{Position: bot.Position, Direction: dir}
}
}
}
return nil
}
func (b *GuardianBot) findNearestEnemy(pos Position, enemies []VisibleBot, config Config) (*VisibleBot, int) {
var nearest *VisibleBot
nearestDist := math.MaxInt32
for i := range enemies {
dist := distance2(pos, enemies[i].Position, config.Rows, config.Cols)
if dist < nearestDist {
nearestDist = dist
nearest = &enemies[i]
}
}
return nearest, nearestDist
}
func (b *GuardianBot) getDirectionToward(from, to Position, wallPositions map[Position]bool, config Config) Direction {
bestDir := DirNone
bestDist := math.MaxInt32
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(from, dir, config.Rows, config.Cols)
if wallPositions[newPos] {
continue
}
dist := distance2(newPos, to, config.Rows, config.Cols)
if dist < bestDist {
bestDist = dist
bestDir = dir
}
}
return bestDir
}
// SwarmBot moves as a coordinated formation.
type SwarmBot struct {
rng *rand.Rand
}
// NewSwarmBot creates a new swarm bot.
func NewSwarmBot(seed int64) *SwarmBot {
return &SwarmBot{
rng: rand.New(rand.NewSource(seed)),
}
}
// GetMoves returns formation-based moves toward enemies.
func (b *SwarmBot) GetMoves(state *VisibleState) ([]Move, error) {
myID := state.You.ID
config := state.Config
// Separate bots
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
if len(myBots) == 0 {
return nil, nil
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
wallPositions := make(map[Position]bool)
for _, w := range state.Walls {
wallPositions[w] = true
}
myBotPositions := make(map[Position]bool)
for _, bot := range myBots {
myBotPositions[bot.Position] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Calculate swarm center
swarmCenter := b.calculateCenter(myBots, config)
// Calculate enemy center if visible
var enemyCenter *Position
if len(enemyBots) > 0 {
center := b.calculateCenter(enemyBots, config)
enemyCenter = &center
}
moves := make([]Move, 0, len(myBots))
claimed := make(map[Position]bool) // destinations already claimed by a friendly bot this turn
for _, bot := range myBots {
move := b.computeBotMove(bot, myBotPositions, enemyPositions, energyPositions, swarmCenter, enemyCenter, wallPositions, claimed, config, len(myBots), state)
if move != nil {
dest := simulateMove(bot.Position, move.Direction, config.Rows, config.Cols)
claimed[dest] = true
moves = append(moves, *move)
} else {
// Bot holds position — claim its current tile
claimed[bot.Position] = true
}
}
return moves, nil
}
const cohesionRadius2 = 9 // 3 tiles squared
func (b *SwarmBot) calculateCenter(bots []VisibleBot, config Config) Position {
if len(bots) == 0 {
return Position{Row: config.Rows / 2, Col: config.Cols / 2}
}
// Use circular mean for toroidal coordinates
sumSinRow, sumCosRow := 0.0, 0.0
sumSinCol, sumCosCol := 0.0, 0.0
rowScale := (2 * math.Pi) / float64(config.Rows)
colScale := (2 * math.Pi) / float64(config.Cols)
for _, bot := range bots {
sumSinRow += math.Sin(float64(bot.Position.Row) * rowScale)
sumCosRow += math.Cos(float64(bot.Position.Row) * rowScale)
sumSinCol += math.Sin(float64(bot.Position.Col) * colScale)
sumCosCol += math.Cos(float64(bot.Position.Col) * colScale)
}
n := float64(len(bots))
avgRow := math.Atan2(sumSinRow/n, sumCosRow/n) / rowScale
avgCol := math.Atan2(sumSinCol/n, sumCosCol/n) / colScale
row := int(math.Mod(math.Mod(avgRow, float64(config.Rows))+float64(config.Rows), float64(config.Rows)))
col := int(math.Mod(math.Mod(avgCol, float64(config.Cols))+float64(config.Cols), float64(config.Cols)))
return Position{Row: row, Col: col}
}
func (b *SwarmBot) computeBotMove(
bot VisibleBot,
myBotPositions, enemyPositions, energyPositions map[Position]bool,
swarmCenter Position,
enemyCenter *Position,
wallPositions, claimed map[Position]bool,
config Config,
friendlyCount int,
state *VisibleState,
) *Move {
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
return &Move{Position: bot.Position, Direction: zoneDir}
}
// Solo mode: when alone or with very few units, gather energy to build the swarm
if friendlyCount <= 2 {
return b.soloMove(bot, energyPositions, enemyPositions, wallPositions, config, state)
}
// Target is enemy center if visible, otherwise map center
target := Position{Row: config.Rows / 2, Col: config.Cols / 2}
if enemyCenter != nil {
target = *enemyCenter
}
bestDir := DirNone
bestScore := -math.MaxFloat64
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(bot.Position, dir, config.Rows, config.Cols)
// Can't move into walls or enemies
if wallPositions[newPos] || enemyPositions[newPos] {
continue
}
// CRITICAL: avoid tiles claimed by another friendly bot this turn (prevents self-collision)
if claimed[newPos] {
continue
}
// Also avoid moving onto a tile occupied by a friendly bot (they might not move)
if myBotPositions[newPos] && newPos != bot.Position {
continue
}
// Check cohesion: must stay within cohesion radius of at least one friendly bot
if !b.maintainsCohesion(newPos, bot.Position, myBotPositions, config) {
continue
}
// Score this move
score := 0.0
// Prefer moving toward enemy
distToTarget := float64(distance2(newPos, target, config.Rows, config.Cols))
currentDistToTarget := float64(distance2(bot.Position, target, config.Rows, config.Cols))
score += (currentDistToTarget - distToTarget) * 10
// Prefer staying near swarm center
distToSwarmCenter := float64(distance2(newPos, swarmCenter, config.Rows, config.Cols))
score -= distToSwarmCenter * 0.5
// Bonus for being in attack range
for enemyPos := range enemyPositions {
dist := distance2(newPos, enemyPos, config.Rows, config.Cols)
if dist <= config.AttackRadius2 {
score += 50
break
}
}
// Small bonus for energy on the way
if energyPositions[newPos] {
score += 15
}
if score > bestScore {
bestScore = score
bestDir = dir
}
}
if bestDir != DirNone {
return &Move{Position: bot.Position, Direction: bestDir}
}
return nil
}
// soloMove handles movement when the swarm is too small for formation tactics.
// Gathers energy to spawn more units, advances toward enemies to build swarm via combat.
func (b *SwarmBot) soloMove(
bot VisibleBot,
energyPositions, enemyPositions, wallPositions map[Position]bool,
config Config,
state *VisibleState,
) *Move {
bestDir := DirNone
bestScore := -math.MaxFloat64
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if wallPositions[newPos] || enemyPositions[newPos] {
continue
}
score := 0.0
// Strong bonus for energy (primary goal in solo mode: build swarm economy)
if energyPositions[newPos] {
score += 120
}
// Move toward nearest energy
for ePos := range energyPositions {
dist := float64(distance2(newPos, ePos, config.Rows, config.Cols))
currentDist := float64(distance2(bot.Position, ePos, config.Rows, config.Cols))
if dist < currentDist {
score += 25.0 / (dist + 1)
}
}
// Advance toward enemies (per plan §5.5: "advance as a group toward enemies")
// Bonus for moving closer to enemies, but secondary to energy gathering
for ePos := range enemyPositions {
dist := float64(distance2(newPos, ePos, config.Rows, config.Cols))
currentDist := float64(distance2(bot.Position, ePos, config.Rows, config.Cols))
if dist < currentDist {
// Moving toward enemy - bonus increases as we get closer
score += 40.0 / (dist + 1)
}
// Moderate bonus for being in attack range (encourages combat but doesn't override energy)
if dist <= float64(config.AttackRadius2) {
score += 35
}
}
if score > bestScore {
bestScore = score
bestDir = dir
}
}
if bestDir != DirNone {
return &Move{Position: bot.Position, Direction: bestDir}
}
return nil
}
func (b *SwarmBot) maintainsCohesion(newPos, oldPos Position, myBotPositions map[Position]bool, config Config) bool {
for botPos := range myBotPositions {
if botPos == oldPos {
continue
}
dist := distance2(newPos, botPos, config.Rows, config.Cols)
if dist <= cohesionRadius2 {
return true
}
}
return false
}
// HunterBot targets isolated enemy units.
type HunterBot struct {
rng *rand.Rand
enemyTrackers map[Position]*enemyTracker
}
type enemyTracker struct {
lastPos *Position
currentPos Position
}
// NewHunterBot creates a new hunter bot.
func NewHunterBot(seed int64) *HunterBot {
return &HunterBot{
rng: rand.New(rand.NewSource(seed)),
enemyTrackers: make(map[Position]*enemyTracker),
}
}
// GetMoves returns moves targeting isolated enemies.
func (b *HunterBot) GetMoves(state *VisibleState) ([]Move, error) {
myID := state.You.ID
config := state.Config
// Separate bots
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
if len(myBots) == 0 {
return nil, nil
}
// Update enemy trackers
for _, enemy := range enemyBots {
tracker, exists := b.enemyTrackers[enemy.Position]
if !exists {
tracker = &enemyTracker{}
b.enemyTrackers[enemy.Position] = tracker
}
tracker.lastPos = &tracker.currentPos
tracker.currentPos = enemy.Position
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
wallPositions := make(map[Position]bool)
for _, w := range state.Walls {
wallPositions[w] = true
}
myBotPositions := make(map[Position]bool)
for _, bot := range myBots {
myBotPositions[bot.Position] = true
}
// Find isolated enemies
isolatedEnemies := b.findIsolatedEnemies(enemyBots, config)
// Assign hunters to targets
moves := make([]Move, 0, len(myBots))
usedEnergy := make(map[Position]bool)
assignedHunters := make(map[Position]bool)
// First, assign hunters to isolated enemies
for _, target := range isolatedEnemies {
// Assign up to 2 hunters per target
huntersAssigned := 0
for i, bot := range myBots {
if assignedHunters[bot.Position] {
continue
}
if huntersAssigned >= 2 {
break
}
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: zoneDir})
assignedHunters[bot.Position] = true
continue
}
// Check if this bot is close enough to be a hunter
dist := distance2(bot.Position, target.Position, config.Rows, config.Cols)
if dist < 400 { // Within ~20 tiles
predictedPos := b.predictPosition(target)
dir := b.getDirectionToward(bot.Position, predictedPos, wallPositions, config)
if dir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
assignedHunters[bot.Position] = true
huntersAssigned++
}
}
_ = i // silence unused variable warning
}
}
// Remaining bots gather or explore
for _, bot := range myBots {
if assignedHunters[bot.Position] {
continue
}
// Priority 1: Escape zone if threatened
if zoneDir := getZoneEscapeDirection(bot.Position, state, wallPositions); zoneDir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: zoneDir})
continue
}
// Try to gather energy
nearestEnergy, nearestDist := Position{}, math.MaxInt32
for pos := range energyPositions {
if usedEnergy[pos] {
continue
}
dist := distance2(bot.Position, pos, config.Rows, config.Cols)
if dist < nearestDist {
nearestDist = dist
nearestEnergy = pos
}
}
if nearestDist < math.MaxInt32 {
usedEnergy[nearestEnergy] = true
dir := b.getDirectionToward(bot.Position, nearestEnergy, wallPositions, config)
if dir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
continue
}
}
// Explore toward center
center := Position{Row: config.Rows / 2, Col: config.Cols / 2}
dir := b.getDirectionToward(bot.Position, center, wallPositions, config)
if dir != DirNone {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
}
}
return moves, nil
}
const isolationThreshold = 16 // 4 tiles squared
func (b *HunterBot) findIsolatedEnemies(enemies []VisibleBot, config Config) []VisibleBot {
isolated := make([]VisibleBot, 0)
for _, bot := range enemies {
nearestDist := math.MaxInt32
for _, other := range enemies {
if bot.Position == other.Position {
continue
}
dist := distance2(bot.Position, other.Position, config.Rows, config.Cols)
if dist < nearestDist {
nearestDist = dist
}
}
// Isolated if nearest friendly is >= 4 tiles away or only enemy
if nearestDist >= isolationThreshold || len(enemies) == 1 {
isolated = append(isolated, bot)
}
}
return isolated
}
func (b *HunterBot) predictPosition(enemy VisibleBot) Position {
tracker, exists := b.enemyTrackers[enemy.Position]
if !exists || tracker.lastPos == nil {
return enemy.Position
}
// Simple prediction: continue in same direction
dr := tracker.currentPos.Row - tracker.lastPos.Row
dc := tracker.currentPos.Col - tracker.lastPos.Col
// Handle wrap
if dr > 30 {
dr -= 60
}
if dr < -30 {
dr += 60
}
if dc > 30 {
dc -= 60
}
if dc < -30 {
dc += 60
}
return Position{
Row: (tracker.currentPos.Row + dr + 60) % 60,
Col: (tracker.currentPos.Col + dc + 60) % 60,
}
}
func (b *HunterBot) getDirectionToward(from, to Position, wallPositions map[Position]bool, config Config) Direction {
bestDir := DirNone
bestDist := math.MaxInt32
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(from, dir, config.Rows, config.Cols)
if wallPositions[newPos] {
continue
}
dist := distance2(newPos, to, config.Rows, config.Cols)
if dist < bestDist {
bestDist = dist
bestDir = dir
}
}
return bestDir
}
// Helper functions
// distance2 calculates squared Euclidean distance with toroidal wrapping.
func distance2(a, b Position, rows, cols int) int {
dr := abs(a.Row - b.Row)
dc := abs(a.Col - b.Col)
// Apply toroidal wrapping
if dr > rows/2 {
dr = rows - dr
}
if dc > cols/2 {
dc = cols - dc
}
return dr*dr + dc*dc
}
// simulateMove returns the new position after moving in a direction.
func simulateMove(pos Position, dir Direction, rows, cols int) Position {
switch dir {
case DirN:
return Position{Row: (pos.Row - 1 + rows) % rows, Col: pos.Col}
case DirE:
return Position{Row: pos.Row, Col: (pos.Col + 1) % cols}
case DirS:
return Position{Row: (pos.Row + 1) % rows, Col: pos.Col}
case DirW:
return Position{Row: pos.Row, Col: (pos.Col - 1 + cols) % cols}
default:
return pos
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}