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) + } +}