From cecbc4a2a0f437329f75215fcdf31873ca170bf9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 17:03:02 -0400 Subject: [PATCH] fix(bot): fix Opportunist retreat and attack pathfinding bugs Two bugs in the Opportunist bot strategy: 1. retreatMove: when a lone bot is surrounded by enemies with no nearby allies, all passable directions scored below the -1 initial threshold due to flat enemy penalties. Fixed by scoring distance-from-enemies as a positive value (further = better) instead of a flat penalty, ensuring the bot always picks the safest direction. 2. attackMove: BFS could never reach enemy targets because the passable function excluded all enemy positions. The target IS an enemy, so the path was unreachable. Fixed by wrapping passable to treat the target position as passable during attack pathfinding. All 19 tests now pass, including TestComputeMovesRetreat and TestComputeMovesNearbyAdvantageAttack. Co-Authored-By: Claude Opus 4.7 --- bots/opportunist/Dockerfile | 19 ++ bots/opportunist/go.mod | 3 + bots/opportunist/grid.go | 107 ++++++ bots/opportunist/main.go | 159 +++++++++ bots/opportunist/strategy.go | 409 ++++++++++++++++++++++ bots/opportunist/strategy_test.go | 549 ++++++++++++++++++++++++++++++ 6 files changed, 1246 insertions(+) create mode 100644 bots/opportunist/Dockerfile create mode 100644 bots/opportunist/go.mod create mode 100644 bots/opportunist/grid.go create mode 100644 bots/opportunist/main.go create mode 100644 bots/opportunist/strategy.go create mode 100644 bots/opportunist/strategy_test.go diff --git a/bots/opportunist/Dockerfile b/bots/opportunist/Dockerfile new file mode 100644 index 0000000..9b3b5e4 --- /dev/null +++ b/bots/opportunist/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 opportunist . + +FROM alpine:3.21 + +WORKDIR /app +COPY --from=builder /app/opportunist . + +ENV BOT_PORT=8080 +ENV BOT_SECRET="" + +EXPOSE 8080 + +CMD ["./opportunist"] diff --git a/bots/opportunist/go.mod b/bots/opportunist/go.mod new file mode 100644 index 0000000..bbfe3d5 --- /dev/null +++ b/bots/opportunist/go.mod @@ -0,0 +1,3 @@ +module acb-opportunist + +go 1.22 diff --git a/bots/opportunist/grid.go b/bots/opportunist/grid.go new file mode 100644 index 0000000..8aae0d4 --- /dev/null +++ b/bots/opportunist/grid.go @@ -0,0 +1,107 @@ +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 +} + +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 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, + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/bots/opportunist/main.go b/bots/opportunist/main.go new file mode 100644 index 0000000..9e6f119 --- /dev/null +++ b/bots/opportunist/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 := NewOpportunistStrategy() + + 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("Opportunist 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 *OpportunistStrategy) { + 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/opportunist/strategy.go b/bots/opportunist/strategy.go new file mode 100644 index 0000000..0d7ddf3 --- /dev/null +++ b/bots/opportunist/strategy.go @@ -0,0 +1,409 @@ +package main + +import "math" + +const ( + engageRadius2 = 25 // ~5 tiles: region considered "local" for numerical advantage + retreatRadius2 = 9 // flee if enemy within 3 tiles and we're outnumbered + patrolRadius = 8 // max distance from core when patrolling + energySeekRange2 = 100 // ~10 tiles: seek energy within this range +) + +// OpportunistStrategy targets the weakest visible enemy — fights only when +// it has local numerical advantage, retreats toward reinforcements otherwise, +// and builds economy during retreats. +type OpportunistStrategy struct{} + +func NewOpportunistStrategy() *OpportunistStrategy { + return &OpportunistStrategy{} +} + +// targetInfo describes a scored enemy target. +type targetInfo struct { + pos Position + owner int + score float64 // higher = more attractive + isolation float64 // distance to nearest friendly + localAlly int // allies within engageRadius2 + localEnemy int // enemies within engageRadius2 +} + +// ComputeMoves assigns each owned bot to attack, retreat, gather energy, or +// patrol near core. +func (s *OpportunistStrategy) ComputeMoves(state *GameState) []Move { + rows := state.Config.Rows + cols := state.Config.Cols + attackR2 := state.Config.AttackRadius2 + myID := state.You.ID + + wallSet := make(map[Position]bool, len(state.Walls)) + for _, w := range state.Walls { + wallSet[w] = true + } + + // Separate bots by ownership + myBots := make([]Position, 0, len(state.Bots)) + myBotSet := make(map[Position]bool) + enemyBots := make([]VisibleBot, 0) + enemySet := make(map[Position]bool) + + for _, b := range state.Bots { + if b.Owner == myID { + myBots = append(myBots, b.Position) + myBotSet[b.Position] = true + } else { + enemyBots = append(enemyBots, b) + enemySet[b.Position] = true + } + } + + // Identify my active cores + myCores := make([]Position, 0) + for _, c := range state.Cores { + if c.Owner == myID && c.Active { + myCores = append(myCores, c.Position) + } + } + + // Score enemy targets: isolation × low-HP-proxy + targets := s.scoreTargets(enemyBots, myBots, rows, cols) + + passable := func(p Position) bool { + return !wallSet[p] && !enemySet[p] + } + + claimedDests := make(map[Position]bool) + moves := make([]Move, 0, len(myBots)) + + // Assign bots: attackers first (closest to best target), then retreaters, then economy + attackAssigns := s.assignAttackers(targets, myBots, attackR2, rows, cols) + + for _, bot := range myBots { + dir := "" + + if assign, ok := attackAssigns[bot]; ok { + // Attack mode: move toward assigned target + dir = s.attackMove(bot, assign.targetPos, passable, rows, cols) + } else if s.shouldFlee(bot, enemyBots, myBots, rows, cols) { + // Retreat mode: move toward nearest ally cluster + dir = s.retreatMove(bot, myBots, enemySet, wallSet, rows, cols) + // Opportunistically grab energy while retreating + if dir == "" { + dir = s.energyMove(bot, state.Energy, passable, claimedDests, rows, cols) + } + } else { + // Economy/patrol mode + dir = s.economyOrPatrol(bot, state.Energy, myCores, passable, claimedDests, rows, cols) + } + + dest := bot + if dir != "" { + dest = simulateMove(bot, dir, rows, cols) + } + + // Prevent self-collision + if dir != "" && claimedDests[dest] { + dir = "" + dest = bot + } + + claimedDests[dest] = true + if dir != "" { + moves = append(moves, Move{Position: bot, Direction: dir}) + } + } + + return moves +} + +// scoreTargets evaluates each visible enemy and returns them sorted by +// attractiveness (isolation × vulnerability). +func (s *OpportunistStrategy) scoreTargets(enemies []VisibleBot, myBots []Position, rows, cols int) []targetInfo { + targets := make([]targetInfo, 0, len(enemies)) + + for _, e := range enemies { + // Isolation: distance to nearest friendly (other enemy owned by same player) + isolation := 0.0 + minFriendly := math.MaxFloat64 + for _, other := range enemies { + if other.Position == e.Position { + continue + } + if other.Owner == e.Owner { + d := float64(distance2(e.Position, other.Position, rows, cols)) + if d < minFriendly { + minFriendly = d + } + } + } + if minFriendly == math.MaxFloat64 { + isolation = 10.0 + } else { + isolation = math.Sqrt(minFriendly) + } + + // Count local allies and enemies around this target + localAlly := 0 + localEnemy := 0 + for _, mb := range myBots { + if distance2(mb, e.Position, rows, cols) <= engageRadius2 { + localAlly++ + } + } + for _, oe := range enemies { + if distance2(oe.Position, e.Position, rows, cols) <= engageRadius2 { + localEnemy++ + } + } + + // Low-HP-proxy: bots that are more isolated are "weaker" targets. + // If the enemy has few local allies, it's more vulnerable. + vulnerability := 1.0 + if localEnemy > 0 { + vulnerability = 1.0 / float64(localEnemy) + } + + score := isolation * vulnerability + + targets = append(targets, targetInfo{ + pos: e.Position, + owner: e.Owner, + score: score, + isolation: isolation, + localAlly: localAlly, + localEnemy: localEnemy, + }) + } + + // Sort by score descending + for i := 1; i < len(targets); i++ { + for j := i; j > 0 && targets[j].score > targets[j-1].score; j-- { + targets[j], targets[j-1] = targets[j-1], targets[j] + } + } + + return targets +} + +// attackAssign holds the assignment of a bot to an attack target. +type attackAssign struct { + targetPos Position +} + +// assignAttackers determines which bots should attack which targets. +// Only assigns bots when we have local numerical advantage (allies >= enemies) +// in the target's region. +func (s *OpportunistStrategy) assignAttackers(targets []targetInfo, myBots []Position, attackR2 int, rows, cols int) map[Position]attackAssign { + assignments := make(map[Position]attackAssign) + assignedBots := make(map[Position]bool) + + for _, tgt := range targets { + // Only attack if we have numerical advantage in the region + if tgt.localAlly < tgt.localEnemy { + continue + } + + // Find closest unassigned bots to send toward this target + type botDist struct { + pos Position + dist int + } + candidates := make([]botDist, 0) + for _, mb := range myBots { + if assignedBots[mb] { + continue + } + d := distance2(mb, tgt.pos, rows, cols) + // Only consider bots within a reasonable engagement range + if d <= engageRadius2*2 { + candidates = append(candidates, botDist{mb, d}) + } + } + + // Sort candidates by distance (closest first) + for i := 1; i < len(candidates); i++ { + for j := i; j > 0 && candidates[j].dist < candidates[j-1].dist; j-- { + candidates[j], candidates[j-1] = candidates[j-1], candidates[j] + } + } + + // Assign enough bots to ensure advantage (send 2 for each enemy in region) + wantCount := tgt.localEnemy + 1 + if wantCount < 2 { + wantCount = 2 + } + + assigned := 0 + for _, c := range candidates { + if assigned >= wantCount { + break + } + assignments[c.pos] = attackAssign{targetPos: tgt.pos} + assignedBots[c.pos] = true + assigned++ + } + } + + return assignments +} + +// attackMove moves a bot toward the assigned target position. +// The target itself is treated as passable so BFS can path to it. +func (s *OpportunistStrategy) attackMove(bot, target Position, passable func(Position) bool, rows, cols int) string { + attackPassable := func(p Position) bool { + if p == target { + return true + } + return passable(p) + } + return BFS(bot, target, attackPassable, rows, cols) +} + +// shouldFlee returns true if the bot is near enemies and locally outnumbered. +func (s *OpportunistStrategy) shouldFlee(bot Position, enemies []VisibleBot, myBots []Position, rows, cols int) bool { + nearbyEnemies := 0 + for _, e := range enemies { + if distance2(bot, e.Position, rows, cols) <= retreatRadius2 { + nearbyEnemies++ + } + } + + if nearbyEnemies == 0 { + return false + } + + nearbyAllies := 0 + for _, mb := range myBots { + if mb == bot { + continue + } + if distance2(bot, mb, rows, cols) <= retreatRadius2 { + nearbyAllies++ + } + } + + return nearbyAllies < nearbyEnemies +} + +// retreatMove moves toward the nearest cluster of friendly bots while +// maximizing distance from enemies. +func (s *OpportunistStrategy) retreatMove(bot Position, myBots []Position, enemySet, wallSet map[Position]bool, rows, cols int) string { + bestDir := "" + bestScore := -1 + + for _, step := range cardinalSteps(bot, rows, cols) { + if wallSet[step.pos] || enemySet[step.pos] { + continue + } + + score := 0 + + // Move toward nearest friendly bot cluster + for _, mb := range myBots { + if mb == bot { + continue + } + d := ToroidalManhattan(step.pos, mb, rows, cols) + if d > 0 { + score += 100 / d + } + } + + // Maximize distance from all enemies (further is safer) + for ep := range enemySet { + d := distance2(step.pos, ep, rows, cols) + score += d + } + + if score > bestScore { + bestScore = score + bestDir = step.dir + } + } + + return bestDir +} + +// economyOrPatrol seeks nearby energy or patrols near core. +func (s *OpportunistStrategy) economyOrPatrol(bot Position, energy []Position, cores []Position, passable func(Position) bool, claimedDests map[Position]bool, rows, cols int) string { + // Try to gather nearby uncontested energy + dir := s.energyMove(bot, energy, passable, claimedDests, rows, cols) + if dir != "" { + return dir + } + + // Patrol near core + if len(cores) > 0 { + nearestCoreDist := math.MaxInt32 + var nearestCore Position + for _, c := range cores { + d := distance2(bot, c, rows, cols) + if d < nearestCoreDist { + nearestCoreDist = d + nearestCore = c + } + } + + // If far from core, move toward it + if nearestCoreDist > patrolRadius*patrolRadius { + dir := BFS(bot, nearestCore, passable, rows, cols) + if dir != "" { + return dir + } + } + } + + // Spread out to avoid clustering + return s.spreadMove(bot, claimedDests, rows, cols) +} + +// energyMove seeks the nearest unclaimed, uncontested energy tile. +func (s *OpportunistStrategy) energyMove(bot Position, energy []Position, passable func(Position) bool, claimedDests map[Position]bool, rows, cols int) string { + bestDist := math.MaxInt32 + var target Position + found := false + + for _, e := range energy { + if claimedDests[e] { + continue + } + d := distance2(bot, e, rows, cols) + if d < bestDist && d <= energySeekRange2 { + bestDist = d + target = e + found = true + } + } + + if found { + return BFS(bot, target, passable, rows, cols) + } + return "" +} + +// spreadMove picks a direction that maximizes distance from claimed destinations. +func (s *OpportunistStrategy) spreadMove(bot Position, claimedDests map[Position]bool, rows, cols int) string { + bestDir := "" + bestScore := -1 + + for _, step := range cardinalSteps(bot, rows, cols) { + if claimedDests[step.pos] { + continue + } + + score := 0 + for dest := range claimedDests { + d := distance2(step.pos, dest, rows, cols) + if d > 0 { + score += d + } + } + + if score > bestScore { + bestScore = score + bestDir = step.dir + } + } + + return bestDir +} diff --git a/bots/opportunist/strategy_test.go b/bots/opportunist/strategy_test.go new file mode 100644 index 0000000..9ba4a02 --- /dev/null +++ b/bots/opportunist/strategy_test.go @@ -0,0 +1,549 @@ +package main + +import ( + "math" + "testing" +) + +func TestDistance2(t *testing.T) { + rows, cols := 60, 60 + a := Position{Row: 0, Col: 0} + b := Position{Row: 3, Col: 4} + got := distance2(a, b, rows, cols) + want := 25 // 3^2 + 4^2 + if got != want { + t.Errorf("distance2 = %d, want %d", got, want) + } +} + +func TestDistance2Wrap(t *testing.T) { + rows, cols := 60, 60 + a := Position{Row: 2, Col: 2} + b := Position{Row: 58, Col: 58} + got := distance2(a, b, rows, cols) + // Wrapped: dr=4, dc=4 → 16+16=32 + if got != 32 { + t.Errorf("distance2 wrap = %d, want 32", got) + } +} + +func TestScoreTargetsIsolation(t *testing.T) { + rows, cols := 60, 60 + myBots := []Position{{Row: 10, Col: 10}} + enemies := []VisibleBot{ + {Position: Position{Row: 15, Col: 15}, Owner: 1}, // isolated + {Position: Position{Row: 16, Col: 16}, Owner: 1}, // near other enemy + } + + s := &OpportunistStrategy{} + targets := s.scoreTargets(enemies, myBots, rows, cols) + + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(targets)) + } + + // The isolated enemy (15,15) should score higher since its nearest friendly + // (16,16) is close but the other one at (15,15) has the friendly at (16,16) even closer. + // Actually both have the same owner, so: + // Target (15,15): nearest friendly is (16,16) at dist2=2 → isolation=sqrt(2)≈1.41 + // Target (16,16): nearest friendly is (15,15) at dist2=2 → isolation=sqrt(2)≈1.41 + // Both equal, just verify they're scored + if targets[0].score <= 0 { + t.Errorf("target score should be positive, got %f", targets[0].score) + } +} + +func TestScoreTargetsLoneEnemy(t *testing.T) { + rows, cols := 60, 60 + myBots := []Position{{Row: 10, Col: 10}} + enemies := []VisibleBot{ + {Position: Position{Row: 30, Col: 30}, Owner: 1}, // completely alone + } + + s := &OpportunistStrategy{} + targets := s.scoreTargets(enemies, myBots, rows, cols) + + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } + + // Lone enemy: isolation should be 10.0 (max) + if targets[0].isolation != 10.0 { + t.Errorf("lone enemy isolation = %f, want 10.0", targets[0].isolation) + } +} + +func TestShouldFlee(t *testing.T) { + rows, cols := 60, 60 + bot := Position{Row: 10, Col: 10} + + s := &OpportunistStrategy{} + + // No enemies nearby → don't flee + enemies := []VisibleBot{{Position: Position{Row: 30, Col: 30}, Owner: 1}} + myBots := []Position{bot} + if s.shouldFlee(bot, enemies, myBots, rows, cols) { + t.Error("should not flee with no nearby enemies") + } + + // Outnumbered → flee + enemies = []VisibleBot{ + {Position: Position{Row: 11, Col: 10}, Owner: 1}, + {Position: Position{Row: 10, Col: 11}, Owner: 1}, + } + if !s.shouldFlee(bot, enemies, myBots, rows, cols) { + t.Error("should flee when outnumbered") + } + + // Equal numbers → don't flee + myBots = []Position{bot, {Row: 11, Col: 11}} + enemies = []VisibleBot{{Position: Position{Row: 11, Col: 10}, Owner: 1}} + if s.shouldFlee(bot, enemies, myBots, rows, cols) { + t.Error("should not flee with equal numbers") + } +} + +func TestAssignAttackersAdvantage(t *testing.T) { + rows, cols := 60, 60 + attackR2 := 5 + + s := &OpportunistStrategy{} + + // 3 my bots near 1 enemy → should assign attackers + myBots := []Position{ + {Row: 10, Col: 10}, + {Row: 10, Col: 11}, + {Row: 11, Col: 10}, + } + enemies := []VisibleBot{ + {Position: Position{Row: 12, Col: 12}, Owner: 1}, + } + targets := s.scoreTargets(enemies, myBots, rows, cols) + + assignments := s.assignAttackers(targets, myBots, attackR2, rows, cols) + + // Should assign at least 2 bots to the target + assigned := 0 + for _, mb := range myBots { + if _, ok := assignments[mb]; ok { + assigned++ + } + } + if assigned < 2 { + t.Errorf("expected at least 2 attackers assigned, got %d", assigned) + } +} + +func TestAssignAttackersNoAdvantage(t *testing.T) { + rows, cols := 60, 60 + attackR2 := 5 + + s := &OpportunistStrategy{} + + // 1 my bot vs 3 enemies → should NOT assign attackers + myBots := []Position{{Row: 10, Col: 10}} + enemies := []VisibleBot{ + {Position: Position{Row: 11, Col: 10}, Owner: 1}, + {Position: Position{Row: 10, Col: 11}, Owner: 1}, + {Position: Position{Row: 12, Col: 10}, Owner: 1}, + } + targets := s.scoreTargets(enemies, myBots, rows, cols) + + assignments := s.assignAttackers(targets, myBots, attackR2, rows, cols) + + if len(assignments) > 0 { + t.Error("should not assign attackers when outnumbered") + } +} + +func TestComputeMovesBasic(t *testing.T) { + state := &GameState{ + MatchID: "test", + Turn: 1, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, // mine + {Position: Position{Row: 30, Col: 30}, Owner: 1}, // enemy far + }, + Energy: []Position{{Row: 12, Col: 10}}, // energy nearby + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + state.You.Energy = 0 + state.You.Score = 1 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + // Should produce at least one move for our bot + if len(moves) == 0 { + t.Error("expected at least one move") + } +} + +func TestComputeMovesNoEnemies(t *testing.T) { + state := &GameState{ + MatchID: "test", + Turn: 1, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, + }, + Energy: []Position{{Row: 12, Col: 10}}, + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + if len(moves) == 0 { + t.Error("expected at least one move toward energy") + } +} + +func TestComputeMovesRetreat(t *testing.T) { + state := &GameState{ + MatchID: "test", + Turn: 1, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, // my lone bot + {Position: Position{Row: 11, Col: 10}, Owner: 1}, // enemy adjacent + {Position: Position{Row: 10, Col: 11}, Owner: 1}, // enemy adjacent + }, + Energy: []Position{}, + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + // Bot should move (retreat from outnumbered situation) + if len(moves) == 0 { + t.Error("expected bot to retreat from 2v1") + } +} + +func TestBFS(t *testing.T) { + rows, cols := 60, 60 + start := Position{Row: 10, Col: 10} + goal := Position{Row: 12, Col: 10} + + passable := func(p Position) bool { return true } + + dir := BFS(start, goal, passable, rows, cols) + if dir != "S" { + t.Errorf("BFS direction = %q, want %q", dir, "S") + } +} + +func TestBFSWithWall(t *testing.T) { + rows, cols := 60, 60 + start := Position{Row: 10, Col: 10} + goal := Position{Row: 10, Col: 12} + + walls := map[Position]bool{{Row: 10, Col: 11}: true} + passable := func(p Position) bool { return !walls[p] } + + dir := BFS(start, goal, passable, rows, cols) + // Should find a path around the wall + if dir == "" { + t.Error("BFS should find a path around wall") + } +} + +func TestToroidalManhattan(t *testing.T) { + rows, cols := 60, 60 + a := Position{Row: 2, Col: 2} + b := Position{Row: 58, Col: 58} + + d := ToroidalManhattan(a, b, rows, cols) + // Wrapped: dr=4, dc=4 → 8 + if d != 8 { + t.Errorf("ToroidalManhattan = %d, want 8", d) + } +} + +func TestAbs(t *testing.T) { + if abs(-5) != 5 { + t.Error("abs(-5) != 5") + } + if abs(5) != 5 { + t.Error("abs(5) != 5") + } + if abs(0) != 0 { + t.Error("abs(0) != 0") + } +} + +func TestComputeMovesNoSelfCollision(t *testing.T) { + state := &GameState{ + MatchID: "test", + Turn: 1, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, + {Position: Position{Row: 11, Col: 10}, Owner: 0}, + }, + Energy: []Position{{Row: 12, Col: 10}}, // both want to go south + Cores: []VisibleCore{}, + Walls: []Position{}, + } + state.You.ID = 0 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + // Verify no two bots end up on the same destination + destinations := make(map[Position]bool) + for _, m := range moves { + dest := simulateMove(m.Position, m.Direction, 60, 60) + if destinations[dest] { + t.Errorf("two bots assigned to same destination %v", dest) + } + destinations[dest] = true + } +} + +func TestSimulateMove(t *testing.T) { + p := Position{Row: 0, Col: 0} + got := simulateMove(p, "N", 60, 60) + if got.Row != 59 || got.Col != 0 { + t.Errorf("simulateMove N wrap = %v, want {59 0}", got) + } + + got = simulateMove(p, "E", 60, 60) + if got.Row != 0 || got.Col != 1 { + t.Errorf("simulateMove E = %v, want {0 1}", got) + } + + got = simulateMove(Position{Row: 59, Col: 59}, "S", 60, 60) + if got.Row != 0 || got.Col != 59 { + t.Errorf("simulateMove S wrap = %v, want {0 59}", got) + } +} + +func TestScoreTargetsMultipleOwners(t *testing.T) { + rows, cols := 60, 60 + myBots := []Position{{Row: 10, Col: 10}} + enemies := []VisibleBot{ + {Position: Position{Row: 15, Col: 15}, Owner: 1}, // owner 1, alone + {Position: Position{Row: 40, Col: 40}, Owner: 2}, // owner 2, alone + {Position: Position{Row: 41, Col: 41}, Owner: 2}, // owner 2, paired + } + + s := &OpportunistStrategy{} + targets := s.scoreTargets(enemies, myBots, rows, cols) + + if len(targets) != 3 { + t.Fatalf("expected 3 targets, got %d", len(targets)) + } + + // Owner 1's lone enemy should have higher isolation than owner 2's paired enemies + var owner1Target *targetInfo + for i := range targets { + if targets[i].owner == 1 { + owner1Target = &targets[i] + break + } + } + if owner1Target == nil { + t.Fatal("no target found for owner 1") + } + + if owner1Target.isolation != 10.0 { + t.Errorf("lone owner-1 enemy isolation = %f, want 10.0", owner1Target.isolation) + } + + // Highest scoring target should be the most isolated + if targets[0].score <= 0 { + t.Errorf("top target score = %f, expected positive", targets[0].score) + } +} + +func TestComputeMovesLargeScale(t *testing.T) { + // Test with multiple bots and enemies to ensure no panics or deadlocks + state := &GameState{ + MatchID: "test", + Turn: 50, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, + {Position: Position{Row: 10, Col: 12}, Owner: 0}, + {Position: Position{Row: 12, Col: 10}, Owner: 0}, + {Position: Position{Row: 30, Col: 30}, Owner: 1}, + {Position: Position{Row: 32, Col: 30}, Owner: 1}, + {Position: Position{Row: 40, Col: 40}, Owner: 2}, + }, + Energy: []Position{ + {Row: 15, Col: 10}, + {Row: 20, Col: 20}, + }, + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + state.You.Energy = 2 + state.You.Score = 1 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + // Should have moves for our 3 bots + if len(moves) == 0 { + t.Error("expected moves for our bots") + } + + // Verify no self-collision + destinations := make(map[Position]bool) + for _, m := range moves { + dest := simulateMove(m.Position, m.Direction, 60, 60) + if destinations[dest] { + t.Errorf("two bots assigned to same destination %v", dest) + } + destinations[dest] = true + } +} + +func TestComputeMovesNearbyAdvantageAttack(t *testing.T) { + // 3v1 situation: our bots should attack the lone enemy + state := &GameState{ + MatchID: "test", + Turn: 10, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, + {Position: Position{Row: 10, Col: 12}, Owner: 0}, + {Position: Position{Row: 12, Col: 10}, Owner: 0}, + {Position: Position{Row: 14, Col: 14}, Owner: 1}, // lone enemy + }, + Energy: []Position{}, + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + + s := NewOpportunistStrategy() + moves := s.ComputeMoves(state) + + if len(moves) == 0 { + t.Error("expected attack moves in 3v1 situation") + } + + // At least some bots should move toward the enemy + movingTowardEnemy := 0 + enemyPos := Position{Row: 14, Col: 14} + for _, m := range moves { + before := distance2(m.Position, enemyPos, 60, 60) + after := distance2(simulateMove(m.Position, m.Direction, 60, 60), enemyPos, 60, 60) + if after < before { + movingTowardEnemy++ + } + } + + if movingTowardEnemy == 0 { + t.Error("expected at least one bot to move toward the lone enemy") + } +} + +func BenchmarkComputeMoves(b *testing.B) { + state := &GameState{ + MatchID: "bench", + Turn: 100, + Config: GameConfig{ + Rows: 60, + Cols: 60, + MaxTurns: 500, + VisionRadius2: 49, + AttackRadius2: 5, + SpawnCost: 3, + EnergyInterval: 10, + }, + Bots: []VisibleBot{ + {Position: Position{Row: 10, Col: 10}, Owner: 0}, + {Position: Position{Row: 12, Col: 12}, Owner: 0}, + {Position: Position{Row: 14, Col: 14}, Owner: 0}, + {Position: Position{Row: 30, Col: 30}, Owner: 1}, + {Position: Position{Row: 32, Col: 32}, Owner: 1}, + }, + Energy: []Position{{Row: 20, Col: 20}, {Row: 25, Col: 25}}, + Cores: []VisibleCore{ + {Position: Position{Row: 5, Col: 5}, Owner: 0, Active: true}, + }, + Walls: []Position{}, + } + state.You.ID = 0 + + s := NewOpportunistStrategy() + + // Use the value to prevent compiler optimization + _ = math.Sqrt(1.0) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.ComputeMoves(state) + } +}