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 <noreply@anthropic.com>
This commit is contained in:
parent
b6b4d27267
commit
2dbfea5163
2 changed files with 170 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
146
engine/match_test.go
Normal file
146
engine/match_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue