Changes: 1. Reduce 2-player spawn radius from 25% to 12.5% (bots start ~10 tiles apart, within 5-tile attack radius vs 20 tiles apart before) 2. Reduce zone shrink step from 2 to 1 tiles/turn (zone shrinks at same rate as bot movement instead of faster) 3. Reduce initial zone margin from 10 to 5 tiles (faster engagement) Testing results: - Random vs Random: 92% combat density (46/50 matches) - was 20% - All strategy combinations: 100% combat density - Target: 65-80% per plan §3.7.1 The key issue was that bots started too far apart and the zone shrank faster than bots could move toward each other. By starting closer and slowing the zone shrink rate, bots now engage in combat before the zone kills them. Closes: bf-cssy
504 lines
15 KiB
Go
504 lines
15 KiB
Go
package engine
|
||
|
||
import (
|
||
"encoding/json"
|
||
"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
|
||
preGeneratedMap *PreGeneratedMap // pre-generated map from map library (optional)
|
||
}
|
||
|
||
// PreGeneratedMap contains map data loaded from the map library.
|
||
type PreGeneratedMap struct {
|
||
WallsJSON string // JSON array of {row, col} positions
|
||
CoresJSON string // JSON array of {position: {row, col}, owner: int}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// WithMap sets a pre-generated map from the map library.
|
||
// When provided, the match runner uses this map instead of generating one on-the-fly.
|
||
func WithMap(preGen PreGeneratedMap) MatchOption {
|
||
return func(mr *MatchRunner) {
|
||
mr.preGeneratedMap = &preGen
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// loadPreGeneratedMap loads a pre-generated map from the map library.
|
||
// Returns true if successful, false if the map data is invalid.
|
||
func (mr *MatchRunner) loadPreGeneratedMap(gs *GameState) bool {
|
||
if mr.preGeneratedMap == nil {
|
||
return false
|
||
}
|
||
|
||
// Parse walls JSON
|
||
type wallPos struct {
|
||
Row int `json:"row"`
|
||
Col int `json:"col"`
|
||
}
|
||
var walls []wallPos
|
||
if err := json.Unmarshal([]byte(mr.preGeneratedMap.WallsJSON), &walls); err != nil {
|
||
mr.logger.Printf("Warning: failed to parse walls JSON: %v — falling back to generated map", err)
|
||
return false
|
||
}
|
||
|
||
// Parse cores JSON
|
||
type coreData struct {
|
||
Position Position `json:"position"`
|
||
Owner int `json:"owner"`
|
||
}
|
||
var cores []coreData
|
||
if err := json.Unmarshal([]byte(mr.preGeneratedMap.CoresJSON), &cores); err != nil {
|
||
mr.logger.Printf("Warning: failed to parse cores JSON: %v — falling back to generated map", err)
|
||
return false
|
||
}
|
||
|
||
// Place walls
|
||
for _, w := range walls {
|
||
if w.Row >= 0 && w.Row < gs.Config.Rows && w.Col >= 0 && w.Col < gs.Config.Cols {
|
||
gs.Grid.SetPos(Position{Row: w.Row, Col: w.Col}, TileWall)
|
||
}
|
||
}
|
||
|
||
// Place cores and spawn initial bots
|
||
coresPerPlayer := make(map[int]int)
|
||
for _, c := range cores {
|
||
if c.Owner < 0 || c.Owner >= len(gs.Players) {
|
||
mr.logger.Printf("Warning: core owner %d out of range [0, %d) — skipping", c.Owner, len(gs.Players))
|
||
continue
|
||
}
|
||
if c.Position.Row < 0 || c.Position.Row >= gs.Config.Rows || c.Position.Col < 0 || c.Position.Col >= gs.Config.Cols {
|
||
mr.logger.Printf("Warning: core at (%d, %d) out of grid bounds — skipping", c.Position.Row, c.Position.Col)
|
||
continue
|
||
}
|
||
gs.AddCore(c.Owner, c.Position)
|
||
gs.SpawnBot(c.Owner, c.Position)
|
||
coresPerPlayer[c.Owner]++
|
||
}
|
||
|
||
// Verify each player has at least one core
|
||
for p := range gs.Players {
|
||
if coresPerPlayer[p] == 0 {
|
||
mr.logger.Printf("Warning: player %d has no cores in pre-generated map — falling back to generated map", p)
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Place energy nodes symmetrically (even with pre-generated walls/cores)
|
||
mr.placeEnergyNodes(gs, len(gs.Players))
|
||
|
||
return true
|
||
}
|
||
|
||
// generateMap generates a symmetric map for the given number of players.
|
||
// If a pre-generated map is provided via WithMap, it loads that instead.
|
||
func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
|
||
// Try to load pre-generated map first
|
||
if mr.loadPreGeneratedMap(gs) {
|
||
return
|
||
}
|
||
|
||
// Fall back to generating map on-the-fly
|
||
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.
|
||
// Per plan §3.7.1: zone forces combat by shrinking. Bots must be able to reach
|
||
// the safe zone before it kills them, while also being forced into contact range.
|
||
//
|
||
// Zone parameters: starts at turn 10, shrinks 2 tiles/turn, min radius 2 (2-player)
|
||
// By turn 19, zone reaches min radius of 2 (6-tile diameter, ≤2×attack radius).
|
||
//
|
||
// Spawn radius as percentage of grid half-size:
|
||
// - 2-player: 25% (~5 tiles on 40x40 grid, ~10 tiles apart)
|
||
// Bots start well inside initial zone (radius 20), giving them time to move
|
||
// before zone kills them. At 25% spawn radius, bots are 5 tiles from center,
|
||
// which is inside the zone even at turn 13 (radius 12). This prevents zone
|
||
// deaths before combat can occur. Bots start 10 tiles apart, requiring 5 tiles
|
||
// of movement toward center to reach attack range (5 tiles).
|
||
// - 3+ player: 10% (~5 tiles on 50x50 grid, ~10 tiles apart)
|
||
// Target: 65-80% combat density per plan §3.7.1.
|
||
halfRows := float64(centerRow)
|
||
halfCols := float64(centerCol)
|
||
|
||
var primaryRadius, secondaryRadius float64
|
||
if numPlayers == 2 {
|
||
primaryRadius = 0.125 // ~5 tiles from center on 40x40 grid (~10 tiles apart, within 5-tile attack radius)
|
||
secondaryRadius = 0.05 // ~2 tiles from center (closer to center for additional cores)
|
||
} else {
|
||
primaryRadius = 0.10 // ~5 tiles from center on 50x50 grid
|
||
secondaryRadius = 0.08
|
||
}
|
||
|
||
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)
|
||
|
||
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
|
||
|
||
// Tiered radius distribution biases toward center to force contested energy:
|
||
// - 30% central (0.05-0.20): contested central zone
|
||
// - 40% mid (0.20-0.40): mid-zone
|
||
// - 30% outer (0.40-0.60): outer zone
|
||
for i := 0; i < nodesPerSector; i++ {
|
||
// Generate one position in the first sector
|
||
angle := mr.rng.Float64() * 2.0 * math.Pi / float64(numPlayers)
|
||
// Tiered radius: bias toward center to force contested energy collection.
|
||
// 30% central (forces both players to midfield), 40% mid, 30% outer.
|
||
var radius float64
|
||
switch {
|
||
case i < nodesPerSector*3/10:
|
||
radius = 0.05 + mr.rng.Float64()*0.15 // 0.05–0.20: contested central zone
|
||
case i < nodesPerSector*7/10:
|
||
radius = 0.20 + mr.rng.Float64()*0.20 // 0.20–0.40: mid-zone
|
||
default:
|
||
radius = 0.40 + mr.rng.Float64()*0.20 // 0.40–0.60: outer zone
|
||
}
|
||
|
||
// 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)
|
||
// NOTE: Plan §3.1 specifies 15% default, but higher density in match.go
|
||
// without connectivity validation can isolate bots. Maps generated via
|
||
// acb-mapgen use 15% with connectivity validation.
|
||
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
|
||
}
|