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:
parent
968af06522
commit
5362b6c011
6 changed files with 923 additions and 0 deletions
19
bots/farmer/Dockerfile
Normal file
19
bots/farmer/Dockerfile
Normal 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
3
bots/farmer/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module acb-farmer
|
||||
|
||||
go 1.22
|
||||
92
bots/farmer/grid.go
Normal file
92
bots/farmer/grid.go
Normal 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
159
bots/farmer/main.go
Normal 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
339
bots/farmer/strategy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
311
bots/farmer/strategy_test.go
Normal file
311
bots/farmer/strategy_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue