From 41d868b5c18ff0f15e536fdc6d3a9fa05cd44d7e Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 25 May 2026 14:14:27 -0400 Subject: [PATCH] feat(engine): add pre-generated map loading from map library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/acb-mapgen/main.go | 15 +++-- cmd/acb-mapgen/mapgen_test.go | 63 ++++++++++----------- cmd/acb-worker/main.go | 9 ++- engine/match.go | 103 +++++++++++++++++++++++++++++++--- 4 files changed, 143 insertions(+), 47 deletions(-) diff --git a/cmd/acb-mapgen/main.go b/cmd/acb-mapgen/main.go index 35176df..cde8154 100644 --- a/cmd/acb-mapgen/main.go +++ b/cmd/acb-mapgen/main.go @@ -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) diff --git a/cmd/acb-mapgen/mapgen_test.go b/cmd/acb-mapgen/mapgen_test.go index 25f4533..1e7439e 100644 --- a/cmd/acb-mapgen/mapgen_test.go +++ b/cmd/acb-mapgen/mapgen_test.go @@ -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) } } }) diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 841e3f9..beb3816 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -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 diff --git a/engine/match.go b/engine/match.go index 5715048..cdfca27 100644 --- a/engine/match.go +++ b/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