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>
367 lines
9.4 KiB
Go
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
|
|
}
|
|
|