ai-code-battle/engine/match.go
jedarden f5d7553f98 Add Phase 7-9 features: evolution dashboard, WASM sandbox, enhanced replay
Phase 7 Evolution:
- Add live-export subcommand to acb-evolver for dashboard JSON generation
- Export programs, stats, and generation log to live.json

Phase 8 Enhanced Features:
- Add WASM game engine build (cmd/acb-wasm/) with JS bindings
- Add in-browser sandbox page with Monaco editor (web/src/pages/sandbox.ts)
- Add win probability computation (web/src/win-probability.ts)
- Add replay commentary generator (web/src/commentary.ts)
- Add clip maker for GIF/MP4 export (web/src/pages/clip-maker.ts)
- Add rivalry detection and pages (web/src/pages/rivalries.ts)
- Add replay feedback system (web/src/pages/feedback.ts)
- Add evolution dashboard page (web/src/pages/evolution.ts)

Phase 9 Platform Depth:
- Add predictions API (cmd/acb-api/predictions.go)
- Add series management API (cmd/acb-api/series.go)
- Add seasons API (cmd/acb-api/seasons.go)
- Add narrative generator for rivalries (cmd/acb-indexer/src/narrative.ts)

Engine Updates:
- Add debug field to move response schema
- Add match event timeline extraction
- Add replay enrichment fields

Web Updates:
- Update app.html navigation for new pages
- Add API client methods for predictions, series, seasons
- Export engine types for browser use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 01:13:23 -04:00

367 lines
9.4 KiB
Go

package engine
import (
"fmt"
"log"
"math"
"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)
}
// DebugProvider is an optional interface bots may implement to expose debug telemetry.
type DebugProvider interface {
LastDebug() *DebugInfo
}
// 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, no debug yet)
replayWriter.RecordTurn(gs, nil)
// 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()
// Collect debug telemetry from bots that support it
var debug map[int]*DebugInfo
for i, bot := range mr.bots {
if dp, ok := bot.(DebugProvider); ok {
if d := dp.LastDebug(); d != nil {
if debug == nil {
debug = make(map[int]*DebugInfo)
}
debug[i] = d
}
}
}
// Record turn state with debug
replayWriter.RecordTurn(gs, debug)
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 * math.Pi / 3.0
row := centerRow + int(float64(centerRow/2)*0.8*(1.0+0.5*math.Cos(angle)))
col := centerCol + int(float64(centerCol/2)*0.8*(1.0+0.5*math.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 * math.Pi / float64(numPlayers)
row := centerRow + int(float64(centerRow/2)*0.7*math.Cos(angle))
col := centerCol + int(float64(centerCol/2)*0.7*math.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 * math.Pi / 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*math.Pi/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*math.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 * math.Pi / float64(numPlayers)
radius := 0.1 + mr.rng.Float64()*0.8 // 10-90% of half-size
row := centerRow + int(float64(centerRow)*radius*math.Cos(angle))
col := centerCol + int(float64(centerCol)*radius*math.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*math.Pi/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*math.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
}