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:
jedarden 2026-06-17 02:58:45 -04:00
parent dce34f97b6
commit 42398eb34a
5 changed files with 1285 additions and 2 deletions

View file

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

View file

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

View 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,
}
}

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

View 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, &centroid, rows, cols)
} else {
centroid
};
self.centroid = Some(centroid);
// Check formation cohesion — are units within formation radius?
let mean_dist = mean_distance2_from(&my_positions, &centroid, 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(&centroid, 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,
&centroid,
&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(&centroid, 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(&centroid, 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(&[], &center, 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, &center, 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, &center, 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, &current, 60, 60);
// Smoothed should be between prev and current but closer to current
assert!(smoothed.row > prev.row);
assert!(smoothed.col > prev.col);
}
}