ai-code-battle/engine/match.go
jedarden c56cc8bae6 fix(matchmaker): multi-match crash cooldown (3 strikes / 30 min) per §4.5 + §6.1
Add crash_strikes and cooldown_until columns to bots table. Worker
increments strikes on crash (cooldown at 3), resets on success.
Matchmaker excludes cooldown bots from pairing, series scheduling,
and championship brackets. Fix erroneous cooldown filter on series
table in finalizeCompletedSeries (column only exists on bots).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:22:12 -04:00

382 lines
9.8 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)
// Collect state snapshots for win probability computation
snapshots := make([]*GameState, 0, mr.config.MaxTurns+1)
snapshots = append(snapshots, gs.Clone())
// 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)
// Collect state snapshot for win probability
snapshots = append(snapshots, gs.Clone())
if mr.verbose {
mr.logger.Printf("Turn %d: %d living bots", gs.Turn, gs.GetLivingBotCount())
}
if result != nil {
break
}
}
// Compute win probability via Monte Carlo rollout
winProbs, criticalMoments := ComputeWinProbability(snapshots, 100, mr.rng)
replayWriter.SetWinProbability(winProbs, criticalMoments)
// Populate crash status per player
result.Crashed = make([]bool, len(mr.bots))
for i, bot := range mr.bots {
if hb, ok := bot.(*HTTPBot); ok {
result.Crashed[i] = hb.IsCrashed()
}
}
// 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) {
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
coresPerPlayer := gs.Config.CoresPerPlayer
if coresPerPlayer < 1 {
coresPerPlayer = 1
}
// Place cores for each player using rotational symmetry.
// Primary core at radius ~70% from center, additional cores at ~40% radius
// offset angularly from the primary.
primaryRadius := 0.7
secondaryRadius := 0.4
halfRows := float64(centerRow)
halfCols := float64(centerCol)
for i := 0; i < numPlayers; i++ {
baseAngle := float64(i) * 2.0 * math.Pi / float64(numPlayers)
for c := 0; c < coresPerPlayer; c++ {
var row, col int
if c == 0 {
// Primary core: far from center
row = centerRow + int(halfRows*primaryRadius*math.Cos(baseAngle))
col = centerCol + int(halfCols*primaryRadius*math.Sin(baseAngle))
} else {
// Additional cores: closer to center, offset angularly
angleOffset := (float64(c) * 0.3) / float64(numPlayers)
angle := baseAngle + angleOffset
row = centerRow + int(halfRows*secondaryRadius*math.Cos(angle))
col = centerCol + int(halfCols*secondaryRadius*math.Sin(angle))
}
// Wrap to grid bounds
row = ((row % gs.Config.Rows) + gs.Config.Rows) % gs.Config.Rows
col = ((col % gs.Config.Cols) + gs.Config.Cols) % gs.Config.Cols
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
// Spawn initial bot only at the primary core
if c == 0 {
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
// Scale energy nodes with map area: ~1 node per 150 tiles, minimum 4 per player
totalArea := gs.Config.Rows * gs.Config.Cols
numNodes := totalArea / 150
minNodes := numPlayers * 4
if numNodes < minNodes {
numNodes = minNodes
}
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: 5% density (20 passable : 1 wall)
totalTiles := gs.Config.Rows * gs.Config.Cols
targetWalls := totalTiles / 20
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
}