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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 17:03:02 -04:00
parent e3e59396f3
commit cecbc4a2a0
6 changed files with 1246 additions and 0 deletions

View file

@ -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"]

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

@ -0,0 +1,3 @@
module acb-opportunist
go 1.22

107
bots/opportunist/grid.go Normal file
View file

@ -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
}

159
bots/opportunist/main.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

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