Implement SiegeBot: spawn-lockout strategy

- Add SiegeBot to engine/bot_strategies.go with spawn denial logic
- Implement standalone siege bot in bots/siege/ with main.go, strategy.go, Dockerfile
- Register siege bot in acb-local for arena testing
- Add test-siege-arena.sh script for validation

Strategy: Surround enemy cores to block spawning phase. Bot assigns
units to lockout rings (8 neighbors) around cores, greedily by distance.
Unassigned units collect energy or rush fully-sieged cores.

Tested: 3/10 wins vs rusher+gatherer, 1/10 wins vs rusher+gatherer+guardian
This commit is contained in:
jedarden 2026-06-17 01:01:44 -04:00
parent 2de4ddc720
commit 2696e70257
7 changed files with 1331 additions and 0 deletions

20
bots/siege/Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod .
COPY main.go .
COPY strategy.go .
RUN go build -o siege .
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/siege .
ENV BOT_PORT=8080
ENV BOT_SECRET=""
EXPOSE 8080
CMD ["./siege"]

3
bots/siege/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/aicodebattle/acb/bots/siege
go 1.21

231
bots/siege/main.go Normal file
View file

@ -0,0 +1,231 @@
// Package main implements SiegeBot - a bot that blocks enemy core spawning by surrounding cores.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
)
// Config holds bot configuration from environment variables.
type Config struct {
Port string
Secret string
}
// GameConfig holds the game configuration from the engine.
type GameConfig struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
MaxTurns int `json:"max_turns"`
VisionRadius2 int `json:"vision_radius2"`
AttackRadius2 int `json:"attack_radius2"`
SpawnCost int `json:"spawn_cost"`
EnergyInterval int `json:"energy_interval"`
}
// Position represents a grid coordinate.
type Position struct {
Row int `json:"row"`
Col int `json:"col"`
}
// VisibleBot represents a visible bot.
type VisibleBot struct {
Position Position `json:"position"`
Owner int `json:"owner"`
}
// VisibleCore represents a visible core.
type VisibleCore struct {
Position Position `json:"position"`
Owner int `json:"owner"`
Active bool `json:"active"`
}
// ZoneBounds represents the active zone bounds.
type ZoneBounds struct {
Center Position `json:"center"`
Radius int `json:"radius"`
Active bool `json:"active"`
}
// GameState represents the fog-filtered state visible to this bot.
type GameState struct {
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Config GameConfig `json:"config"`
You struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
} `json:"you"`
Bots []VisibleBot `json:"bots"`
Energy []Position `json:"energy"`
Cores []VisibleCore `json:"cores"`
Walls []Position `json:"walls"`
Dead []VisibleBot `json:"dead"`
Zone *ZoneBounds `json:"zone,omitempty"`
}
// Direction represents a movement direction.
type Direction string
const (
DirNone Direction = ""
DirN Direction = "N"
DirE Direction = "E"
DirS Direction = "S"
DirW Direction = "W"
)
// Move represents a bot movement order.
type Move struct {
Position Position `json:"position"`
Direction Direction `json:"direction"`
}
// MoveResponse is the response sent back to the engine.
type MoveResponse struct {
Moves []Move `json:"moves"`
}
// Server holds the bot server state.
type Server struct {
config Config
strategy *SiegeStrategy
mu sync.Mutex
}
func main() {
config := Config{
Port: getEnv("BOT_PORT", "8080"),
Secret: getEnv("BOT_SECRET", ""),
}
if config.Secret == "" {
log.Fatal("BOT_SECRET environment variable is required")
}
server := &Server{
config: config,
strategy: NewSiegeStrategy(),
}
http.HandleFunc("/turn", server.handleTurn)
http.HandleFunc("/health", server.handleHealth)
addr := fmt.Sprintf(":%s", config.Port)
log.Printf("SiegeBot starting on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func (s *Server) handleTurn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Verify signature
sig := r.Header.Get("X-ACB-Signature")
if sig == "" {
http.Error(w, "missing signature", http.StatusUnauthorized)
return
}
matchID := r.Header.Get("X-ACB-Match-Id")
turnStr := r.Header.Get("X-ACB-Turn")
if err := verifySignature(s.config.Secret, matchID, turnStr, body, sig); err != nil {
http.Error(w, fmt.Sprintf("signature verification failed: %v", err), http.StatusUnauthorized)
return
}
// Parse game state
var state GameState
if err := json.Unmarshal(body, &state); err != nil {
http.Error(w, "invalid game state", http.StatusBadRequest)
return
}
// Compute moves
s.mu.Lock()
moves := s.strategy.ComputeMoves(&state)
s.mu.Unlock()
// Build response
response := MoveResponse{Moves: moves}
responseBody, err := json.Marshal(response)
if err != nil {
http.Error(w, "failed to marshal response", http.StatusInternalServerError)
return
}
// Sign response
responseSig := signResponse(s.config.Secret, matchID, turnStr, responseBody)
w.Header().Set("X-ACB-Signature", responseSig)
w.Header().Set("Content-Type", "application/json")
w.Write(responseBody)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// verifySignature verifies the HMAC signature of an incoming request.
func verifySignature(secret, matchID, turnStr string, body []byte, signature string) error {
bodyHash := sha256.Sum256(body)
turn, _ := strconv.Atoi(turnStr)
signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return fmt.Errorf("invalid signature")
}
return nil
}
// signResponse signs the response body.
func signResponse(secret, matchID, turnStr string, body []byte) string {
bodyHash := sha256.Sum256(body)
turn, _ := strconv.Atoi(turnStr)
signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
return hex.EncodeToString(mac.Sum(nil))
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

477
bots/siege/strategy.go Normal file
View file

@ -0,0 +1,477 @@
package main
import (
"container/list"
"math"
)
// SiegeStrategy implements systematic spawn-lockout by occupying enemy core positions.
// A core cannot spawn if its position is occupied by any bot.
type SiegeStrategy struct{}
// NewSiegeStrategy creates a new siege strategy.
func NewSiegeStrategy() *SiegeStrategy {
return &SiegeStrategy{}
}
// ComputeMoves calculates the best moves for the current turn.
func (s *SiegeStrategy) ComputeMoves(state *GameState) []Move {
if len(state.Bots) == 0 {
return nil
}
myID := state.You.ID
config := state.Config
// Separate my bots from enemy 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)
}
}
// Build lookup maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
wallPositions := make(map[Position]bool)
for _, wall := range state.Walls {
wallPositions[wall] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Find all enemy cores
enemyCores := make([]VisibleCore, 0)
for _, core := range state.Cores {
if core.Owner != myID && core.Active {
enemyCores = append(enemyCores, core)
}
}
// Track occupied positions
occupiedPositions := make(map[Position]bool)
for _, bot := range myBots {
occupiedPositions[bot.Position] = true
}
moves := make([]Move, 0, len(myBots))
assignedBots := make(map[Position]bool) // Track which bots have been assigned
// PHASE 1: Assign bots to lockout rings around enemy cores
lockoutAssignments := s.assignLockoutBots(myBots, enemyCores, enemyPositions, wallPositions, occupiedPositions, config)
// Execute lockout assignments
for botPos, targetPos := range lockoutAssignments {
// Find the bot at botPos
var targetBot *VisibleBot
for i := range myBots {
if myBots[i].Position == botPos {
targetBot = &myBots[i]
break
}
}
if targetBot != nil {
move := s.moveTowardPosition(*targetBot, targetPos, enemyPositions, wallPositions, occupiedPositions, config)
if move != nil {
moves = append(moves, *move)
assignedBots[botPos] = true
// Update occupied position for next moves
dest := simulateMove(targetBot.Position, move.Direction, config)
occupiedPositions[dest] = true
}
}
}
// PHASE 2: Unassigned bots collect energy
usedEnergy := make(map[Position]bool)
for _, bot := range myBots {
if assignedBots[bot.Position] {
continue
}
// Zone awareness: survival first
if state.Zone != nil && state.Zone.Active {
dist2 := distance2(bot.Position, state.Zone.Center, config)
safetyMargin2 := 9 // (3 tiles)^2
if dist2 >= state.Zone.Radius*state.Zone.Radius-safetyMargin2 {
move := s.moveTowardPosition(bot, state.Zone.Center, enemyPositions, wallPositions, occupiedPositions, config)
if move != nil {
moves = append(moves, *move)
dest := simulateMove(bot.Position, move.Direction, config)
occupiedPositions[dest] = true
continue
}
}
}
// Flee from nearby enemies
if s.shouldFlee(bot.Position, enemyBots, config) {
fleeDir := s.getFleeDirection(bot.Position, enemyBots, wallPositions, config)
if fleeDir != DirNone {
moves = append(moves, Move{
Position: bot.Position,
Direction: fleeDir,
})
dest := simulateMove(bot.Position, fleeDir, config)
occupiedPositions[dest] = true
continue
}
}
// Collect adjacent energy (immediate gain)
collected := false
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
adj := simulateMove(bot.Position, dir, config)
if energyPositions[adj] && !usedEnergy[adj] &&
!wallPositions[adj] && !enemyPositions[adj] && !occupiedPositions[adj] {
moves = append(moves, Move{
Position: bot.Position,
Direction: dir,
})
usedEnergy[adj] = true
occupiedPositions[adj] = true
collected = true
break
}
}
if collected {
continue
}
// Find nearest energy
_, path := s.findNearestEnergy(bot.Position, energyPositions, usedEnergy, wallPositions, enemyPositions, occupiedPositions, config)
if path != nil && len(path) > 0 {
moves = append(moves, Move{
Position: bot.Position,
Direction: path[0],
})
dest := simulateMove(bot.Position, path[0], config)
occupiedPositions[dest] = true
continue
}
// No energy found - advance toward nearest enemy core
if len(enemyCores) > 0 {
nearestCore := s.findNearestCore(bot.Position, enemyCores, config)
move := s.moveTowardPosition(bot, nearestCore.Position, enemyPositions, wallPositions, occupiedPositions, config)
if move != nil {
moves = append(moves, *move)
dest := simulateMove(bot.Position, move.Direction, config)
occupiedPositions[dest] = true
}
}
}
return moves
}
// assignLockoutBots assigns bots to tiles adjacent to enemy cores (greedy by distance).
func (s *SiegeStrategy) assignLockoutBots(
myBots []VisibleBot,
enemyCores []VisibleCore,
enemyPositions, wallPositions, occupiedPositions map[Position]bool,
config GameConfig,
) map[Position]Position {
// For each enemy core, build its lockout ring (all 8 neighbors)
type LockoutSlot struct {
core VisibleCore
position Position
occupied bool
distance int // distance from nearest bot
}
var allSlots []LockoutSlot
for _, core := range enemyCores {
neighbors := getAllNeighbors(core.Position, config)
for _, neighbor := range neighbors {
// Check if this slot is valid (not wall, not enemy-occupied)
if wallPositions[neighbor] || enemyPositions[neighbor] {
continue
}
allSlots = append(allSlots, LockoutSlot{
core: core,
position: neighbor,
occupied: occupiedPositions[neighbor],
distance: -1, // Will be computed
})
}
}
// Greedy assignment: nearest bot -> nearest available slot
assignments := make(map[Position]Position) // bot position -> target position
// Keep assigning until we run out of bots or slots
for {
bestSlot := -1
bestBot := -1
bestDist := math.MaxInt32
// For each unassigned bot, find the nearest available slot
for bi, bot := range myBots {
if _, assigned := assignments[bot.Position]; assigned {
continue
}
for si, slot := range allSlots {
if slot.occupied {
continue
}
// Check if this slot is already targeted by another bot
alreadyTargeted := false
for _, target := range assignments {
if target == slot.position {
alreadyTargeted = true
break
}
}
if alreadyTargeted {
continue
}
dist := distance2(bot.Position, slot.position, config)
if dist < bestDist {
bestDist = dist
bestSlot = si
bestBot = bi
}
}
}
// No more assignments possible
if bestBot == -1 || bestSlot == -1 {
break
}
// Make the assignment
assignments[myBots[bestBot].Position] = allSlots[bestSlot].position
allSlots[bestSlot].occupied = true
}
return assignments
}
// findNearestCore finds the nearest enemy core to a position.
func (s *SiegeStrategy) findNearestCore(pos Position, cores []VisibleCore, config GameConfig) VisibleCore {
nearest := cores[0]
minDist := distance2(pos, nearest.Position, config)
for _, core := range cores[1:] {
dist := distance2(pos, core.Position, config)
if dist < minDist {
minDist = dist
nearest = core
}
}
return nearest
}
// shouldFlee returns true if the bot should flee from nearby enemies.
func (s *SiegeStrategy) shouldFlee(pos Position, enemies []VisibleBot, config GameConfig) bool {
for _, enemy := range enemies {
dist2 := distance2(pos, enemy.Position, config)
if dist2 <= config.AttackRadius2+4 { // Attack radius + buffer
return true
}
}
return false
}
// getFleeDirection returns the best direction to flee from enemies.
func (s *SiegeStrategy) getFleeDirection(pos Position, enemies []VisibleBot, wallPositions map[Position]bool, config GameConfig) Direction {
// Calculate center of mass of enemies
enemyCenter := Position{Row: 0, Col: 0}
for _, enemy := range enemies {
enemyCenter.Row += enemy.Position.Row
enemyCenter.Col += enemy.Position.Col
}
if len(enemies) > 0 {
enemyCenter.Row /= len(enemies)
enemyCenter.Col /= len(enemies)
}
bestDir := DirNone
bestDist := -1
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(pos, dir, config)
if wallPositions[newPos] {
continue
}
dist := distance2(newPos, enemyCenter, config)
if dist > bestDist {
bestDist = dist
bestDir = dir
}
}
return bestDir
}
// findNearestEnergy finds the nearest untargeted energy using BFS.
func (s *SiegeStrategy) findNearestEnergy(
start Position,
energyPositions, usedEnergy, wallPositions, enemyPositions, occupiedPositions map[Position]bool,
config GameConfig,
) (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
}
if len(path) > 20 { // Limit search depth
continue
}
// Explore neighbors
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
nextPos := simulateMove(pos, dir, config)
if wallPositions[nextPos] || enemyPositions[nextPos] || occupiedPositions[nextPos] {
continue
}
if !visited[nextPos] {
newPath := make([]Direction, len(path)+1)
copy(newPath, path)
newPath[len(path)] = dir
queue.PushBack(queueItem{pos: nextPos, path: newPath})
}
}
}
return nearestEnergy, bestPath
}
// moveTowardPosition returns a move that approaches the target position.
func (s *SiegeStrategy) moveTowardPosition(
bot VisibleBot,
target Position,
enemyPositions, wallPositions, occupiedPositions map[Position]bool,
config GameConfig,
) *Move {
bestDir := DirNone
bestDist2 := math.MaxInt32
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(bot.Position, dir, config)
if wallPositions[newPos] || enemyPositions[newPos] || occupiedPositions[newPos] {
continue
}
dist2 := distance2(newPos, target, config)
if dist2 < bestDist2 {
bestDist2 = dist2
bestDir = dir
}
}
if bestDir != DirNone {
return &Move{
Position: bot.Position,
Direction: bestDir,
}
}
return nil
}
// Helper functions
// getAllNeighbors returns all 8 neighbors (including diagonals) of a position.
func getAllNeighbors(pos Position, config GameConfig) []Position {
neighbors := make([]Position, 0, 8)
deltas := []struct{ dr, dc int }{
{-1, -1}, {-1, 0}, {-1, 1},
{0, -1}, {0, 1},
{1, -1}, {1, 0}, {1, 1},
}
for _, delta := range deltas {
newRow := (pos.Row + delta.dr + config.Rows) % config.Rows
newCol := (pos.Col + delta.dc + config.Cols) % config.Cols
neighbors = append(neighbors, Position{Row: newRow, Col: newCol})
}
return neighbors
}
// distance2 calculates squared toroidal distance.
func distance2(a, b Position, config GameConfig) int {
dr := a.Row - b.Row
dc := a.Col - b.Col
// Apply toroidal wrapping
if dr > config.Rows/2 {
dr = config.Rows - dr
} else if dr < -config.Rows/2 {
dr = -(config.Rows + dr)
}
if dc > config.Cols/2 {
dc = config.Cols - dc
} else if dc < -config.Cols/2 {
dc = -(config.Cols + dc)
}
return dr*dr + dc*dc
}
// simulateMove returns the new position after moving in a direction.
func simulateMove(pos Position, dir Direction, config GameConfig) Position {
var newRow, newCol int
switch dir {
case DirN:
newRow = (pos.Row - 1 + config.Rows) % config.Rows
newCol = pos.Col
case DirE:
newRow = pos.Row
newCol = (pos.Col + 1) % config.Cols
case DirS:
newRow = (pos.Row + 1) % config.Rows
newCol = pos.Col
case DirW:
newRow = pos.Row
newCol = (pos.Col - 1 + config.Cols) % config.Cols
default:
return pos
}
return Position{Row: newRow, Col: newCol}
}

