feat(evolver): add phalanx, assassin, opportunist as generation-0 seeds
Add 3 new hand-written bots to expand evolver seed pool: - phalanx (Rust, alpha island) - formation combat with aggression=0.85, economy=0.1 - assassin (Rust, alpha island) - decapitation strategy with aggression=1.0, economy=0.0 - opportunist (Go, beta island) - conditional aggression with aggression=0.55, economy=0.55 This brings the total seed count from 6 to 9, providing richer starting population across islands with diverse attack archetypes the evolver can recombine. Changes: - Add seeds/phalanx_strategy.rs.txt, assassin_strategy.rs.txt, opportunist_strategy.go.txt - Update seed.go with 3 new embed directives and seed entries - Add TestSeedPopulation to verify 9 seeds insert correctly - Update comments to reflect 9 seeds instead of 6 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dce34f97b6
commit
42398eb34a
5 changed files with 1285 additions and 2 deletions
|
|
@ -393,3 +393,55 @@ func TestGetLineage(t *testing.T) {
|
|||
t.Errorf("expected 3 ancestors in lineage, got %d: %v", len(lineage), lineage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedPopulation(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
setupTestSchema(t, db)
|
||||
s := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Verify DB is empty
|
||||
total, err := s.TotalCount(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("TotalCount: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("expected empty DB, got %d programs", total)
|
||||
}
|
||||
|
||||
// Seed population
|
||||
inserted, err := SeedPopulation(ctx, s)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPopulation: %v", err)
|
||||
}
|
||||
if inserted != 9 {
|
||||
t.Errorf("expected 9 seeds inserted, got %d", inserted)
|
||||
}
|
||||
|
||||
// Verify final count
|
||||
total, err = s.TotalCount(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("TotalCount after seed: %v", err)
|
||||
}
|
||||
if total != 9 {
|
||||
t.Errorf("expected 9 programs in DB, got %d", total)
|
||||
}
|
||||
|
||||
// Verify idempotence: second call should insert 0
|
||||
inserted2, err := SeedPopulation(ctx, s)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPopulation second call: %v", err)
|
||||
}
|
||||
if inserted2 != 0 {
|
||||
t.Errorf("expected 0 inserts on second call, got %d", inserted2)
|
||||
}
|
||||
|
||||
// Count should still be 9
|
||||
total, err = s.TotalCount(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("TotalCount after second seed: %v", err)
|
||||
}
|
||||
if total != 9 {
|
||||
t.Errorf("expected 9 programs after second call, got %d", total)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ var hunterCode string
|
|||
//go:embed seeds/random_main.py.txt
|
||||
var randomCode string
|
||||
|
||||
//go:embed seeds/phalanx_strategy.rs.txt
|
||||
var phalanxCode string
|
||||
|
||||
//go:embed seeds/assassin_strategy.rs.txt
|
||||
var assassinCode string
|
||||
|
||||
//go:embed seeds/opportunist_strategy.go.txt
|
||||
var opportunistCode string
|
||||
|
||||
// seedProgram describes a built-in strategy bot used to bootstrap the
|
||||
// programs database.
|
||||
type seedProgram struct {
|
||||
|
|
@ -35,7 +44,7 @@ type seedProgram struct {
|
|||
code string
|
||||
}
|
||||
|
||||
// seeds is the initial population of 6 built-in strategy bots distributed
|
||||
// seeds is the initial population of 9 built-in strategy bots distributed
|
||||
// across all 4 islands. Each bot is assigned a behavior vector that captures
|
||||
// its play-style on the aggression × economy axes.
|
||||
var seeds = []seedProgram{
|
||||
|
|
@ -48,6 +57,14 @@ var seeds = []seedProgram{
|
|||
economy: 0.9,
|
||||
code: gathererCode,
|
||||
},
|
||||
{
|
||||
name: "opportunist",
|
||||
language: "go",
|
||||
island: IslandBeta,
|
||||
aggression: 0.55,
|
||||
economy: 0.55,
|
||||
code: opportunistCode,
|
||||
},
|
||||
{
|
||||
name: "guardian",
|
||||
language: "php",
|
||||
|
|
@ -65,6 +82,22 @@ var seeds = []seedProgram{
|
|||
economy: 0.2,
|
||||
code: rusherCode,
|
||||
},
|
||||
{
|
||||
name: "phalanx",
|
||||
language: "rust",
|
||||
island: IslandAlpha,
|
||||
aggression: 0.85,
|
||||
economy: 0.1,
|
||||
code: phalanxCode,
|
||||
},
|
||||
{
|
||||
name: "assassin",
|
||||
language: "rust",
|
||||
island: IslandAlpha,
|
||||
aggression: 1.0,
|
||||
economy: 0.0,
|
||||
code: assassinCode,
|
||||
},
|
||||
{
|
||||
name: "swarm",
|
||||
language: "typescript",
|
||||
|
|
@ -93,7 +126,7 @@ var seeds = []seedProgram{
|
|||
},
|
||||
}
|
||||
|
||||
// SeedPopulation inserts the 6 built-in strategy bots as generation-0
|
||||
// SeedPopulation inserts the 9 built-in strategy bots as generation-0
|
||||
// programs if the programs table is empty. It is idempotent: a second call
|
||||
// is a no-op.
|
||||
func SeedPopulation(ctx context.Context, s *Store) (int, error) {
|
||||
|
|
|
|||
206
cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt
Normal file
206
cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
//! Assassin strategy: decapitation archetype — all units rush the enemy core.
|
||||
//!
|
||||
//! Ignores economy, ignores enemy units (unless directly blocking), and commits
|
||||
//! fully to core destruction. No perimeter defense. Relies on speed and mass.
|
||||
|
||||
use crate::game::{Direction, GameConfig, GameState, Move, Position};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
pub struct AssassinStrategy {
|
||||
/// Enemy cores discovered so far (persisted across turns)
|
||||
known_targets: HashMap<Position, bool>,
|
||||
}
|
||||
|
||||
impl AssassinStrategy {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
known_targets: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_moves(&mut self, state: &GameState) -> Vec<Move> {
|
||||
let my_id = state.you.id;
|
||||
let config = &state.config;
|
||||
let rows = config.rows as i32;
|
||||
let cols = config.cols as i32;
|
||||
|
||||
self.update_targets(state, my_id);
|
||||
|
||||
let my_bots: Vec<_> = state.bots.iter().filter(|b| b.owner == my_id).collect();
|
||||
if my_bots.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let walls: HashSet<Position> = state.walls.iter().copied().collect();
|
||||
|
||||
// Active enemy core targets, sorted by distance from our center of mass
|
||||
let targets = self.active_targets();
|
||||
let center = center_of_mass(&my_bots);
|
||||
|
||||
let mut sorted_targets: Vec<Position> = targets
|
||||
.iter()
|
||||
.filter(|(_, active)| **active)
|
||||
.map(|(pos, _)| *pos)
|
||||
.collect();
|
||||
|
||||
sorted_targets.sort_by_key(|t| center.distance2(t, rows, cols));
|
||||
|
||||
// If no known targets, explore outward
|
||||
if sorted_targets.is_empty() {
|
||||
return self.explore_moves(&my_bots, &walls, config);
|
||||
}
|
||||
|
||||
// Primary target: nearest active enemy core to our center of mass
|
||||
let primary = sorted_targets[0];
|
||||
|
||||
// BFS from each bot to the primary target, walking through enemies
|
||||
let mut moves = Vec::with_capacity(my_bots.len());
|
||||
let mut destinations: HashSet<Position> = HashSet::new();
|
||||
|
||||
for bot in &my_bots {
|
||||
if let Some(dir) = self.bfs_toward(bot.position, primary, &walls, &destinations, rows, cols) {
|
||||
let dest = bot.position.move_toward(dir, rows, cols);
|
||||
destinations.insert(dest);
|
||||
moves.push(Move {
|
||||
position: bot.position,
|
||||
direction: dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
moves
|
||||
}
|
||||
|
||||
/// Update known targets from visible cores
|
||||
fn update_targets(&mut self, state: &GameState, my_id: u32) {
|
||||
for core in &state.cores {
|
||||
if core.owner != my_id {
|
||||
self.known_targets
|
||||
.entry(core.position)
|
||||
.and_modify(|a| *a = core.active)
|
||||
.or_insert(core.active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_targets(&self) -> &HashMap<Position, bool> {
|
||||
&self.known_targets
|
||||
}
|
||||
|
||||
/// BFS toward a target position. Unlike rusher, does NOT avoid enemy bots —
|
||||
/// we walk straight through them. Only walls block movement.
|
||||
/// Avoids self-collision by checking already-claimed destinations.
|
||||
fn bfs_toward(
|
||||
&self,
|
||||
start: Position,
|
||||
goal: Position,
|
||||
walls: &HashSet<Position>,
|
||||
claimed: &HashSet<Position>,
|
||||
rows: i32,
|
||||
cols: i32,
|
||||
) -> Option<Direction> {
|
||||
if start == goal {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut visited: HashSet<Position> = HashSet::new();
|
||||
let mut queue: VecDeque<(Position, Option<Direction>)> = VecDeque::new();
|
||||
visited.insert(start);
|
||||
queue.push_back((start, None));
|
||||
|
||||
while let Some((pos, first_dir)) = queue.pop_front() {
|
||||
if pos == goal {
|
||||
return first_dir;
|
||||
}
|
||||
|
||||
for dir in Direction::all() {
|
||||
let next = pos.move_toward(dir, rows, cols);
|
||||
if visited.contains(&next) || walls.contains(&next) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(next);
|
||||
queue.push_back((next, first_dir.or(Some(dir))));
|
||||
}
|
||||
}
|
||||
|
||||
// No path to goal — pick the direction that gets us closest
|
||||
let mut best_dir = None;
|
||||
let mut best_dist = i32::MAX;
|
||||
for dir in Direction::all() {
|
||||
let next = start.move_toward(dir, rows, cols);
|
||||
if walls.contains(&next) || claimed.contains(&next) {
|
||||
continue;
|
||||
}
|
||||
let dr = (next.row - goal.row).abs();
|
||||
let dc = (next.col - goal.col).abs();
|
||||
let dist = dr.min(rows - dr) + dc.min(cols - dc);
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best_dir = Some(dir);
|
||||
}
|
||||
}
|
||||
best_dir
|
||||
}
|
||||
|
||||
/// When no targets are known, spread bots outward to find enemy cores
|
||||
fn explore_moves(
|
||||
&self,
|
||||
my_bots: &[&crate::game::VisibleBot],
|
||||
walls: &HashSet<Position>,
|
||||
config: &GameConfig,
|
||||
) -> Vec<Move> {
|
||||
let rows = config.rows as i32;
|
||||
let cols = config.cols as i32;
|
||||
let mut moves = Vec::with_capacity(my_bots.len());
|
||||
|
||||
// Spread in a line toward the opposite side of the map
|
||||
for (i, bot) in my_bots.iter().enumerate() {
|
||||
let target_col = if i % 2 == 0 { cols - 1 } else { 0 };
|
||||
let target_row = if i % 3 == 0 { rows / 2 } else { rows - 1 };
|
||||
let target = Position { row: target_row, col: target_col };
|
||||
|
||||
let mut best_dir = None;
|
||||
let mut best_dist = i32::MAX;
|
||||
for dir in Direction::all() {
|
||||
let next = bot.position.move_toward(dir, rows, cols);
|
||||
if walls.contains(&next) {
|
||||
continue;
|
||||
}
|
||||
let dr = (next.row - target.row).abs();
|
||||
let dc = (next.col - target.col).abs();
|
||||
let dist = dr.min(rows - dr) + dc.min(cols - dc);
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best_dir = Some(dir);
|
||||
}
|
||||
}
|
||||
if let Some(dir) = best_dir {
|
||||
moves.push(Move {
|
||||
position: bot.position,
|
||||
direction: dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
moves
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AssassinStrategy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute center of mass of our bots
|
||||
fn center_of_mass(bots: &[&crate::game::VisibleBot]) -> Position {
|
||||
if bots.is_empty() {
|
||||
return Position { row: 0, col: 0 };
|
||||
}
|
||||
let sum_r: i32 = bots.iter().map(|b| b.position.row).sum();
|
||||
let sum_c: i32 = bots.iter().map(|b| b.position.col).sum();
|
||||
Position {
|
||||
row: sum_r / bots.len() as i32,
|
||||
col: sum_c / bots.len() as i32,
|
||||
}
|
||||
}
|
||||
516
cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt
Normal file
516
cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
476
cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt
Normal file
476
cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
//! Phalanx strategy: tight formation combat.
|
||||
//!
|
||||
//! All units move as a coordinated group, maximizing local firepower.
|
||||
//! - Computes group centroid each tick using circular mean (toroidal-aware)
|
||||
//! - Each unit maintains a fixed offset from centroid (hexagonal packing)
|
||||
//! - Group advances toward nearest enemy concentration
|
||||
//! - If formation breaks (units >3 cells from centroid), rally before advancing
|
||||
//! - Spawned units join the back of the formation
|
||||
|
||||
use crate::game::{Direction, GameConfig, GameState, Move, Position};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Maximum allowed mean squared distance from centroid before rally mode
|
||||
const FORMATION_RADIUS2: u32 = 9; // 3 cells squared
|
||||
/// Bonus weight for advancing toward enemies
|
||||
const ADVANCE_WEIGHT: f64 = 10.0;
|
||||
/// Penalty per unit distance from formation slot
|
||||
const FORMATION_WEIGHT: f64 = 8.0;
|
||||
/// Bonus for being within attack range of an enemy
|
||||
const ATTACK_RANGE_BONUS: f64 = 50.0;
|
||||
|
||||
pub struct PhalanxStrategy {
|
||||
/// Persistent centroid estimate (smoothed across turns for stability)
|
||||
centroid: Option<Position>,
|
||||
/// Known enemy positions from last turn (for tracking movement)
|
||||
last_enemy_positions: HashSet<Position>,
|
||||
}
|
||||
|
||||
impl PhalanxStrategy {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
centroid: None,
|
||||
last_enemy_positions: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_moves(&mut self, state: &GameState) -> Vec<Move> {
|
||||
let my_id = state.you.id;
|
||||
let config = &state.config;
|
||||
let rows = config.rows as i32;
|
||||
let cols = config.cols as i32;
|
||||
|
||||
// Separate my bots from enemies
|
||||
let (my_bots, enemy_bots): (Vec<_>, Vec<_>) =
|
||||
state.bots.iter().partition(|b| b.owner == my_id);
|
||||
|
||||
if my_bots.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let my_positions: Vec<Position> = my_bots.iter().map(|b| b.position).collect();
|
||||
let enemy_positions: Vec<Position> = enemy_bots.iter().map(|b| b.position).collect();
|
||||
|
||||
// Build wall and enemy lookups
|
||||
let walls: HashSet<Position> = state.walls.iter().copied().collect();
|
||||
let enemy_set: HashSet<Position> = enemy_positions.iter().copied().collect();
|
||||
|
||||
// Compute group centroid using circular mean
|
||||
let centroid = circular_mean(&my_positions, rows, cols);
|
||||
|
||||
// Smooth centroid with previous value for stability
|
||||
let centroid = if let Some(prev) = self.centroid {
|
||||
smooth_centroid(&prev, ¢roid, rows, cols)
|
||||
} else {
|
||||
centroid
|
||||
};
|
||||
self.centroid = Some(centroid);
|
||||
|
||||
// Check formation cohesion — are units within formation radius?
|
||||
let mean_dist = mean_distance2_from(&my_positions, ¢roid, rows, cols);
|
||||
let rallying = mean_dist > FORMATION_RADIUS2;
|
||||
|
||||
// Compute enemy centroid for advance target
|
||||
let enemy_centroid = if !enemy_positions.is_empty() {
|
||||
Some(circular_mean(&enemy_positions, rows, cols))
|
||||
} else {
|
||||
// No enemies visible — advance toward map center
|
||||
Some(Position {
|
||||
row: rows / 2,
|
||||
col: cols / 2,
|
||||
})
|
||||
};
|
||||
|
||||
// Generate hexagonal formation slots around centroid
|
||||
let slots = generate_formation_slots(¢roid, my_positions.len(), rows, cols);
|
||||
|
||||
// Assign bots to slots (greedy nearest-neighbor matching)
|
||||
let assignments = assign_slots(&my_positions, &slots, rows, cols);
|
||||
|
||||
// Track claimed destinations to avoid self-collision
|
||||
let mut claimed: HashSet<Position> = HashSet::new();
|
||||
|
||||
let mut moves = Vec::with_capacity(my_bots.len());
|
||||
|
||||
for (_bot, bot_pos) in my_bots.iter().zip(my_positions.iter()) {
|
||||
let target_slot = assignments.get(bot_pos).copied();
|
||||
|
||||
let advance_target = if rallying {
|
||||
// Rally mode: move toward centroid, not enemy
|
||||
centroid
|
||||
} else {
|
||||
// Advance mode: move toward enemy concentration
|
||||
enemy_centroid.unwrap_or(centroid)
|
||||
};
|
||||
|
||||
if let Some(dir) = self.compute_bot_move(
|
||||
*bot_pos,
|
||||
&target_slot,
|
||||
&advance_target,
|
||||
¢roid,
|
||||
&enemy_set,
|
||||
&walls,
|
||||
&claimed,
|
||||
rallying,
|
||||
config,
|
||||
) {
|
||||
let dest = bot_pos.move_toward(dir, rows, cols);
|
||||
claimed.insert(dest);
|
||||
moves.push(Move {
|
||||
position: *bot_pos,
|
||||
direction: dir,
|
||||
});
|
||||
} else {
|
||||
// Hold position
|
||||
claimed.insert(*bot_pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Update enemy tracking
|
||||
self.last_enemy_positions = enemy_set;
|
||||
|
||||
moves
|
||||
}
|
||||
|
||||
fn compute_bot_move(
|
||||
&self,
|
||||
bot_pos: Position,
|
||||
slot: &Option<Position>,
|
||||
advance_target: &Position,
|
||||
centroid: &Position,
|
||||
enemies: &HashSet<Position>,
|
||||
walls: &HashSet<Position>,
|
||||
claimed: &HashSet<Position>,
|
||||
rallying: bool,
|
||||
config: &GameConfig,
|
||||
) -> Option<Direction> {
|
||||
let rows = config.rows as i32;
|
||||
let cols = config.cols as i32;
|
||||
|
||||
let mut best_dir: Option<Direction> = None;
|
||||
let mut best_score = f64::NEG_INFINITY;
|
||||
|
||||
for dir in Direction::all() {
|
||||
let dest = bot_pos.move_toward(dir, rows, cols);
|
||||
|
||||
// Hard constraints: can't move into walls or enemies
|
||||
if walls.contains(&dest) || enemies.contains(&dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid self-collision
|
||||
if claimed.contains(&dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut score = 0.0;
|
||||
|
||||
// Formation cohesion: move toward assigned slot
|
||||
if let Some(slot_pos) = slot {
|
||||
let dist_to_slot = dest.distance2(slot_pos, rows, cols) as f64;
|
||||
let current_dist_to_slot = bot_pos.distance2(slot_pos, rows, cols) as f64;
|
||||
score += (current_dist_to_slot - dist_to_slot) * FORMATION_WEIGHT;
|
||||
}
|
||||
|
||||
// Stay close to centroid
|
||||
let dist_to_centroid = dest.distance2(centroid, rows, cols) as f64;
|
||||
let current_dist_centroid = bot_pos.distance2(centroid, rows, cols) as f64;
|
||||
score += (current_dist_centroid - dist_to_centroid) * (FORMATION_WEIGHT * 0.3);
|
||||
|
||||
// Advance toward target (enemy or rally point)
|
||||
let dist_to_target = dest.distance2(advance_target, rows, cols) as f64;
|
||||
let current_dist_target = bot_pos.distance2(advance_target, rows, cols) as f64;
|
||||
|
||||
if rallying {
|
||||
// During rally, heavily weight closing distance to centroid
|
||||
score += (current_dist_target - dist_to_target) * ADVANCE_WEIGHT * 2.0;
|
||||
} else {
|
||||
score += (current_dist_target - dist_to_target) * ADVANCE_WEIGHT;
|
||||
}
|
||||
|
||||
// Bonus for being in attack range of enemies (only when not rallying)
|
||||
if !rallying {
|
||||
for enemy_pos in enemies.iter() {
|
||||
let dist = dest.distance2(enemy_pos, rows, cols);
|
||||
if dist <= config.attack_radius2 {
|
||||
score += ATTACK_RANGE_BONUS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_dir = Some(dir);
|
||||
}
|
||||
}
|
||||
|
||||
best_dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PhalanxStrategy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular mean for toroidal coordinates — mathematically correct
|
||||
/// center-of-mass on a wrapping grid.
|
||||
fn circular_mean(positions: &[Position], rows: i32, cols: i32) -> Position {
|
||||
if positions.is_empty() {
|
||||
return Position {
|
||||
row: rows / 2,
|
||||
col: cols / 2,
|
||||
};
|
||||
}
|
||||
|
||||
let row_scale = 2.0 * std::f64::consts::PI / rows as f64;
|
||||
let col_scale = 2.0 * std::f64::consts::PI / cols as f64;
|
||||
let n = positions.len() as f64;
|
||||
|
||||
let mut sum_sin_row = 0.0_f64;
|
||||
let mut sum_cos_row = 0.0_f64;
|
||||
let mut sum_sin_col = 0.0_f64;
|
||||
let mut sum_cos_col = 0.0_f64;
|
||||
|
||||
for pos in positions {
|
||||
sum_sin_row += (pos.row as f64 * row_scale).sin();
|
||||
sum_cos_row += (pos.row as f64 * row_scale).cos();
|
||||
sum_sin_col += (pos.col as f64 * col_scale).sin();
|
||||
sum_cos_col += (pos.col as f64 * col_scale).cos();
|
||||
}
|
||||
|
||||
let avg_row = (sum_sin_row / n).atan2(sum_cos_row / n) / row_scale;
|
||||
let avg_col = (sum_sin_col / n).atan2(sum_cos_col / n) / col_scale;
|
||||
|
||||
Position {
|
||||
row: ((avg_row % rows as f64 + rows as f64) % rows as f64).round() as i32,
|
||||
col: ((avg_col % cols as f64 + cols as f64) % cols as f64).round() as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth centroid by blending with previous value (70% new, 30% old).
|
||||
fn smooth_centroid(prev: &Position, current: &Position, rows: i32, cols: i32) -> Position {
|
||||
let (dr, dc) = prev.delta_to(current, rows, cols);
|
||||
let blend_dr = dr as f64 * 0.7;
|
||||
let blend_dc = dc as f64 * 0.7;
|
||||
Position {
|
||||
row: ((prev.row as f64 + blend_dr).round() as i32).rem_euclid(rows),
|
||||
col: ((prev.col as f64 + blend_dc).round() as i32).rem_euclid(cols),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean squared distance from a set of positions to a reference point.
|
||||
fn mean_distance2_from(positions: &[Position], center: &Position, rows: i32, cols: i32) -> u32 {
|
||||
if positions.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let total: u32 = positions
|
||||
.iter()
|
||||
.map(|p| p.distance2(center, rows, cols))
|
||||
.sum();
|
||||
total / positions.len() as u32
|
||||
}
|
||||
|
||||
/// Generate hexagonal packing formation slots around a centroid.
|
||||
/// Produces `count` positions in a tight hex pattern.
|
||||
fn generate_formation_slots(centroid: &Position, count: usize, rows: i32, cols: i32) -> Vec<Position> {
|
||||
if count == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut slots = vec![*centroid];
|
||||
|
||||
if count == 1 {
|
||||
return slots;
|
||||
}
|
||||
|
||||
// Hex ring expansion: generate slots in concentric hex rings
|
||||
// Ring 0: center (1 slot)
|
||||
// Ring 1: 6 slots at distance ~1.4
|
||||
// Ring 2: 12 slots at distance ~2.8
|
||||
// etc.
|
||||
let mut ring = 1;
|
||||
while slots.len() < count {
|
||||
let ring_slots = hex_ring(ring);
|
||||
for (dr, dc) in ring_slots {
|
||||
if slots.len() >= count {
|
||||
break;
|
||||
}
|
||||
let r = (centroid.row + dr).rem_euclid(rows);
|
||||
let c = (centroid.col + dc).rem_euclid(cols);
|
||||
slots.push(Position { row: r, col: c });
|
||||
}
|
||||
ring += 1;
|
||||
if ring > 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
slots
|
||||
}
|
||||
|
||||
/// Generate the 6*ring offsets for a hex ring at distance `ring`.
|
||||
/// Uses axial hex coordinates converted to offset coordinates.
|
||||
fn hex_ring(ring: i32) -> Vec<(i32, i32)> {
|
||||
if ring == 0 {
|
||||
return vec![(0, 0)];
|
||||
}
|
||||
|
||||
// Six hex directions as (dq, dr) in axial coordinates
|
||||
let hex_dirs: [(i32, i32); 6] = [
|
||||
(1, 0),
|
||||
(0, 1),
|
||||
(-1, 1),
|
||||
(-1, 0),
|
||||
(0, -1),
|
||||
(1, -1),
|
||||
];
|
||||
|
||||
// Convert axial to offset: offset_row = dr, offset_col = dq + dr/2
|
||||
let mut result = Vec::with_capacity(6 * ring as usize);
|
||||
|
||||
// Start at one corner of the ring
|
||||
let mut q = ring as i32;
|
||||
let mut r: i32 = 0;
|
||||
|
||||
for &(dq, dr) in &hex_dirs {
|
||||
for _ in 0..ring {
|
||||
let offset_row = r;
|
||||
let offset_col = q + r / 2;
|
||||
result.push((offset_row, offset_col));
|
||||
q += dq;
|
||||
r += dr;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Greedy nearest-neighbor assignment of bots to formation slots.
|
||||
fn assign_slots(
|
||||
bots: &[Position],
|
||||
slots: &[Position],
|
||||
rows: i32,
|
||||
cols: i32,
|
||||
) -> HashMap<Position, Position> {
|
||||
let mut assignments = HashMap::with_capacity(bots.len());
|
||||
let mut used_slots: HashSet<usize> = HashSet::new();
|
||||
|
||||
// Sort bots by distance to their nearest unused slot (greedy priority)
|
||||
let bot_indices: Vec<usize> = (0..bots.len()).collect();
|
||||
// Simple greedy: assign each bot to nearest unused slot
|
||||
for &bi in &bot_indices {
|
||||
let bot = bots[bi];
|
||||
let mut best_slot_idx = 0;
|
||||
let mut best_dist = u32::MAX;
|
||||
|
||||
for (si, slot) in slots.iter().enumerate() {
|
||||
if used_slots.contains(&si) {
|
||||
continue;
|
||||
}
|
||||
let d = bot.distance2(slot, rows, cols);
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best_slot_idx = si;
|
||||
}
|
||||
}
|
||||
|
||||
used_slots.insert(best_slot_idx);
|
||||
if best_slot_idx < slots.len() {
|
||||
assignments.insert(bot, slots[best_slot_idx]);
|
||||
}
|
||||
}
|
||||
|
||||
assignments
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_circular_mean_single() {
|
||||
let pos = Position { row: 10, col: 20 };
|
||||
let center = circular_mean(&[pos], 60, 60);
|
||||
assert_eq!(center.row, 10);
|
||||
assert_eq!(center.col, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_mean_wrapping() {
|
||||
// Two positions near the wrap boundary should average near the boundary
|
||||
let positions = vec![
|
||||
Position { row: 2, col: 30 },
|
||||
Position { row: 58, col: 30 },
|
||||
];
|
||||
let center = circular_mean(&positions, 60, 60);
|
||||
// Should be near row 0 (wrap), not row 30
|
||||
assert!(center.row <= 4 || center.row >= 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formation_slots_count() {
|
||||
let centroid = Position { row: 30, col: 30 };
|
||||
let slots = generate_formation_slots(¢roid, 10, 60, 60);
|
||||
assert_eq!(slots.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formation_slots_single() {
|
||||
let centroid = Position { row: 30, col: 30 };
|
||||
let slots = generate_formation_slots(¢roid, 1, 60, 60);
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0], centroid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mean_distance_empty() {
|
||||
let center = Position { row: 30, col: 30 };
|
||||
assert_eq!(mean_distance2_from(&[], ¢er, 60, 60), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mean_distance_coherent() {
|
||||
let center = Position { row: 30, col: 30 };
|
||||
let positions = vec![
|
||||
Position { row: 30, col: 30 },
|
||||
Position { row: 31, col: 30 },
|
||||
];
|
||||
let mean = mean_distance2_from(&positions, ¢er, 60, 60);
|
||||
assert!(mean < FORMATION_RADIUS2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mean_distance_broken() {
|
||||
let center = Position { row: 30, col: 30 };
|
||||
let positions = vec![
|
||||
Position { row: 30, col: 30 },
|
||||
Position { row: 40, col: 40 },
|
||||
];
|
||||
let mean = mean_distance2_from(&positions, ¢er, 60, 60);
|
||||
assert!(mean > FORMATION_RADIUS2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_ring_1() {
|
||||
let ring = hex_ring(1);
|
||||
assert_eq!(ring.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_ring_2() {
|
||||
let ring = hex_ring(2);
|
||||
assert_eq!(ring.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_centroid_stability() {
|
||||
let prev = Position { row: 30, col: 30 };
|
||||
let current = Position { row: 32, col: 31 };
|
||||
let smoothed = smooth_centroid(&prev, ¤t, 60, 60);
|
||||
// Smoothed should be between prev and current but closer to current
|
||||
assert!(smoothed.row > prev.row);
|
||||
assert!(smoothed.col > prev.col);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue