From 2dbfea5163c18b076e13a723667a1ecc6d8cb8fa Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 25 May 2026 09:14:48 -0400 Subject: [PATCH] fix(engine): increase spawn radius to force zone combat Root cause of zero combat deaths: bots spawned inside the final zone. On 40x40 grid, bots spawned at ~5 tiles from center but zone min radius was 3 tiles. Zone only pushed bots 2 tiles toward center - not enough to force them within attack range (6 tiles). Fix: Calculate spawn radius as absolute tile distance from center, then convert to percentage of grid half-size: - 2-player: spawn at 10 tiles from center (was ~5 tiles) - 3+ player: spawn at 8 tiles from center (was ~6 tiles) When zone shrinks to minimum (radius 3 for 2p, 1 for 3+), bots are forced within attack range of each other, triggering focus-fire combat. Test: Unit tests verify spawn distance > zone_min_radius for all player counts. Manual test shows combat_death events now occur. Closes: bf-52mn Co-Authored-By: Claude Opus 4.7 --- engine/match.go | 35 +++++++---- engine/match_test.go | 146 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 engine/match_test.go diff --git a/engine/match.go b/engine/match.go index bfc202f..ff756b4 100644 --- a/engine/match.go +++ b/engine/match.go @@ -255,19 +255,32 @@ func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) { } // Place cores for each player using 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 - var primaryRadius, secondaryRadius float64 - if numPlayers == 2 { - primaryRadius = 0.15 // 6 tiles apart = exactly attack radius (6) - secondaryRadius = 0.12 - } else { - primaryRadius = 0.063 // ~3.4 tiles apart on toroidal grid (within attack radius of 3.46) - secondaryRadius = 0.05 - } + // Per plan §3.7.1: zone forces combat by shrinking. Bots must start OUTSIDE the final + // safe zone so they are forced inward as the zone contracts, creating contact pressure. + // + // Zone min radius: 3 for 2-player (6 tiles diameter), 1 for 3+ (2 tiles diameter) + // Spawn radius must be > zone_min_radius to ensure bots start outside final zone. + // + // Calculate spawn radius in tiles, then convert to percentage of grid half-size: + // - 2-player: spawn at 10 tiles from center (well outside zone_min_radius=3) + // - 3+ player: spawn at 8 tiles from center (well outside zone_min_radius=1) + // This ensures zone shrinking forces bots into attack range (6 tiles for 2p, 3.5 for 3+) halfRows := float64(centerRow) halfCols := float64(centerCol) + halfSize := math.Min(halfRows, halfCols) + + var primaryRadius, secondaryRadius float64 + if numPlayers == 2 { + primarySpawnDist := 10.0 // tiles from center + secondarySpawnDist := 7.0 + primaryRadius = primarySpawnDist / halfSize + secondaryRadius = secondarySpawnDist / halfSize + } else { + primarySpawnDist := 8.0 // tiles from center + secondarySpawnDist := 6.0 + primaryRadius = primarySpawnDist / halfSize + secondaryRadius = secondarySpawnDist / halfSize + } for i := 0; i < numPlayers; i++ { baseAngle := float64(i) * 2.0 * math.Pi / float64(numPlayers) diff --git a/engine/match_test.go b/engine/match_test.go new file mode 100644 index 0000000..2781b6f --- /dev/null +++ b/engine/match_test.go @@ -0,0 +1,146 @@ +package engine + +import ( + "math" + "math/rand" + "testing" +) + +// TestSpawnRadiusOutsideZone verifies that bots spawn outside the final zone. +// Per plan §3.7.1, the zone forces combat by shrinking. Bots must start OUTSIDE +// the final safe zone so they are forced inward as the zone contracts. +func TestSpawnRadiusOutsideZone(t *testing.T) { + tests := []struct { + name string + numPlayers int + coresPerPlayer int + }{ + {"2-player", 2, 1}, + {"2-player-2-cores", 2, 2}, + {"3-player", 3, 1}, + {"4-player", 4, 1}, + {"6-player", 6, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ConfigForPlayers(tt.numPlayers, tt.coresPerPlayer) + gs := NewGameState(cfg, rand.New(rand.NewSource(42))) + + // Add players + for range tt.numPlayers { + gs.AddPlayer() + } + + // Generate map using the match runner + mr := NewMatchRunner(cfg, WithRNG(rand.New(rand.NewSource(42)))) + mr.generateMap(gs, tt.numPlayers) + + // Verify all spawn positions are outside the final zone + center := Position{Row: cfg.Rows / 2, Col: cfg.Cols / 2} + + for _, bot := range gs.Bots { + if !bot.Alive { + continue + } + + // Calculate distance from center + dist2 := gs.Grid.Distance2(bot.Position, center) + dist := math.Sqrt(float64(dist2)) + + // Verify spawn distance > zone min radius + if dist <= float64(cfg.ZoneMinRadius) { + t.Errorf("Player %d bot spawned at distance %.1f from center, <= zone min radius %d (position: %v)", + bot.Owner, dist, cfg.ZoneMinRadius, bot.Position) + } + } + }) + } +} + +// TestSpawnRadiusForcesCombat verifies that when zone shrinks to minimum, +// bots on opposite sides are within attack radius of each other. +func TestSpawnRadiusForcesCombat(t *testing.T) { + tests := []struct { + name string + numPlayers int + coresPerPlayer int + }{ + {"2-player", 2, 1}, + {"3-player", 3, 1}, + {"4-player", 4, 1}, + {"6-player", 6, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ConfigForPlayers(tt.numPlayers, tt.coresPerPlayer) + gs := NewGameState(cfg, rand.New(rand.NewSource(42))) + + // Add players + for range tt.numPlayers { + gs.AddPlayer() + } + + // Generate map + mr := NewMatchRunner(cfg, WithRNG(rand.New(rand.NewSource(42)))) + mr.generateMap(gs, tt.numPlayers) + + // Simulate zone shrinking to minimum + // Bots would be forced toward center, ending at zone edge + centerRow := cfg.Rows / 2 + centerCol := cfg.Cols / 2 + + // Calculate where each player's bot would end up at zone edge + // (simplified: project to zone edge along the same angle) + attackRadius := math.Sqrt(float64(cfg.AttackRadius2)) + + for _, bot := range gs.Bots { + if !bot.Alive || bot.Owner != 0 { + continue + } + + // Vector from center to bot + dr := float64(bot.Position.Row - centerRow) + dc := float64(bot.Position.Col - centerCol) + + // Normalize to zone edge + dist := math.Sqrt(dr*dr + dc*dc) + if dist == 0 { + continue + } + scale := float64(cfg.ZoneMinRadius) / dist + + // Check distance to other players' projected zone edge positions + for _, other := range gs.Bots { + if !other.Alive || other.Owner <= bot.Owner { + continue + } + + odr := float64(other.Position.Row - centerRow) + odc := float64(other.Position.Col - centerCol) + odist := math.Sqrt(odr*odr + odc*odc) + if odist == 0 { + continue + } + oscale := float64(cfg.ZoneMinRadius) / odist + + // Distance between zone edge projections + er := centerRow + int(dr*scale) + ec := centerCol + int(dc*scale) + eor := centerRow + int(odr*oscale) + eoc := centerCol + int(odc*oscale) + + // Toroidal distance + gridDist2 := gs.Grid.Distance2(Position{Row: er, Col: ec}, Position{Row: eor, Col: eoc}) + gridDist := math.Sqrt(float64(gridDist2)) + + if gridDist > attackRadius { + t.Errorf("Players %d and %d: projected zone edge distance %.1f > attack radius %.1f (bot pos: %v, other pos: %v)", + bot.Owner, other.Owner, gridDist, attackRadius, bot.Position, other.Position) + } + } + } + }) + } +}