diff --git a/bots/farmer/Dockerfile b/bots/farmer/Dockerfile new file mode 100644 index 0000000..b311437 --- /dev/null +++ b/bots/farmer/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +COPY go.mod . +COPY main.go grid.go strategy.go . + +RUN CGO_ENABLED=0 go build -o farmer . + +FROM alpine:3.21 + +WORKDIR /app +COPY --from=builder /app/farmer . + +ENV BOT_PORT=8080 +ENV BOT_SECRET="" + +EXPOSE 8080 + +CMD ["./farmer"] diff --git a/bots/farmer/go.mod b/bots/farmer/go.mod new file mode 100644 index 0000000..07e269b --- /dev/null +++ b/bots/farmer/go.mod @@ -0,0 +1,3 @@ +module acb-farmer + +go 1.22 diff --git a/bots/farmer/grid.go b/bots/farmer/grid.go new file mode 100644 index 0000000..f63ab06 --- /dev/null +++ b/bots/farmer/grid.go @@ -0,0 +1,92 @@ +package main + +func ToroidalManhattan(a, b Position, rows, cols int) int { + dr := abs(a.Row - b.Row) + dc := abs(a.Col - b.Col) + dr = min(dr, rows-dr) + dc = min(dc, cols-dc) + return dr + dc +} + +func distance2(a, b Position, rows, cols int) int { + dr := abs(a.Row - b.Row) + dc := abs(a.Col - b.Col) + dr = min(dr, rows-dr) + dc = min(dc, cols-dc) + return dr*dr + dc*dc +} + +type cardinalStep struct { + pos Position + dir string +} + +func cardinalSteps(p Position, rows, cols int) []cardinalStep { + steps := []struct { + dr, dc int + dir string + }{{-1, 0, "N"}, {0, 1, "E"}, {1, 0, "S"}, {0, -1, "W"}} + result := make([]cardinalStep, 0, 4) + for _, s := range steps { + result = append(result, cardinalStep{ + pos: Position{ + Row: (p.Row + s.dr + rows) % rows, + Col: (p.Col + s.dc + cols) % cols, + }, + dir: s.dir, + }) + } + return result +} + +// BFS finds the shortest path from start to goal on a toroidal grid +// using 4-directional (cardinal) movement. passable returns true if a cell can be entered. +// Returns the first direction to move, or "" if unreachable. +func BFS(start, goal Position, passable func(Position) bool, rows, cols int) string { + if start == goal { + return "" + } + + type node struct { + pos Position + dir string + } + + visited := map[Position]bool{start: true} + queue := []node{} + + for _, step := range cardinalSteps(start, rows, cols) { + if step.pos == goal && passable(step.pos) { + return step.dir + } + if passable(step.pos) && !visited[step.pos] { + visited[step.pos] = true + queue = append(queue, node{step.pos, step.dir}) + } + } + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + + if cur.pos == goal { + return cur.dir + } + + for _, step := range cardinalSteps(cur.pos, rows, cols) { + if !visited[step.pos] && passable(step.pos) { + visited[step.pos] = true + queue = append(queue, node{step.pos, cur.dir}) + } + } + } + + return "" +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/bots/farmer/main.go b/bots/farmer/main.go new file mode 100644 index 0000000..5254e1f --- /dev/null +++ b/bots/farmer/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" +) + +var directions = []string{"N", "E", "S", "W"} + +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"` +} + +type Position struct { + Row int `json:"row"` + Col int `json:"col"` +} + +type VisibleBot struct { + Position Position `json:"position"` + Owner int `json:"owner"` +} + +type VisibleCore struct { + Position Position `json:"position"` + Owner int `json:"owner"` + Active bool `json:"active"` +} + +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"` +} + +type Move struct { + Position Position `json:"position"` + Direction string `json:"direction"` +} + +type MoveResponse struct { + Moves []Move `json:"moves"` +} + +func main() { + port := getEnv("BOT_PORT", "8080") + secret := getEnv("BOT_SECRET", "") + + if secret == "" { + log.Fatal("BOT_SECRET environment variable is required") + } + + strategy := NewFarmerStrategy() + + http.HandleFunc("/turn", func(w http.ResponseWriter, r *http.Request) { + handleTurn(w, r, secret, strategy) + }) + http.HandleFunc("/health", handleHealth) + + addr := fmt.Sprintf(":%s", port) + log.Printf("Farmer bot listening on %s", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func handleTurn(w http.ResponseWriter, r *http.Request, secret string, strategy *FarmerStrategy) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + sig := r.Header.Get("X-ACB-Signature") + matchID := r.Header.Get("X-ACB-Match-Id") + turnStr := r.Header.Get("X-ACB-Turn") + timestamp := r.Header.Get("X-ACB-Timestamp") + + if sig == "" || !verifySignature(secret, matchID, turnStr, timestamp, body, sig) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + var state GameState + if err := json.Unmarshal(body, &state); err != nil { + http.Error(w, "invalid game state", http.StatusBadRequest) + return + } + + moves := strategy.ComputeMoves(&state) + response := MoveResponse{Moves: moves} + responseBody, _ := json.Marshal(response) + + turn, _ := strconv.Atoi(turnStr) + responseSig := signResponse(secret, matchID, turn, responseBody) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ACB-Signature", responseSig) + w.Write(responseBody) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func verifySignature(secret, matchID, turnStr, timestamp string, body []byte, signature string) bool { + bodyHash := sha256.Sum256(body) + signingString := fmt.Sprintf("%s.%s.%s.%s", matchID, turnStr, timestamp, hex.EncodeToString(bodyHash[:])) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingString)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(signature), []byte(expected)) +} + +func signResponse(secret, matchID string, turn int, body []byte) string { + bodyHash := sha256.Sum256(body) + 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, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/bots/farmer/strategy.go b/bots/farmer/strategy.go new file mode 100644 index 0000000..381b7c5 --- /dev/null +++ b/bots/farmer/strategy.go @@ -0,0 +1,339 @@ +package main + +import "math" + +const ( + fleeRadius2 = 9 // flee if enemy within 3 cells (squared = 9) + dangerBuffer = 20 // extra buffer beyond attack radius for avoidance +) + +// FarmerStrategy maximizes energy collection and spawn rate while avoiding combat. +type FarmerStrategy struct{} + +func NewFarmerStrategy() *FarmerStrategy { + return &FarmerStrategy{} +} + +// ComputeMoves assigns each owned bot to seek energy, flee enemies, or +// stay near core for spawning. +func (s *FarmerStrategy) ComputeMoves(state *GameState) []Move { + rows := state.Config.Rows + cols := state.Config.Cols + attackR2 := state.Config.AttackRadius2 + myID := state.You.ID + + // Build lookup maps + wallSet := make(map[Position]bool, len(state.Walls)) + for _, w := range state.Walls { + wallSet[w] = true + } + + enemySet := make(map[Position]bool) + enemyPositions := make([]Position, 0) + for _, b := range state.Bots { + if b.Owner != myID { + enemySet[b.Position] = true + enemyPositions = append(enemyPositions, b.Position) + } + } + + // Identify my active cores + myCores := make([]Position, 0) + for _, c := range state.Cores { + if c.Owner == myID && c.Active { + myCores = append(myCores, c.Position) + } + } + + // Determine which energy tiles are contested (enemy adjacent) + contestedEnergy := make(map[Position]bool) + for _, e := range state.Energy { + for _, ep := range enemyPositions { + if distance2(e, ep, rows, cols) <= 2 { + contestedEnergy[e] = true + break + } + } + } + + // Separate my bots + myBots := make([]VisibleBot, 0, len(state.Bots)) + for _, b := range state.Bots { + if b.Owner == myID { + myBots = append(myBots, b) + } + } + + // Track assigned energy targets to avoid duplicate assignments + assignedEnergy := make(map[Position]bool) + // Track claimed destinations to prevent self-collision + claimedDests := make(map[Position]bool) + + // Sort bots: prioritize bots closest to uncontested energy + botScores := make([]int, len(myBots)) + for i, b := range myBots { + bestDist := math.MaxInt32 + for _, e := range state.Energy { + if !contestedEnergy[e] { + d := distance2(b.Position, e, rows, cols) + if d < bestDist { + bestDist = d + } + } + } + botScores[i] = bestDist + } + + // Simple selection sort for small arrays + sorted := make([]int, len(myBots)) + for i := range sorted { + sorted[i] = i + } + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if botScores[sorted[j]] < botScores[sorted[i]] { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + moves := make([]Move, 0, len(myBots)) + + for _, idx := range sorted { + bot := myBots[idx] + dir := s.computeBotMove(bot, state, wallSet, enemyPositions, enemySet, + myCores, contestedEnergy, assignedEnergy, claimedDests, rows, cols, attackR2) + + dest := bot.Position + if dir != "" { + dest = simulateMove(bot.Position, dir, rows, cols) + } + + // If destination is already claimed by another bot, hold position + if dir != "" && claimedDests[dest] { + dir = "" + dest = bot.Position + } + + claimedDests[dest] = true + if dir != "" { + moves = append(moves, Move{Position: bot.Position, Direction: dir}) + } + } + + return moves +} + +func (s *FarmerStrategy) computeBotMove( + bot VisibleBot, + state *GameState, + wallSet map[Position]bool, + enemyPositions []Position, + enemySet map[Position]bool, + myCores []Position, + contestedEnergy map[Position]bool, + assignedEnergy map[Position]bool, + claimedDests map[Position]bool, + rows, cols, attackR2 int, +) string { + pos := bot.Position + + // Priority 1: FLEE if any enemy within flee radius + if len(enemyPositions) > 0 { + minEnemyDist2 := math.MaxInt32 + for _, ep := range enemyPositions { + d := distance2(pos, ep, rows, cols) + if d < minEnemyDist2 { + minEnemyDist2 = d + } + } + + if minEnemyDist2 <= fleeRadius2 { + dir := s.fleeDirection(pos, enemyPositions, wallSet, enemySet, rows, cols) + if dir != "" { + return dir + } + } + + // Also flee if enemy within attack radius + buffer + if minEnemyDist2 <= attackR2+dangerBuffer { + dir := s.fleeDirection(pos, enemyPositions, wallSet, enemySet, rows, cols) + if dir != "" { + return dir + } + } + } + + passable := func(p Position) bool { + if wallSet[p] { + return false + } + // Avoid stepping directly onto enemy positions + if enemySet[p] { + return false + } + return true + } + + // Priority 2: Seek nearest uncontested, unassigned energy + var bestEnergyTarget *Position + bestDist := math.MaxInt32 + for i, e := range state.Energy { + if contestedEnergy[e] || assignedEnergy[e] { + continue + } + d := distance2(pos, e, rows, cols) + if d < bestDist { + bestDist = d + eCopy := state.Energy[i] + bestEnergyTarget = &eCopy + } + } + + if bestEnergyTarget != nil { + assignedEnergy[*bestEnergyTarget] = true + dir := BFS(pos, *bestEnergyTarget, passable, rows, cols) + if dir != "" { + return dir + } + } + + // Priority 3: If on or adjacent to energy, collect it (hold or step onto) + for _, e := range state.Energy { + if e == pos { + // Already on energy, hold to collect + return "" + } + } + + // Priority 4: Move toward nearest energy (even contested) + if len(state.Energy) > 0 { + bestDist = math.MaxInt32 + var target Position + for _, e := range state.Energy { + d := distance2(pos, e, rows, cols) + if d < bestDist { + bestDist = d + target = e + } + } + dir := BFS(pos, target, passable, rows, cols) + if dir != "" { + return dir + } + } + + // Priority 5: Stay near active core for spawning + if len(myCores) > 0 { + nearestCoreDist := math.MaxInt32 + var nearestCore Position + for _, c := range myCores { + d := distance2(pos, c, rows, cols) + if d < nearestCoreDist { + nearestCoreDist = d + nearestCore = c + } + } + + // If far from core, move toward it + if nearestCoreDist > 4 { + dir := BFS(pos, nearestCore, passable, rows, cols) + if dir != "" { + return dir + } + } + } + + // Priority 6: Spread out from other friendly bots to avoid self-collision + return s.spreadMove(pos, state, claimedDests, rows, cols) +} + +// fleeDirection picks the cardinal direction that maximizes distance from +// the nearest enemy. +func (s *FarmerStrategy) fleeDirection( + pos Position, + enemies []Position, + wallSet, enemySet map[Position]bool, + rows, cols int, +) string { + bestDir := "" + bestMinDist := -1 + + for _, step := range cardinalSteps(pos, rows, cols) { + if wallSet[step.pos] || enemySet[step.pos] { + continue + } + + minDist := math.MaxInt32 + for _, ep := range enemies { + d := distance2(step.pos, ep, rows, cols) + if d < minDist { + minDist = d + } + } + + if minDist > bestMinDist { + bestMinDist = minDist + bestDir = step.dir + } + } + + return bestDir +} + +// spreadMove picks a direction that moves away from the densest cluster +// of friendly bots. +func (s *FarmerStrategy) spreadMove( + pos Position, + state *GameState, + claimedDests map[Position]bool, + rows, cols int, +) string { + myID := state.You.ID + + bestDir := "" + bestScore := -1 + + for _, step := range cardinalSteps(pos, rows, cols) { + if claimedDests[step.pos] { + continue + } + + // Score = minimum distance to any friendly bot (maximize spacing) + minDist := math.MaxInt32 + for _, b := range state.Bots { + if b.Owner != myID { + continue + } + d := distance2(step.pos, b.Position, rows, cols) + if d < minDist { + minDist = d + } + } + + if minDist > bestScore { + bestScore = minDist + bestDir = step.dir + } + } + + return bestDir +} + +func simulateMove(pos Position, dir string, rows, cols int) Position { + dr, dc := 0, 0 + switch dir { + case "N": + dr = -1 + case "S": + dr = 1 + case "E": + dc = 1 + case "W": + dc = -1 + } + return Position{ + Row: (pos.Row + dr + rows) % rows, + Col: (pos.Col + dc + cols) % cols, + } +} diff --git a/bots/farmer/strategy_test.go b/bots/farmer/strategy_test.go new file mode 100644 index 0000000..d924018 --- /dev/null +++ b/bots/farmer/strategy_test.go @@ -0,0 +1,311 @@ +package main + +import ( + "testing" +) + +func makeState(myID, energy int, bots []VisibleBot, energyTiles []Position, cores []VisibleCore, walls []Position) *GameState { + state := &GameState{ + MatchID: "test", + Turn: 1, + Config: GameConfig{ + Rows: 20, + Cols: 20, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Energy: energyTiles, + Cores: cores, + Walls: walls, + } + state.You.ID = myID + state.You.Energy = energy + state.Bots = bots + return state +} + +func TestFarmerSeeksEnergy(t *testing.T) { + state := makeState(0, 0, + []VisibleBot{{Position: Position{10, 10}, Owner: 0}}, + []Position{{10, 14}}, // energy 4 tiles east + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + if moves[0].Direction != "E" { + t.Errorf("expected move E toward energy, got %s", moves[0].Direction) + } +} + +func TestFarmerFleesEnemy(t *testing.T) { + state := makeState(0, 0, + []VisibleBot{ + {Position: Position{10, 10}, Owner: 0}, // my bot + {Position: Position{10, 12}, Owner: 1}, // enemy 2 tiles south + }, + []Position{{10, 14}}, // energy exists but should flee instead + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + // Should flee away from enemy at (10,12), not go toward energy at (10,14) + if moves[0].Direction == "E" { + t.Errorf("bot should flee from enemy, not move toward energy; got %s", moves[0].Direction) + } +} + +func TestFarmerFleesFromNearbyEnemy(t *testing.T) { + // Enemy within 3 tiles (fleeRadius2 = 9, distance2 = 4 < 9) + state := makeState(0, 0, + []VisibleBot{ + {Position: Position{10, 10}, Owner: 0}, + {Position: Position{12, 10}, Owner: 1}, // enemy 2 tiles south, d2=4 + }, + nil, + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + // Should flee north (away from enemy to the south) + if moves[0].Direction != "N" { + t.Errorf("expected flee north, got %s", moves[0].Direction) + } +} + +func TestFarmerMultipleBotsSeekDifferentEnergy(t *testing.T) { + state := makeState(0, 6, + []VisibleBot{ + {Position: Position{5, 5}, Owner: 0}, + {Position: Position{15, 15}, Owner: 0}, + }, + []Position{{5, 8}, {15, 12}}, // two energy tiles + []VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 2 { + t.Fatalf("expected 2 moves, got %d", len(moves)) + } + + // Each bot should target its nearest energy + dirs := map[string]bool{} + for _, m := range moves { + dirs[m.Direction] = true + } + + // Bot at (5,5) should move E toward (5,8) + // Bot at (15,15) should move W toward (15,12) + // We can't guarantee exact mapping, but both should have moves + if len(dirs) < 1 { + t.Errorf("expected bots to move toward different energy, got dirs: %v", dirs) + } +} + +func TestFarmerHoldsOnEnergy(t *testing.T) { + // Bot already on energy tile, no enemies + state := makeState(0, 0, + []VisibleBot{{Position: Position{10, 10}, Owner: 0}}, + []Position{{10, 10}}, // energy on same tile as bot + []VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + // Bot should hold position to collect energy (no move issued) + if len(moves) != 0 { + t.Errorf("expected bot to hold on energy (0 moves), got %d", len(moves)) + } +} + +func TestFarmerStaysNearCore(t *testing.T) { + // Bot far from core, no energy, no enemies + state := makeState(0, 0, + []VisibleBot{{Position: Position{15, 15}, Owner: 0}}, + nil, + []VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + // Should move toward core at (5,5), i.e. N or W + dir := moves[0].Direction + if dir != "N" && dir != "W" { + t.Errorf("expected move toward core (N or W), got %s", dir) + } +} + +func TestFarmerIgnoresContestedEnergy(t *testing.T) { + // Energy at (10,14) with enemy adjacent at (10,15) - contested + // Uncontested energy at (10,5) + state := makeState(0, 0, + []VisibleBot{ + {Position: Position{10, 10}, Owner: 0}, + {Position: Position{10, 15}, Owner: 1}, // enemy adjacent to energy + }, + []Position{{10, 14}, {10, 5}}, // contested energy, safe energy + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + // Should prefer safe energy at (10,5) -> move W + if moves[0].Direction != "W" { + t.Errorf("expected move W toward safe energy, got %s", moves[0].Direction) + } +} + +func TestFarmerAvoidsWalls(t *testing.T) { + // Bot at (10,10), energy at (10,14), wall at (10,11) blocking direct path + state := makeState(0, 0, + []VisibleBot{{Position: Position{10, 10}, Owner: 0}}, + []Position{{10, 14}}, + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + []Position{{10, 11}}, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + if len(moves) != 1 { + t.Fatalf("expected 1 move, got %d", len(moves)) + } + // Should not move E into the wall + if moves[0].Direction == "E" { + t.Errorf("bot should avoid wall at (10,11), got direction E") + } +} + +func TestDistance2(t *testing.T) { + tests := []struct { + a, b Position + rows, cols int + want int + }{ + {Position{0, 0}, Position{0, 0}, 20, 20, 0}, + {Position{0, 0}, Position{0, 3}, 20, 20, 9}, + {Position{0, 0}, Position{3, 4}, 20, 20, 25}, + // Toroidal: distance from (0,0) to (19,0) on 20-row grid = 1 row + {Position{0, 0}, Position{19, 0}, 20, 20, 1}, + // Toroidal: distance from (0,0) to (0,19) on 20-col grid = 1 col + {Position{0, 0}, Position{0, 19}, 20, 20, 1}, + } + + for _, tt := range tests { + got := distance2(tt.a, tt.b, tt.rows, tt.cols) + if got != tt.want { + t.Errorf("distance2(%v, %v, %d, %d) = %d, want %d", + tt.a, tt.b, tt.rows, tt.cols, got, tt.want) + } + } +} + +func TestBFS(t *testing.T) { + wallSet := map[Position]bool{{10, 11}: true} + passable := func(p Position) bool { return !wallSet[p] } + + // Direct path north + dir := BFS(Position{5, 5}, Position{3, 5}, passable, 20, 20) + if dir != "N" { + t.Errorf("BFS to north: got %q, want N", dir) + } + + // Path around wall: (10,10) to (10,14) with wall at (10,11) + dir = BFS(Position{10, 10}, Position{10, 14}, passable, 20, 20) + if dir == "" { + t.Error("BFS should find path around wall") + } + // Should go N or S to bypass wall at (10,11), not E + if dir == "E" { + t.Errorf("BFS should not go directly into wall, got E") + } +} + +func TestSimulateMove(t *testing.T) { + tests := []struct { + pos Position + dir string + rows, cols int + want Position + }{ + {Position{5, 5}, "N", 20, 20, Position{4, 5}}, + {Position{5, 5}, "S", 20, 20, Position{6, 5}}, + {Position{5, 5}, "E", 20, 20, Position{5, 6}}, + {Position{5, 5}, "W", 20, 20, Position{5, 4}}, + // Toroidal wrap + {Position{0, 0}, "N", 20, 20, Position{19, 0}}, + {Position{0, 0}, "W", 20, 20, Position{0, 19}}, + {Position{19, 19}, "S", 20, 20, Position{0, 19}}, + {Position{19, 19}, "E", 20, 20, Position{19, 0}}, + } + + for _, tt := range tests { + got := simulateMove(tt.pos, tt.dir, tt.rows, tt.cols) + if got != tt.want { + t.Errorf("simulateMove(%v, %q, %d, %d) = %v, want %v", + tt.pos, tt.dir, tt.rows, tt.cols, got, tt.want) + } + } +} + +func TestFarmerNoSelfCollision(t *testing.T) { + // Two bots at (10,10) and (10,11), energy at (10,12) + // Both want to go east, but can't land on same tile + state := makeState(0, 0, + []VisibleBot{ + {Position: Position{10, 10}, Owner: 0}, + {Position: Position{10, 11}, Owner: 0}, + }, + []Position{{10, 12}}, + []VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}}, + nil, + ) + + s := NewFarmerStrategy() + moves := s.ComputeMoves(state) + + // Collect destinations + dests := map[Position]bool{} + for _, m := range moves { + dest := simulateMove(m.Position, m.Direction, 20, 20) + if dests[dest] { + t.Errorf("two bots collide at %v", dest) + } + dests[dest] = true + } +}