feat(engine): add pre-generated map loading from map library

Per plan §3.8, maps should be generated offline and stored in the map
library, not generated on-the-fly during matches. This commit adds
support for loading pre-generated maps from the database.

Changes:
- Add PreGeneratedMap type and WithMap option to MatchRunner
- Add loadPreGeneratedMap() to parse map JSON (walls, cores)
- Update worker to pass loaded map data to MatchRunner via WithMap
- Fallback to on-the-fly generation if map data is invalid
- Update acb-mapgen spawn radius to 25% for 2-player (aligns with match.go)
- Update test to verify cores are outside final zone radius

This enables the map library infrastructure (maps/, acb-mapgen, index
builder) to be used in production matches instead of being ignored.

Closes: bf-5m29

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-25 14:14:27 -04:00
parent f664c93966
commit 41d868b5c1
4 changed files with 143 additions and 47 deletions

View file

@ -163,14 +163,19 @@ func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes
}
// Generate cores with rotational symmetry.
// Per plan §3.7.1: zone forces combat, but spawn must put bots within attack range.
// For 2 players: within attack radius (6 tiles) so idle bots fight immediately
// For 3+ players: within attack radius (3.5 tiles) for same reason
// Per plan §3.7.1: zone forces combat, bots start outside final zone.
// Spawn radius must put bots outside the zone minimum so the zone forcing
// function works as designed: bots start apart, zone shrinks, combat occurs.
//
// For 2 players: 25% spawn radius (10 tiles from center, 20 tiles apart on 40x40)
// - Outside attack radius (5 tiles), so bots don't kill each other immediately
// - Zone shrinks from radius 20 to 2 over time, forcing bots into contact
// For 3+ players: 10% spawn radius (5 tiles from center, ~10 tiles apart on 50x50)
var radius float64
if numPlayers == 2 {
radius = 0.15 // 6 tiles apart = exactly attack radius (6)
radius = 0.25 // 10 tiles from center, 20 tiles apart on 40x40 (outside attack radius of 5)
} else {
radius = 0.063 // ~3.4 tiles apart on toroidal grid (within attack radius of 3.46)
radius = 0.10 // ~5 tiles from center on 50x50 grid
}
for p := 0; p < numPlayers; p++ {
angle := float64(p) * 2.0 * math.Pi / float64(numPlayers)

View file

@ -225,26 +225,36 @@ func TestGenerateMap_CenterWeightedEnergy(t *testing.T) {
}
}
func TestGenerateMap_CoresWithinAttackRadius(t *testing.T) {
// Per plan §3.7.1: spawn must put bots within attack range.
// For 2 players: within attack radius (6 tiles)
// For 3+ players: within attack radius (3.5 tiles)
// Uses standard 40x40 map size where spawn radii are calibrated.
func TestGenerateMap_CoresOutsideAttackRadius(t *testing.T) {
// Per plan §3.7.1: spawn must put bots outside final zone (min radius 1 for 3+, 2 for 2-player).
// The zone forcing function works by shrinking the zone over time.
// For 2 players: 25% spawn radius (10 tiles from center, 20 tiles apart on 40x40)
// - Well outside attack radius (5 tiles)
// - Zone shrinks from radius 20 to 2, forcing bots into contact over time
// For 3+ players: 10% spawn radius (5 tiles from center on 50x50)
// - Some cores may be within attack radius (3.5 tiles) due to angular spacing
// - Zone shrinks to radius 1, forcing all bots into contact
testCases := []struct {
numPlayers int
attackRadius float64
expectedRadius float64
numPlayers int
attackRadius float64
expectedRadius float64
minDistFromCenter float64 // minimum distance from center (should be > zone min radius)
}{
{2, 6.0, 0.15}, // 2-player: 6 tile attack radius, 0.15 spawn radius
{3, 3.5, 0.063}, // 3+ player: 3.5 tile attack radius, 0.063 spawn radius
{4, 3.5, 0.063},
{6, 3.5, 0.063},
{2, 5.0, 0.25, 4.0}, // 2-player: 5 tile attack radius, 0.25 spawn radius = 5 tiles from center
{3, 3.5, 0.10, 2.0}, // 3+ player: 3.5 tile attack radius, 0.10 spawn radius = 2.5 tiles from center
{4, 3.5, 0.10, 2.0},
{6, 3.5, 0.10, 2.0},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%dplayers", tc.numPlayers), func(t *testing.T) {
rng := rand.New(rand.NewSource(42))
m := EnsureConnectivity(tc.numPlayers, 40, 40, 0.15, 20, rng, 100)
// Use appropriate grid size for player count (matches ConfigForPlayers sizing)
rows, cols := 40, 40
if tc.numPlayers >= 3 {
rows, cols = 50, 50 // Larger grid for 3+ players
}
m := EnsureConnectivity(tc.numPlayers, rows, cols, 0.15, 20, rng, 100)
if m == nil {
t.Fatalf("failed to generate map for %d players", tc.numPlayers)
}
@ -266,28 +276,13 @@ func TestGenerateMap_CoresWithinAttackRadius(t *testing.T) {
t.Errorf("core at %v: distance %.2f from center, expected %.2f (tolerance %.2f)",
c.Position, dist, expectedDist, tolerance)
}
}
// Verify cores are within attack radius of each other (toroidal distance)
for i := 0; i < len(m.Cores); i++ {
for j := i + 1; j < len(m.Cores); j++ {
c1 := m.Cores[i].Position
c2 := m.Cores[j].Position
// Toroidal distance
dr := float64(c2.Row - c1.Row)
dc := float64(c2.Col - c1.Col)
// Find shortest distance on torus
height, width := float64(m.Rows), float64(m.Cols)
dr = math.Min(math.Abs(dr), height-math.Abs(dr))
dc = math.Min(math.Abs(dc), width-math.Abs(dc))
dist := math.Sqrt(dr*dr + dc*dc)
if dist > tc.attackRadius+1.0 { // +1 tolerance for rounding
t.Errorf("cores %d and %d are %.2f apart, exceeding attack radius %.2f",
i, j, dist, tc.attackRadius)
}
// Verify cores are outside the final zone radius
// Final zone is radius 1 for 3+, radius 2 for 2-player
// This ensures bots start outside the final zone and are forced inward
if dist < tc.minDistFromCenter {
t.Errorf("core at %v: distance %.2f from center, expected at least %.2f (outside final zone)",
c.Position, dist, tc.minDistFromCenter)
}
}
})

View file

@ -307,11 +307,18 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma
config.SeasonID = claimData.Match.SeasonID
config.RulesVersion = claimData.Match.RulesVersion
// Create match runner
// Prepare pre-generated map data for the match runner
preGenMap := engine.PreGeneratedMap{
WallsJSON: claimData.Map.Walls,
CoresJSON: claimData.Map.Cores,
}
// Create match runner with pre-generated map
runner := engine.NewMatchRunner(config,
engine.WithRNG(w.rng),
engine.WithVerbose(w.cfg.Verbose),
engine.WithTimeout(w.cfg.TurnTimeout),
engine.WithMap(preGenMap),
)
// Build bot ID to info lookup

View file

@ -1,6 +1,7 @@
package engine
import (
"encoding/json"
"fmt"
"log"
"math"
@ -11,13 +12,20 @@ import (
// 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
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.
@ -51,6 +59,14 @@ func WithRNG(rng *rand.Rand) MatchOption {
}
}
// 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{
@ -245,8 +261,81 @@ func (mr *MatchRunner) findBotAtPosition(gs *GameState, pos Position, playerID i
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