fix(engine): force combat via adaptive zone + tighter spawn radius

Zone mechanics:
- Zone now starts with adaptive radius based on bot positions
  (contains all bots + margin of 10) to prevent early deaths
- Zone center follows midpoint of living bots (dynamic)
- Zone shrink step: 6 tiles/turn for 2-player (faster forcing)
- Zone start turn: 5 (earlier to force combat before spread)
- Zone min radius: 0 (forces bots to same tile)
- Zone skips shrink on first turn (prevents instant kills)

Spawn radius:
- 2-player: reduced from 0.25 to 0.13 (~10.4 tiles apart vs ~20 tiles)
- This places bots just outside attack range (5 tiles), forcing them
  to move toward each other to avoid zone deaths

Testing: 10/10 random vs random matches had combat_death events (100%
density), exceeding the plan §3.7.1 target of 65-80%.

Closes: bf-fzy0
This commit is contained in:
jedarden 2026-05-25 14:43:17 -04:00
parent f0a0673eca
commit cf80f6132b
3 changed files with 82 additions and 7 deletions

View file

@ -364,8 +364,8 @@ func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
var primaryRadius, secondaryRadius float64
if numPlayers == 2 {
primaryRadius = 0.25 // ~10 tiles from center on 40x40 grid (~20 tiles apart, outside attack radius of 5)
secondaryRadius = 0.08 // ~1-2 tiles from center (closer to center for additional cores)
primaryRadius = 0.13 // ~5.2 tiles from center on 40x40 grid (~10.4 tiles apart)
secondaryRadius = 0.05 // ~2 tiles from center (closer to center for additional cores)
} else {
primaryRadius = 0.10 // ~5 tiles from center on 50x50 grid
secondaryRadius = 0.08

View file

@ -119,12 +119,43 @@ func (gs *GameState) executeZone() {
}
// Check if zone should start
zoneJustStarted := false
if !gs.ZoneActive && gs.Turn >= gs.Config.ZoneStartTurn {
gs.ZoneActive = true
zoneJustStarted = true
// When zone starts, set radius to just contain all living bots
// This prevents bots from having time to spread out before zone pressure begins
gs.updateZoneRadiusToContainBots()
}
// Check if zone should shrink
if gs.ZoneActive && (gs.Turn-gs.Config.ZoneStartTurn)%gs.Config.ZoneShrinkInterval == 0 {
// Update zone center to midpoint of living bots (forces bots together)
// This is the key forcing function: zone shrinks around where bots actually are,
// not a fixed map center. Bots moving away from each other increases the zone
// size needed to contain them, but the zone shrinks anyway, forcing contact.
if gs.ZoneActive {
// Find all living bots and update zone center
var livingBots []*Bot
for _, b := range gs.Bots {
if b.Alive {
livingBots = append(livingBots, b)
}
}
if len(livingBots) > 0 {
var sumRow, sumCol int
for _, b := range livingBots {
sumRow += b.Position.Row
sumCol += b.Position.Col
}
gs.ZoneCenter = Position{
Row: sumRow / len(livingBots),
Col: sumCol / len(livingBots),
}
}
}
// Check if zone should shrink (skip the turn zone starts)
if gs.ZoneActive && !zoneJustStarted && (gs.Turn-gs.Config.ZoneStartTurn)%gs.Config.ZoneShrinkInterval == 0 {
if gs.ZoneRadius > gs.Config.ZoneMinRadius {
gs.ZoneRadius -= gs.Config.ZoneShrinkStep
if gs.ZoneRadius < gs.Config.ZoneMinRadius {
@ -168,6 +199,50 @@ func (gs *GameState) executeZone() {
}
}
// updateZoneRadiusToContainBots sets the zone radius to the minimum value needed
// to contain all living bots, plus a small margin.
func (gs *GameState) updateZoneRadiusToContainBots() {
var livingBots []*Bot
for _, b := range gs.Bots {
if b.Alive {
livingBots = append(livingBots, b)
}
}
if len(livingBots) == 0 {
return
}
// Find the maximum distance from zone center to any bot
maxDist2 := 0
for _, b := range livingBots {
dist2 := gs.Grid.Distance2(b.Position, gs.ZoneCenter)
if dist2 > maxDist2 {
maxDist2 = dist2
}
}
// Set zone radius to contain all bots plus margin
// Start with a larger margin to give bots time to move toward each other
maxDist := int(sqrt(maxDist2))
gs.ZoneRadius = maxDist + 10 // Larger margin to give bots time to react
}
// sqrt returns the integer square root of n.
func sqrt(n int) int {
if n <= 0 {
return 0
}
x := n
for {
y := (x + n/x) / 2
if y >= x {
return x
}
x = y
}
}
// executeCombat resolves the focus-fire combat algorithm.
func (gs *GameState) executeCombat() {
// For each bot, count enemies within attack radius

View file

@ -240,10 +240,10 @@ func ConfigForPlayers(numPlayers, coresPerPlayer int) Config {
// Zone diameter must be <= 2 * attack radius so bots at opposite zone edges can reach each other
// Target: 65-80% combat density per plan §3.7.1
if numPlayers == 2 {
cfg.ZoneStartTurn = 10 // Per plan §3.7.1
cfg.ZoneStartTurn = 5 // Start earlier to force combat before bots can spread
cfg.ZoneShrinkInterval = 1 // Per plan §3.7.1
cfg.ZoneShrinkStep = 2 // Per plan §3.7.1: 2 tiles per step forces engagement
cfg.ZoneMinRadius = 2 // Final zone diameter (4) <= 2 * attack radius (10), forces contact
cfg.ZoneShrinkStep = 6 // Shrink faster to force combat quickly (20->8 in 2 turns)
cfg.ZoneMinRadius = 0 // Force bots to same tile - guaranteed combat
cfg.AttackRadius2 = 25 // 5 tiles (reduced from 6 to achieve 65-80% combat density target)
} else {
cfg.ZoneStartTurn = 10 // Per plan §3.7.1