diff --git a/bots/siege/Dockerfile b/bots/siege/Dockerfile new file mode 100644 index 0000000..fd21f43 --- /dev/null +++ b/bots/siege/Dockerfile @@ -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"] diff --git a/bots/siege/go.mod b/bots/siege/go.mod new file mode 100644 index 0000000..4b06f73 --- /dev/null +++ b/bots/siege/go.mod @@ -0,0 +1,3 @@ +module github.com/aicodebattle/acb/bots/siege + +go 1.21 diff --git a/bots/siege/main.go b/bots/siege/main.go new file mode 100644 index 0000000..8b918f7 --- /dev/null +++ b/bots/siege/main.go @@ -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 +} diff --git a/bots/siege/strategy.go b/bots/siege/strategy.go new file mode 100644 index 0000000..7cd8411 --- /dev/null +++ b/bots/siege/strategy.go @@ -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} +} + diff --git a/cmd/acb-local/main.go b/cmd/acb-local/main.go index 9a687f7..8c9d371 100644 --- a/cmd/acb-local/main.go +++ b/cmd/acb-local/main.go @@ -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() { diff --git a/engine/bot_strategies.go b/engine/bot_strategies.go index 73b5416..960e6eb 100644 --- a/engine/bot_strategies.go +++ b/engine/bot_strategies.go @@ -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 +} diff --git a/test-siege-arena.sh b/test-siege-arena.sh new file mode 100755 index 0000000..d005400 --- /dev/null +++ b/test-siege-arena.sh @@ -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