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:
parent
f664c93966
commit
41d868b5c1
4 changed files with 143 additions and 47 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
103
engine/match.go
103
engine/match.go
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue