From 42398eb34a1cdf8d6b4eb35254ad868c43fdc2de Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 17 Jun 2026 02:58:45 -0400 Subject: [PATCH] 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 --- cmd/acb-evolver/internal/db/programs_test.go | 52 ++ cmd/acb-evolver/internal/db/seed.go | 37 +- .../db/seeds/assassin_strategy.rs.txt | 206 +++++++ .../db/seeds/opportunist_strategy.go.txt | 516 ++++++++++++++++++ .../internal/db/seeds/phalanx_strategy.rs.txt | 476 ++++++++++++++++ 5 files changed, 1285 insertions(+), 2 deletions(-) create mode 100644 cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt create mode 100644 cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt create mode 100644 cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt diff --git a/cmd/acb-evolver/internal/db/programs_test.go b/cmd/acb-evolver/internal/db/programs_test.go index bf7b9f1..e4e280a 100644 --- a/cmd/acb-evolver/internal/db/programs_test.go +++ b/cmd/acb-evolver/internal/db/programs_test.go @@ -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) + } +} diff --git a/cmd/acb-evolver/internal/db/seed.go b/cmd/acb-evolver/internal/db/seed.go index eeb6def..241d1b9 100644 --- a/cmd/acb-evolver/internal/db/seed.go +++ b/cmd/acb-evolver/internal/db/seed.go @@ -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) { diff --git a/cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt b/cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt new file mode 100644 index 0000000..b767f2f --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/assassin_strategy.rs.txt @@ -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, +} + +impl AssassinStrategy { + pub fn new() -> Self { + Self { + known_targets: HashMap::new(), + } + } + + pub fn compute_moves(&mut self, state: &GameState) -> Vec { + 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 = 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 = 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 = 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 { + &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, + claimed: &HashSet, + rows: i32, + cols: i32, + ) -> Option { + if start == goal { + return None; + } + + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque<(Position, Option)> = 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, + config: &GameConfig, + ) -> Vec { + 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, + } +} diff --git a/cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt b/cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt new file mode 100644 index 0000000..e1807c0 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/opportunist_strategy.go.txt @@ -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 +} diff --git a/cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt b/cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt new file mode 100644 index 0000000..016f3e4 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/phalanx_strategy.rs.txt @@ -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, + /// Known enemy positions from last turn (for tracking movement) + last_enemy_positions: HashSet, +} + +impl PhalanxStrategy { + pub fn new() -> Self { + Self { + centroid: None, + last_enemy_positions: HashSet::new(), + } + } + + pub fn compute_moves(&mut self, state: &GameState) -> Vec { + 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 = my_bots.iter().map(|b| b.position).collect(); + let enemy_positions: Vec = enemy_bots.iter().map(|b| b.position).collect(); + + // Build wall and enemy lookups + let walls: HashSet = state.walls.iter().copied().collect(); + let enemy_set: HashSet = 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 = 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, + advance_target: &Position, + centroid: &Position, + enemies: &HashSet, + walls: &HashSet, + claimed: &HashSet, + rallying: bool, + config: &GameConfig, + ) -> Option { + let rows = config.rows as i32; + let cols = config.cols as i32; + + let mut best_dir: Option = 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 { + 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 { + let mut assignments = HashMap::with_capacity(bots.len()); + let mut used_slots: HashSet = HashSet::new(); + + // Sort bots by distance to their nearest unused slot (greedy priority) + let bot_indices: Vec = (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); + } +}