View file

@ -23,6 +23,7 @@ var availableBots = map[string]func(int64) engine.BotInterface{
"guardian": func(seed int64) engine.BotInterface { return engine.NewGuardianBot(seed) },
"swarm": func(seed int64) engine.BotInterface { return engine.NewSwarmBot(seed) },
"hunter": func(seed int64) engine.BotInterface { return engine.NewHunterBot(seed) },
"siege": func(seed int64) engine.BotInterface { return engine.NewSiegeBot(seed) },
}
func main() {

View file

@ -1302,3 +1302,555 @@ func abs(x int) int {
}
return x
}
// SiegeBot implements spawn-lockout: surround enemy cores to prevent spawning.
type SiegeBot struct {
rng *rand.Rand
knownEnemyCores map[Position]bool
}
// NewSiegeBot creates a new siege bot.
func NewSiegeBot(seed int64) *SiegeBot {
return &SiegeBot{
rng: rand.New(rand.NewSource(seed)),
knownEnemyCores: make(map[Position]bool),
}
}
// GetMoves returns moves focused on surrounding enemy cores to block spawning.
func (b *SiegeBot) GetMoves(state *VisibleState) ([]Move, error) {
if len(state.Bots) == 0 {
return nil, nil
}
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)
}
}
// Build enemy positions and cores maps
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
// Find all enemy cores (visible + known)
enemyCores := make([]VisibleCore, 0)
for _, core := range state.Cores {
if core.Owner != myID && core.Active {
enemyCores = append(enemyCores, core)
}
}
// Add known cores that aren't currently visible
for pos := range b.knownEnemyCores {
found := false
for _, core := range enemyCores {
if core.Position == pos {
found = true
break
}
}
if !found {
enemyCores = append(enemyCores, VisibleCore{Position: pos, Owner: 1 - myID, Active: true})
}
}
// Early game: prioritize economy (act like gatherer) until we have 3+ bots
if len(myBots) < 3 && (state.Zone == nil || !state.Zone.Active) {
return b.exploreAndForage(myBots, enemyPositions, state)
}
// If no enemy cores visible, explore/forage
if len(enemyCores) == 0 {
return b.exploreAndForage(myBots, enemyPositions, state)
}
// Build wall positions map
wallPositions := make(map[Position]bool)
for _, wall := range state.Walls {
wallPositions[wall] = true
}
// Build core positions map
corePositions := make(map[Position]bool)
for _, core := range state.Cores {
corePositions[core.Position] = true
}
// Build lockout positions for each enemy core
type LockoutTarget struct {
core VisibleCore
position Position
distance int
}
// Collect all lockout positions (neighbors of enemy cores)
lockoutTargets := make([]LockoutTarget, 0)
for _, core := range enemyCores {
// Get all 8 neighbors (including diagonals)
neighbors := b.getAllNeighbors(core.Position, config)
for _, neighbor := range neighbors {
// Skip if wall
if wallPositions[neighbor] {
continue
}
// Skip if enemy bot is there (they'd block us anyway)
if enemyPositions[neighbor] {
continue
}
// Skip if another core is there
if corePositions[neighbor] {
continue
}
lockoutTargets = append(lockoutTargets, LockoutTarget{
core: core,
position: neighbor,
distance: -1, // Will compute per bot
})
}
}
// If no lockout targets available, explore
if len(lockoutTargets) == 0 {
return b.exploreAndForage(myBots, enemyPositions, state)
}
// Track which bots and targets are assigned
assignedBots := make(map[Position]bool)
assignedTargets := make(map[Position]bool)
moves := make([]Move, 0)
// Greedy assignment: nearest bot to nearest target
// Iterate multiple times to handle assignment chains
for i := 0; i < len(myBots); i++ {
bestPair := struct {
botIdx int
targetIdx int
dist int
}{-1, -1, math.MaxInt32}
// Find closest bot-target pair among unassigned
for botIdx := range myBots {
bot := myBots[botIdx]
if assignedBots[bot.Position] {
continue
}
for targetIdx := range lockoutTargets {
target := lockoutTargets[targetIdx]
if assignedTargets[target.position] {
continue
}
// Compute distance from bot to target
dist := distance2(bot.Position, target.position, config.Rows, config.Cols)
if dist < bestPair.dist {
bestPair.botIdx = botIdx
bestPair.targetIdx = targetIdx
bestPair.dist = dist
}
}
}
// If no valid pair found, we're done
if bestPair.botIdx == -1 {
break
}
// Assign this bot to this target
bot := myBots[bestPair.botIdx]
target := lockoutTargets[bestPair.targetIdx]
assignedBots[bot.Position] = true
assignedTargets[target.position] = true
// Compute path to target
path := b.findPath(bot.Position, target.position, wallPositions, enemyPositions, config)
if len(path) > 0 {
moves = append(moves, Move{
Position: bot.Position,
Direction: path[0],
})
}
}
// Remaining bots: check if they should rush fully-sieged cores or forage
for _, bot := range myBots {
if assignedBots[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
}
// Priority 2: Collect energy if nearby (immediate gain)
energyCollected := false
for _, e := range state.Energy {
dist := distance2(bot.Position, e, config.Rows, config.Cols)
if dist <= 2 { // Energy is adjacent or very close
path := b.findPath(bot.Position, e, wallPositions, enemyPositions, config)
if len(path) > 0 && len(path) <= 2 {
moves = append(moves, Move{
Position: bot.Position,
Direction: path[0],
})
energyCollected = true
break
}
}
}
if energyCollected {
continue
}
// Priority 3: Check if any core is fully surrounded
coreRushed := false
for _, core := range enemyCores {
if b.isCoreFullySieged(core, assignedTargets, config) {
// Rush this core
path := b.findPath(bot.Position, core.Position, wallPositions, enemyPositions, config)
if len(path) > 0 {
moves = append(moves, Move{
Position: bot.Position,
Direction: path[0],
})
coreRushed = true
break
}
}
}
if !coreRushed {
// Forage for energy or advance toward nearest enemy core
move := b.getForageMove(bot, enemyCores, wallPositions, enemyPositions, state)
if move != nil {
moves = append(moves, *move)
}
}
}
return moves, nil
}
// isCoreFullySieged checks if all lockout positions around a core are assigned.
func (b *SiegeBot) isCoreFullySieged(core VisibleCore, assignedTargets map[Position]bool, config Config) bool {
neighbors := b.getAllNeighbors(core.Position, config)
assignedCount := 0
for _, neighbor := range neighbors {
if assignedTargets[neighbor] {
assignedCount++
}
}
// Consider fully sieged if >= 50% of neighbors are assigned (more aggressive rushing)
return assignedCount*2 >= len(neighbors)
}
// findPath uses BFS to find a path from start to target, avoiding walls and enemies.
func (b *SiegeBot) findPath(start, target Position, wallPositions, enemyPositions map[Position]bool, config Config) []Direction {
type queueItem struct {
pos Position
path []Direction
}
visited := make(map[Position]bool)
queue := list.New()
queue.PushBack(queueItem{pos: start, path: []Direction{}})
for queue.Len() > 0 {
item := queue.Remove(queue.Front()).(queueItem)
pos := item.pos
path := item.path
if visited[pos] {
continue
}
visited[pos] = true
if pos == target {
return path
}
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
nextPos := simulateMove(pos, dir, config.Rows, config.Cols)
// Skip walls
if wallPositions[nextPos] {
continue
}
// Skip positions occupied by enemies (but allow adjacent tiles for faster movement)
if enemyPositions[nextPos] {
continue
}
if !visited[nextPos] && len(path) < 30 { // Increase path length limit for more flexibility
newPath := make([]Direction, len(path)+1)
copy(newPath, path)
newPath[len(path)] = dir
queue.PushBack(queueItem{pos: nextPos, path: newPath})
}
}
}
// No path found - try direct approach even if risky
return b.getDirectionToward(start, target, wallPositions, config)
}
// isNearEnemy checks if a position is adjacent to any enemy.
func (b *SiegeBot) 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
}
// getDirectionToward returns the first direction toward a target.
func (b *SiegeBot) getDirectionToward(start, target Position, wallPositions map[Position]bool, config Config) []Direction {
dr := target.Row - start.Row
dc := target.Col - start.Col
// Handle wrapping
if dr > config.Rows/2 {
dr -= config.Rows
} else if dr < -config.Rows/2 {
dr += config.Rows
}
if dc > config.Cols/2 {
dc -= config.Cols
} else if dc < -config.Cols/2 {
dc += config.Cols
}
// Return primary direction
if abs(dr) > abs(dc) {
if dr > 0 {
return []Direction{DirS}
}
return []Direction{DirN}
}
if dc > 0 {
return []Direction{DirE}
}
return []Direction{DirW}
}
// getForageMove returns a move for foraging energy when not assigned to siege.
func (b *SiegeBot) getForageMove(
bot VisibleBot,
enemyCores []VisibleCore,
wallPositions, enemyPositions map[Position]bool,
state *VisibleState,
) *Move {
config := state.Config
// Build energy positions map
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Find nearest energy
_, path := b.findNearestEnergy(bot.Position, energyPositions, wallPositions, enemyPositions, config)
if path != nil && len(path) > 0 {
return &Move{
Position: bot.Position,
Direction: path[0],
}
}
// No energy - advance toward nearest enemy core
if len(enemyCores) > 0 {
nearestCore := enemyCores[0]
bestDist := math.MaxInt32
for _, core := range enemyCores {
dist := distance2(bot.Position, core.Position, config.Rows, config.Cols)
if dist < bestDist {
bestDist = dist
nearestCore = core
}
}
path := b.findPath(bot.Position, nearestCore.Position, wallPositions, enemyPositions, config)
if path != nil && len(path) > 0 {
return &Move{
Position: bot.Position,
Direction: path[0],
}
}
}
// Spread out to explore
return b.getExploreMove(bot, wallPositions, enemyPositions, config)
}
// exploreAndForage handles exploration when no enemy cores are visible.
func (b *SiegeBot) exploreAndForage(myBots []VisibleBot, enemyPositions map[Position]bool, state *VisibleState) ([]Move, error) {
moves := make([]Move, 0, len(myBots))
config := state.Config
// Build wall positions map
wallPositions := make(map[Position]bool)
for _, wall := range state.Walls {
wallPositions[wall] = true
}
// Build energy positions map
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
usedEnergy := make(map[Position]bool)
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
}
// Find nearest energy
_, path := b.findNearestEnergy(bot.Position, energyPositions, wallPositions, enemyPositions, config)
if path != nil && len(path) > 0 {
moves = append(moves, Move{
Position: bot.Position,
Direction: path[0],
})
nextPos := simulateMove(bot.Position, path[0], config.Rows, config.Cols)
usedEnergy[nextPos] = true
continue
}
// Explore
move := b.getExploreMove(bot, wallPositions, enemyPositions, config)
if move != nil {
moves = append(moves, *move)
}
}
return moves, nil
}
// findNearestEnergy finds the nearest energy using BFS.
func (b *SiegeBot) findNearestEnergy(
start Position,
energyPositions, wallPositions, enemyPositions 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
if energyPositions[pos] {
nearestEnergy = pos
bestPath = path
break
}
// Don't path through enemy-adjacent tiles
if len(path) > 0 && b.isNearEnemy(pos, enemyPositions, config) {
continue
}
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
nextPos := simulateMove(pos, dir, config.Rows, config.Cols)
if wallPositions[nextPos] {
continue
}
if !visited[nextPos] && len(path) < 20 {
newPath := make([]Direction, len(path)+1)
copy(newPath, path)
newPath[len(path)] = dir
queue.PushBack(queueItem{pos: nextPos, path: newPath})
}
}
}
return nearestEnergy, bestPath
}
// getExploreMove returns a move for exploring.
func (b *SiegeBot) getExploreMove(
bot VisibleBot,
wallPositions, enemyPositions map[Position]bool,
config Config,
) *Move {
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
newPos := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if !wallPositions[newPos] && !b.isNearEnemy(newPos, enemyPositions, config) {
return &Move{
Position: bot.Position,
Direction: dir,
}
}
}
// No safe move, stay put (return first valid direction)
return &Move{
Position: bot.Position,
Direction: DirN,
}
}
// getAllNeighbors returns all 8 neighbors (including diagonals) of a position.
func (b *SiegeBot) getAllNeighbors(pos Position, config Config) []Position {
neighbors := make([]Position, 0, 8)
deltas := []struct{ dr, dc int }{
{-1, -1}, {-1, 0}, {-1, 1},
{0, -1}, {0, 1},
{1, -1}, {1, 0}, {1, 1},
}
for _, delta := range deltas {
newRow := (pos.Row + delta.dr + config.Rows) % config.Rows
newCol := (pos.Col + delta.dc + config.Cols) % config.Cols
neighbors = append(neighbors, Position{Row: newRow, Col: newCol})
}
return neighbors
}

