ai-code-battle/engine/match.go
jedarden 6d3f3506b3 Implement Phase 1 core engine: grid, combat, fog of war, turn execution
- Add engine package with toroidal grid, game state, turn execution
- Implement focus-fire combat resolution with simultaneous deaths
- Add fog of war visibility filtering for bot state
- Implement energy collection (contested resources denied)
- Add bot spawning at active cores
- Implement win conditions: elimination, draw, dominance, turns
- Add replay JSON writer for match recording
- Add match runner with concurrent bot communication
- Add CLI tools: acb-local (match runner), acb-mapgen (map generator)
- Add comprehensive unit tests (26 tests passing)

Exit criteria met: can run complete 500-turn matches and produce valid replays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 01:48:27 -04:00

358 lines
9.1 KiB
Go

package engine
import (
"fmt"
"log"
"math/rand"
"sync"
"time"
)
// MatchRunner orchestrates a match between multiple bots.
type MatchRunner struct {
config Config
bots []BotInterface
names []string
rng *rand.Rand
verbose bool
logger *log.Logger
timeout time.Duration // per-turn timeout
}
// MatchOption is a functional option for MatchRunner.
type MatchOption func(*MatchRunner)
// WithVerbose enables verbose logging.
func WithVerbose(v bool) MatchOption {
return func(mr *MatchRunner) {
mr.verbose = v
}
}
// WithLogger sets a custom logger.
func WithLogger(l *log.Logger) MatchOption {
return func(mr *MatchRunner) {
mr.logger = l
}
}
// WithTimeout sets the per-turn timeout.
func WithTimeout(d time.Duration) MatchOption {
return func(mr *MatchRunner) {
mr.timeout = d
}
}
// WithRNG sets the random number generator.
func WithRNG(rng *rand.Rand) MatchOption {
return func(mr *MatchRunner) {
mr.rng = rng
}
}
// NewMatchRunner creates a new match runner.
func NewMatchRunner(config Config, options ...MatchOption) *MatchRunner {
mr := &MatchRunner{
config: config,
bots: make([]BotInterface, 0),
names: make([]string, 0),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
verbose: false,
logger: log.Default(),
timeout: 3 * time.Second,
}
for _, opt := range options {
opt(mr)
}
return mr
}
// AddBot adds a bot to the match.
func (mr *MatchRunner) AddBot(bot BotInterface, name string) {
mr.bots = append(mr.bots, bot)
mr.names = append(mr.names, name)
}
// Run executes the match and returns the result and replay.
func (mr *MatchRunner) Run() (*MatchResult, *Replay, error) {
if len(mr.bots) < 2 {
return nil, nil, fmt.Errorf("need at least 2 bots, got %d", len(mr.bots))
}
// Initialize game state
gs := NewGameState(mr.config, mr.rng)
// Add players
for range mr.bots {
gs.AddPlayer()
}
// Set up replay writer
replayWriter := NewReplayWriter(gs.MatchID, mr.config)
// Record players
replayPlayers := make([]ReplayPlayer, len(mr.bots))
for i, name := range mr.names {
replayPlayers[i] = ReplayPlayer{ID: i, Name: name}
}
replayWriter.SetPlayers(replayPlayers)
// Generate a simple symmetric map for 2 players
mr.generateMap(gs, len(mr.bots))
// Record initial map state
replayWriter.SetMap(gs)
// Record turn 0 (initial state)
replayWriter.RecordTurn(gs)
// Run the match
var result *MatchResult
for gs.Turn < mr.config.MaxTurns {
// Get moves from all bots concurrently
moves := mr.getMovesFromBots(gs)
// Submit moves to game state
gs.ClearTurnState()
for playerID, playerMoves := range moves {
for _, move := range playerMoves {
// Validate bot ownership
bot := mr.findBotAtPosition(gs, move.Position, playerID)
if bot != nil && bot.Alive {
gs.SubmitMove(move.Position, move.Direction)
}
}
}
// Execute the turn
result = gs.ExecuteTurn()
// Record turn state
replayWriter.RecordTurn(gs)
if mr.verbose {
mr.logger.Printf("Turn %d: %d living bots", gs.Turn, gs.GetLivingBotCount())
}
if result != nil {
break
}
}
// Finalize replay
replayWriter.Finalize(result)
return result, replayWriter.GetReplay(), nil
}
// getMovesFromBots gets moves from all bots concurrently.
func (mr *MatchRunner) getMovesFromBots(gs *GameState) map[int][]Move {
moves := make(map[int][]Move)
var mu sync.Mutex
var wg sync.WaitGroup
for playerID, bot := range mr.bots {
wg.Add(1)
go func(pid int, b BotInterface) {
defer wg.Done()
// Get visible state for this player
visibleState := gs.GetVisibleState(pid)
// Get moves with timeout
moveChan := make(chan []Move, 1)
errChan := make(chan error, 1)
go func() {
m, err := b.GetMoves(visibleState)
if err != nil {
errChan <- err
return
}
moveChan <- m
}()
select {
case m := <-moveChan:
mu.Lock()
moves[pid] = m
mu.Unlock()
case <-errChan:
// Bot returned error, no moves
if mr.verbose {
mr.logger.Printf("Bot %d returned error", pid)
}
case <-time.After(mr.timeout):
// Timeout, no moves
if mr.verbose {
mr.logger.Printf("Bot %d timed out", pid)
}
}
}(playerID, bot)
}
wg.Wait()
return moves
}
// findBotAtPosition finds a bot at a position owned by a player.
func (mr *MatchRunner) findBotAtPosition(gs *GameState, pos Position, playerID int) *Bot {
for _, b := range gs.Bots {
if b.Alive && b.Position == pos && b.Owner == playerID {
return b
}
}
return nil
}
// generateMap generates a symmetric map for the given number of players.
func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
// For 2 players: 180° rotational symmetry
// Place cores at opposite corners
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
switch numPlayers {
case 2:
// Place cores at opposite positions (rotational symmetry through center)
core0 := Position{Row: centerRow / 2, Col: centerCol / 2}
core1 := Position{Row: centerRow + centerRow/2, Col: centerCol + centerCol/2}
gs.AddCore(0, core0)
gs.AddCore(1, core1)
// Place bots at cores
gs.SpawnBot(0, core0)
gs.SpawnBot(1, core1)
case 3:
// 120° rotational symmetry (equilateral triangle)
// Simplified: place at roughly equal angles
for i := 0; i < 3; i++ {
angle := float64(i) * 2.0 * 3.14159 / 3.0
row := centerRow + int(float64(centerRow/2)*0.8*(1.0+0.5*cos(angle)))
col := centerCol + int(float64(centerCol/2)*0.8*(1.0+0.5*sin(angle)))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
}
case 4:
// 90° rotational symmetry (four corners of a square)
offset := centerRow / 2
positions := []Position{
{Row: centerRow - offset, Col: centerCol - offset},
{Row: centerRow - offset, Col: centerCol + offset},
{Row: centerRow + offset, Col: centerCol + offset},
{Row: centerRow + offset, Col: centerCol - offset},
}
for i, pos := range positions {
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
}
default:
// Fallback: place cores in a circle
for i := 0; i < numPlayers; i++ {
angle := float64(i) * 2.0 * 3.14159 / float64(numPlayers)
row := centerRow + int(float64(centerRow/2)*0.7*cos(angle))
col := centerCol + int(float64(centerCol/2)*0.7*sin(angle))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
}
}
// Place energy nodes symmetrically
mr.placeEnergyNodes(gs, numPlayers)
// Place walls symmetrically
mr.placeWalls(gs, numPlayers)
}
// placeEnergyNodes places energy nodes symmetrically.
func (mr *MatchRunner) placeEnergyNodes(gs *GameState, numPlayers int) {
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
// Place energy nodes in a ring around the center
numNodes := mr.config.EnergyInterval * 2 // e.g., 20 nodes for interval 10
nodesPerSector := numNodes / numPlayers
for i := 0; i < nodesPerSector; i++ {
// Generate one position in the first sector
angle := mr.rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
radius := 0.3 + mr.rng.Float64()*0.4 // 30-70% of half-size
// Mirror for all players
for p := 0; p < numPlayers; p++ {
rotAngle := angle + float64(p)*2.0*3.14159/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
gs.AddEnergyNode(Position{Row: r, Col: c})
}
}
}
// placeWalls places walls symmetrically.
func (mr *MatchRunner) placeWalls(gs *GameState, numPlayers int) {
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
// Calculate target number of walls
totalTiles := gs.Config.Rows * gs.Config.Cols
targetWalls := int(float64(totalTiles) * 0.15) // 15% wall density
wallsPerSector := targetWalls / numPlayers
for i := 0; i < wallsPerSector; i++ {
// Generate one position in the first sector
angle := mr.rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
radius := 0.1 + mr.rng.Float64()*0.8 // 10-90% of half-size
row := centerRow + int(float64(centerRow)*radius*cos(angle))
col := centerCol + int(float64(centerCol)*radius*sin(angle))
// Check it's not on a core or energy node
pos := Position{Row: row, Col: col}
if mr.isValidWallPosition(gs, pos) {
// Mirror for all players
for p := 0; p < numPlayers; p++ {
rotAngle := angle + float64(p)*2.0*3.14159/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
mirrorPos := Position{Row: r, Col: c}
if mr.isValidWallPosition(gs, mirrorPos) {
gs.Grid.SetPos(mirrorPos, TileWall)
}
}
}
}
}
// isValidWallPosition checks if a position can have a wall.
func (mr *MatchRunner) isValidWallPosition(gs *GameState, pos Position) bool {
// Check for core
for _, c := range gs.Cores {
if c.Position == pos {
return false
}
}
// Check for energy node
for _, en := range gs.Energy {
if en.Position == pos {
return false
}
}
return true
}
// cos and sin helpers (avoid importing math for simple cases)
func cos(x float64) float64 {
// Simple approximation using Taylor series
return 1 - x*x/2 + x*x*x*x/24 - x*x*x*x*x*x/720
}
func sin(x float64) float64 {
// Simple approximation using Taylor series
return x - x*x*x/6 + x*x*x*x*x/120
}