ai-code-battle/bots/opportunist/strategy.go
jedarden cecbc4a2a0 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>
2026-04-22 17:03:02 -04:00

409 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}