feat(bot): add Farmer bot (Go) — economy-maximizer archetype

Economy-maximizing bot that prioritizes energy collection and spawning
while avoiding combat entirely. Seeks nearest uncontested energy via BFS,
flees enemies within 3 cells, avoids contested energy tiles, and stays
near active cores for maximum spawn throughput. Includes 12 unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 16:30:26 -04:00
parent 968af06522
commit 5362b6c011
6 changed files with 923 additions and 0 deletions

19
bots/farmer/Dockerfile Normal file
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 farmer .
FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/farmer .
ENV BOT_PORT=8080
ENV BOT_SECRET=""
EXPOSE 8080
CMD ["./farmer"]

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

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

92
bots/farmer/grid.go Normal file
View file

@ -0,0 +1,92 @@
package main
func ToroidalManhattan(a, b Position, rows, cols int) int {
dr := abs(a.Row - b.Row)
dc := abs(a.Col - b.Col)
dr = min(dr, rows-dr)
dc = min(dc, cols-dc)
return dr + dc
}
func distance2(a, b Position, rows, cols int) int {
dr := abs(a.Row - b.Row)
dc := abs(a.Col - b.Col)
dr = min(dr, rows-dr)
dc = min(dc, cols-dc)
return dr*dr + dc*dc
}
type cardinalStep struct {
pos Position
dir string
}
func cardinalSteps(p Position, rows, cols int) []cardinalStep {
steps := []struct {
dr, dc int
dir string
}{{-1, 0, "N"}, {0, 1, "E"}, {1, 0, "S"}, {0, -1, "W"}}
result := make([]cardinalStep, 0, 4)
for _, s := range steps {
result = append(result, cardinalStep{
pos: Position{
Row: (p.Row + s.dr + rows) % rows,
Col: (p.Col + s.dc + cols) % cols,
},
dir: s.dir,
})
}
return result
}
// BFS finds the shortest path from start to goal on a toroidal grid
// using 4-directional (cardinal) movement. passable returns true if a cell can be entered.
// Returns the first direction to move, or "" if unreachable.
func BFS(start, goal Position, passable func(Position) bool, rows, cols int) string {
if start == goal {
return ""
}
type node struct {
pos Position
dir string
}
visited := map[Position]bool{start: true}
queue := []node{}
for _, step := range cardinalSteps(start, rows, cols) {
if step.pos == goal && passable(step.pos) {
return step.dir
}
if passable(step.pos) && !visited[step.pos] {
visited[step.pos] = true
queue = append(queue, node{step.pos, step.dir})
}
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if cur.pos == goal {
return cur.dir
}
for _, step := range cardinalSteps(cur.pos, rows, cols) {
if !visited[step.pos] && passable(step.pos) {
visited[step.pos] = true
queue = append(queue, node{step.pos, cur.dir})
}
}
}
return ""
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

159
bots/farmer/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 := NewFarmerStrategy()
http.HandleFunc("/turn", func(w http.ResponseWriter, r *http.Request) {
handleTurn(w, r, secret, strategy)
})
http.HandleFunc("/health", handleHealth)
addr := fmt.Sprintf(":%s", port)
log.Printf("Farmer bot listening on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func handleTurn(w http.ResponseWriter, r *http.Request, secret string, strategy *FarmerStrategy) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
sig := r.Header.Get("X-ACB-Signature")
matchID := r.Header.Get("X-ACB-Match-Id")
turnStr := r.Header.Get("X-ACB-Turn")
timestamp := r.Header.Get("X-ACB-Timestamp")
if sig == "" || !verifySignature(secret, matchID, turnStr, timestamp, body, sig) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var state GameState
if err := json.Unmarshal(body, &state); err != nil {
http.Error(w, "invalid game state", http.StatusBadRequest)
return
}
moves := strategy.ComputeMoves(&state)
response := MoveResponse{Moves: moves}
responseBody, _ := json.Marshal(response)
turn, _ := strconv.Atoi(turnStr)
responseSig := signResponse(secret, matchID, turn, responseBody)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ACB-Signature", responseSig)
w.Write(responseBody)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func verifySignature(secret, matchID, turnStr, timestamp string, body []byte, signature string) bool {
bodyHash := sha256.Sum256(body)
signingString := fmt.Sprintf("%s.%s.%s.%s", matchID, turnStr, timestamp, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func signResponse(secret, matchID string, turn int, body []byte) string {
bodyHash := sha256.Sum256(body)
signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
return hex.EncodeToString(mac.Sum(nil))
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

339
bots/farmer/strategy.go Normal file
View file

@ -0,0 +1,339 @@
package main
import "math"
const (
fleeRadius2 = 9 // flee if enemy within 3 cells (squared = 9)
dangerBuffer = 20 // extra buffer beyond attack radius for avoidance
)
// FarmerStrategy maximizes energy collection and spawn rate while avoiding combat.
type FarmerStrategy struct{}
func NewFarmerStrategy() *FarmerStrategy {
return &FarmerStrategy{}
}
// ComputeMoves assigns each owned bot to seek energy, flee enemies, or
// stay near core for spawning.
func (s *FarmerStrategy) ComputeMoves(state *GameState) []Move {
rows := state.Config.Rows
cols := state.Config.Cols
attackR2 := state.Config.AttackRadius2
myID := state.You.ID
// Build lookup maps
wallSet := make(map[Position]bool, len(state.Walls))
for _, w := range state.Walls {
wallSet[w] = true
}
enemySet := make(map[Position]bool)
enemyPositions := make([]Position, 0)
for _, b := range state.Bots {
if b.Owner != myID {
enemySet[b.Position] = true
enemyPositions = append(enemyPositions, b.Position)
}
}
// Identify my active cores
myCores := make([]Position, 0)
for _, c := range state.Cores {
if c.Owner == myID && c.Active {
myCores = append(myCores, c.Position)
}
}
// Determine which energy tiles are contested (enemy adjacent)
contestedEnergy := make(map[Position]bool)
for _, e := range state.Energy {
for _, ep := range enemyPositions {
if distance2(e, ep, rows, cols) <= 2 {
contestedEnergy[e] = true
break
}
}
}
// Separate my bots
myBots := make([]VisibleBot, 0, len(state.Bots))
for _, b := range state.Bots {
if b.Owner == myID {
myBots = append(myBots, b)
}
}
// Track assigned energy targets to avoid duplicate assignments
assignedEnergy := make(map[Position]bool)
// Track claimed destinations to prevent self-collision
claimedDests := make(map[Position]bool)
// Sort bots: prioritize bots closest to uncontested energy
botScores := make([]int, len(myBots))
for i, b := range myBots {
bestDist := math.MaxInt32
for _, e := range state.Energy {
if !contestedEnergy[e] {
d := distance2(b.Position, e, rows, cols)
if d < bestDist {
bestDist = d
}
}
}
botScores[i] = bestDist
}
// Simple selection sort for small arrays
sorted := make([]int, len(myBots))
for i := range sorted {
sorted[i] = i
}
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if botScores[sorted[j]] < botScores[sorted[i]] {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
moves := make([]Move, 0, len(myBots))
for _, idx := range sorted {
bot := myBots[idx]
dir := s.computeBotMove(bot, state, wallSet, enemyPositions, enemySet,
myCores, contestedEnergy, assignedEnergy, claimedDests, rows, cols, attackR2)
dest := bot.Position
if dir != "" {
dest = simulateMove(bot.Position, dir, rows, cols)
}
// If destination is already claimed by another bot, hold position
if dir != "" && claimedDests[dest] {
dir = ""
dest = bot.Position
}
claimedDests[dest] = true
if dir != "" {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
}
}
return moves
}
func (s *FarmerStrategy) computeBotMove(
bot VisibleBot,
state *GameState,
wallSet map[Position]bool,
enemyPositions []Position,
enemySet map[Position]bool,
myCores []Position,
contestedEnergy map[Position]bool,
assignedEnergy map[Position]bool,
claimedDests map[Position]bool,
rows, cols, attackR2 int,
) string {
pos := bot.Position
// Priority 1: FLEE if any enemy within flee radius
if len(enemyPositions) > 0 {
minEnemyDist2 := math.MaxInt32
for _, ep := range enemyPositions {
d := distance2(pos, ep, rows, cols)
if d < minEnemyDist2 {
minEnemyDist2 = d
}
}
if minEnemyDist2 <= fleeRadius2 {
dir := s.fleeDirection(pos, enemyPositions, wallSet, enemySet, rows, cols)
if dir != "" {
return dir
}
}
// Also flee if enemy within attack radius + buffer
if minEnemyDist2 <= attackR2+dangerBuffer {
dir := s.fleeDirection(pos, enemyPositions, wallSet, enemySet, rows, cols)
if dir != "" {
return dir
}
}
}
passable := func(p Position) bool {
if wallSet[p] {
return false
}
// Avoid stepping directly onto enemy positions
if enemySet[p] {
return false
}
return true
}
// Priority 2: Seek nearest uncontested, unassigned energy
var bestEnergyTarget *Position
bestDist := math.MaxInt32
for i, e := range state.Energy {
if contestedEnergy[e] || assignedEnergy[e] {
continue
}
d := distance2(pos, e, rows, cols)
if d < bestDist {
bestDist = d
eCopy := state.Energy[i]
bestEnergyTarget = &eCopy
}
}
if bestEnergyTarget != nil {
assignedEnergy[*bestEnergyTarget] = true
dir := BFS(pos, *bestEnergyTarget, passable, rows, cols)
if dir != "" {
return dir
}
}
// Priority 3: If on or adjacent to energy, collect it (hold or step onto)
for _, e := range state.Energy {
if e == pos {
// Already on energy, hold to collect
return ""
}
}
// Priority 4: Move toward nearest energy (even contested)
if len(state.Energy) > 0 {
bestDist = math.MaxInt32
var target Position
for _, e := range state.Energy {
d := distance2(pos, e, rows, cols)
if d < bestDist {
bestDist = d
target = e
}
}
dir := BFS(pos, target, passable, rows, cols)
if dir != "" {
return dir
}
}
// Priority 5: Stay near active core for spawning
if len(myCores) > 0 {
nearestCoreDist := math.MaxInt32
var nearestCore Position
for _, c := range myCores {
d := distance2(pos, c, rows, cols)
if d < nearestCoreDist {
nearestCoreDist = d
nearestCore = c
}
}
// If far from core, move toward it
if nearestCoreDist > 4 {
dir := BFS(pos, nearestCore, passable, rows, cols)
if dir != "" {
return dir
}
}
}
// Priority 6: Spread out from other friendly bots to avoid self-collision
return s.spreadMove(pos, state, claimedDests, rows, cols)
}
// fleeDirection picks the cardinal direction that maximizes distance from
// the nearest enemy.
func (s *FarmerStrategy) fleeDirection(
pos Position,
enemies []Position,
wallSet, enemySet map[Position]bool,
rows, cols int,
) string {
bestDir := ""
bestMinDist := -1
for _, step := range cardinalSteps(pos, rows, cols) {
if wallSet[step.pos] || enemySet[step.pos] {
continue
}
minDist := math.MaxInt32
for _, ep := range enemies {
d := distance2(step.pos, ep, rows, cols)
if d < minDist {
minDist = d
}
}
if minDist > bestMinDist {
bestMinDist = minDist
bestDir = step.dir
}
}
return bestDir
}
// spreadMove picks a direction that moves away from the densest cluster
// of friendly bots.
func (s *FarmerStrategy) spreadMove(
pos Position,
state *GameState,
claimedDests map[Position]bool,
rows, cols int,
) string {
myID := state.You.ID
bestDir := ""
bestScore := -1
for _, step := range cardinalSteps(pos, rows, cols) {
if claimedDests[step.pos] {
continue
}
// Score = minimum distance to any friendly bot (maximize spacing)
minDist := math.MaxInt32
for _, b := range state.Bots {
if b.Owner != myID {
continue
}
d := distance2(step.pos, b.Position, rows, cols)
if d < minDist {
minDist = d
}
}
if minDist > bestScore {
bestScore = minDist
bestDir = step.dir
}
}
return bestDir
}
func simulateMove(pos Position, dir string, rows, cols int) Position {
dr, dc := 0, 0
switch dir {
case "N":
dr = -1
case "S":
dr = 1
case "E":
dc = 1
case "W":
dc = -1
}
return Position{
Row: (pos.Row + dr + rows) % rows,
Col: (pos.Col + dc + cols) % cols,
}
}

View file

@ -0,0 +1,311 @@
package main
import (
"testing"
)
func makeState(myID, energy int, bots []VisibleBot, energyTiles []Position, cores []VisibleCore, walls []Position) *GameState {
state := &GameState{
MatchID: "test",
Turn: 1,
Config: GameConfig{
Rows: 20,
Cols: 20,
MaxTurns: 500,
VisionRadius2: 49,
AttackRadius2: 5,
SpawnCost: 3,
EnergyInterval: 10,
},
Energy: energyTiles,
Cores: cores,
Walls: walls,
}
state.You.ID = myID
state.You.Energy = energy
state.Bots = bots
return state
}
func TestFarmerSeeksEnergy(t *testing.T) {
state := makeState(0, 0,
[]VisibleBot{{Position: Position{10, 10}, Owner: 0}},
[]Position{{10, 14}}, // energy 4 tiles east
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
if moves[0].Direction != "E" {
t.Errorf("expected move E toward energy, got %s", moves[0].Direction)
}
}
func TestFarmerFleesEnemy(t *testing.T) {
state := makeState(0, 0,
[]VisibleBot{
{Position: Position{10, 10}, Owner: 0}, // my bot
{Position: Position{10, 12}, Owner: 1}, // enemy 2 tiles south
},
[]Position{{10, 14}}, // energy exists but should flee instead
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
// Should flee away from enemy at (10,12), not go toward energy at (10,14)
if moves[0].Direction == "E" {
t.Errorf("bot should flee from enemy, not move toward energy; got %s", moves[0].Direction)
}
}
func TestFarmerFleesFromNearbyEnemy(t *testing.T) {
// Enemy within 3 tiles (fleeRadius2 = 9, distance2 = 4 < 9)
state := makeState(0, 0,
[]VisibleBot{
{Position: Position{10, 10}, Owner: 0},
{Position: Position{12, 10}, Owner: 1}, // enemy 2 tiles south, d2=4
},
nil,
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
// Should flee north (away from enemy to the south)
if moves[0].Direction != "N" {
t.Errorf("expected flee north, got %s", moves[0].Direction)
}
}
func TestFarmerMultipleBotsSeekDifferentEnergy(t *testing.T) {
state := makeState(0, 6,
[]VisibleBot{
{Position: Position{5, 5}, Owner: 0},
{Position: Position{15, 15}, Owner: 0},
},
[]Position{{5, 8}, {15, 12}}, // two energy tiles
[]VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 2 {
t.Fatalf("expected 2 moves, got %d", len(moves))
}
// Each bot should target its nearest energy
dirs := map[string]bool{}
for _, m := range moves {
dirs[m.Direction] = true
}
// Bot at (5,5) should move E toward (5,8)
// Bot at (15,15) should move W toward (15,12)
// We can't guarantee exact mapping, but both should have moves
if len(dirs) < 1 {
t.Errorf("expected bots to move toward different energy, got dirs: %v", dirs)
}
}
func TestFarmerHoldsOnEnergy(t *testing.T) {
// Bot already on energy tile, no enemies
state := makeState(0, 0,
[]VisibleBot{{Position: Position{10, 10}, Owner: 0}},
[]Position{{10, 10}}, // energy on same tile as bot
[]VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
// Bot should hold position to collect energy (no move issued)
if len(moves) != 0 {
t.Errorf("expected bot to hold on energy (0 moves), got %d", len(moves))
}
}
func TestFarmerStaysNearCore(t *testing.T) {
// Bot far from core, no energy, no enemies
state := makeState(0, 0,
[]VisibleBot{{Position: Position{15, 15}, Owner: 0}},
nil,
[]VisibleCore{{Position: Position{5, 5}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
// Should move toward core at (5,5), i.e. N or W
dir := moves[0].Direction
if dir != "N" && dir != "W" {
t.Errorf("expected move toward core (N or W), got %s", dir)
}
}
func TestFarmerIgnoresContestedEnergy(t *testing.T) {
// Energy at (10,14) with enemy adjacent at (10,15) - contested
// Uncontested energy at (10,5)
state := makeState(0, 0,
[]VisibleBot{
{Position: Position{10, 10}, Owner: 0},
{Position: Position{10, 15}, Owner: 1}, // enemy adjacent to energy
},
[]Position{{10, 14}, {10, 5}}, // contested energy, safe energy
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
// Should prefer safe energy at (10,5) -> move W
if moves[0].Direction != "W" {
t.Errorf("expected move W toward safe energy, got %s", moves[0].Direction)
}
}
func TestFarmerAvoidsWalls(t *testing.T) {
// Bot at (10,10), energy at (10,14), wall at (10,11) blocking direct path
state := makeState(0, 0,
[]VisibleBot{{Position: Position{10, 10}, Owner: 0}},
[]Position{{10, 14}},
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
[]Position{{10, 11}},
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
if len(moves) != 1 {
t.Fatalf("expected 1 move, got %d", len(moves))
}
// Should not move E into the wall
if moves[0].Direction == "E" {
t.Errorf("bot should avoid wall at (10,11), got direction E")
}
}
func TestDistance2(t *testing.T) {
tests := []struct {
a, b Position
rows, cols int
want int
}{
{Position{0, 0}, Position{0, 0}, 20, 20, 0},
{Position{0, 0}, Position{0, 3}, 20, 20, 9},
{Position{0, 0}, Position{3, 4}, 20, 20, 25},
// Toroidal: distance from (0,0) to (19,0) on 20-row grid = 1 row
{Position{0, 0}, Position{19, 0}, 20, 20, 1},
// Toroidal: distance from (0,0) to (0,19) on 20-col grid = 1 col
{Position{0, 0}, Position{0, 19}, 20, 20, 1},
}
for _, tt := range tests {
got := distance2(tt.a, tt.b, tt.rows, tt.cols)
if got != tt.want {
t.Errorf("distance2(%v, %v, %d, %d) = %d, want %d",
tt.a, tt.b, tt.rows, tt.cols, got, tt.want)
}
}
}
func TestBFS(t *testing.T) {
wallSet := map[Position]bool{{10, 11}: true}
passable := func(p Position) bool { return !wallSet[p] }
// Direct path north
dir := BFS(Position{5, 5}, Position{3, 5}, passable, 20, 20)
if dir != "N" {
t.Errorf("BFS to north: got %q, want N", dir)
}
// Path around wall: (10,10) to (10,14) with wall at (10,11)
dir = BFS(Position{10, 10}, Position{10, 14}, passable, 20, 20)
if dir == "" {
t.Error("BFS should find path around wall")
}
// Should go N or S to bypass wall at (10,11), not E
if dir == "E" {
t.Errorf("BFS should not go directly into wall, got E")
}
}
func TestSimulateMove(t *testing.T) {
tests := []struct {
pos Position
dir string
rows, cols int
want Position
}{
{Position{5, 5}, "N", 20, 20, Position{4, 5}},
{Position{5, 5}, "S", 20, 20, Position{6, 5}},
{Position{5, 5}, "E", 20, 20, Position{5, 6}},
{Position{5, 5}, "W", 20, 20, Position{5, 4}},
// Toroidal wrap
{Position{0, 0}, "N", 20, 20, Position{19, 0}},
{Position{0, 0}, "W", 20, 20, Position{0, 19}},
{Position{19, 19}, "S", 20, 20, Position{0, 19}},
{Position{19, 19}, "E", 20, 20, Position{19, 0}},
}
for _, tt := range tests {
got := simulateMove(tt.pos, tt.dir, tt.rows, tt.cols)
if got != tt.want {
t.Errorf("simulateMove(%v, %q, %d, %d) = %v, want %v",
tt.pos, tt.dir, tt.rows, tt.cols, got, tt.want)
}
}
}
func TestFarmerNoSelfCollision(t *testing.T) {
// Two bots at (10,10) and (10,11), energy at (10,12)
// Both want to go east, but can't land on same tile
state := makeState(0, 0,
[]VisibleBot{
{Position: Position{10, 10}, Owner: 0},
{Position: Position{10, 11}, Owner: 0},
},
[]Position{{10, 12}},
[]VisibleCore{{Position: Position{10, 10}, Owner: 0, Active: true}},
nil,
)
s := NewFarmerStrategy()
moves := s.ComputeMoves(state)
// Collect destinations
dests := map[Position]bool{}
for _, m := range moves {
dest := simulateMove(m.Position, m.Direction, 20, 20)
if dests[dest] {
t.Errorf("two bots collide at %v", dest)
}
dests[dest] = true
}
}