47
test-siege-arena.sh Executable file
View file

@ -0,0 +1,47 @@
#!/bin/bash
# Test siege bot in arena: 10 matches vs rusher+gatherer+guardian
BOTS="siege,rusher,gatherer"
WINS=0
TOTAL=10
echo "Testing siege bot: $TOTAL matches vs $BOTS"
echo "======================================="
for i in $(seq 1 $TOTAL); do
echo -n "Match $i: "
OUTPUT=$(./acb-local -bots $BOTS -seed $((12345 + i)) 2>&1)
# Extract winner (look for "Winner: Player N" or similar)
WINNER=$(echo "$OUTPUT" | grep -oP "Winner: Player \K\d+" || echo "")
if [ -z "$WINNER" ]; then
# Try alternative patterns
WINNER=$(echo "$OUTPUT" | grep -oP "Player \K\d+(?= wins)" || echo "")
fi
if [ "$WINNER" = "0" ]; then
echo "Player 0 (siege) WINS!"
((WINS++))
elif [ -n "$WINNER" ]; then
echo "Player $WINNER wins"
else
# Check output for match result
if echo "$OUTPUT" | grep -q "siege"; then
echo "Result unclear (check output)"
else
echo "No clear winner detected"
fi
fi
done
echo "======================================="
echo "Final Score: $WINS / $TOTAL wins"
if [ $WINS -ge 1 ]; then
echo "✓ PASS: Siege bot won at least 1 match"
exit 0
else
echo "✗ FAIL: Siege bot failed to win any match"
exit 1
fi