feat(engine): add shrinking play-zone (storm) for combat density

Add configurable active zone that contracts toward map center on an interval,
forcing bots together to trigger focus-fire engagements. Bots outside the zone
die with reason "zone", and zone bounds are recorded in replay turns.

Config fields:
- ZoneEnabled: enable/disable the zone
- ZoneStartTurn: turn when zone starts shrinking (default 50)
- ZoneShrinkInterval: turns between shrink steps (default 5)
- ZoneShrinkStep: tiles to shrink each step (default 2)
- ZoneMinRadius: minimum zone radius (default 10)

Turn sequence update: MOVE → COMBAT → ZONE → CAPTURE → COLLECT → SPAWN → ENERGY_TICK

Zone phase inserted after COMBAT, uses toroidal distance calculation,
and emits bot_died events with reason "zone" for killed bots.

Replay update: ZoneBounds struct added to ReplayTurn to record center,
radius, and active state per turn for visualization.

Determinism verified: all tests pass, engine remains deterministic.

Closes: bf-2g96
This commit is contained in:
jedarden 2026-05-24 10:01:34 -04:00
parent c55459d6d4
commit 16d474aceb
4 changed files with 91 additions and 8 deletions

View file

@ -30,6 +30,11 @@ type GameState struct {
StalemateTurns int // consecutive turns with no progress
LastTotalEnergy int // total energy held by all players at last progress
LastTotalBots int // total living bots at last progress
// Zone (storm) state
ZoneCenter Position // center of the zone (map center)
ZoneRadius int // current radius of the safe zone
ZoneActive bool // whether the zone is currently shrinking
}
// Event represents something that happened during a turn.
@ -47,10 +52,14 @@ const (
EventCoreCaptured = "core_captured"
EventCombatDeath = "combat_death"
EventCollisionDeath = "collision_death"
EventZoneDeath = "zone_death"
)
// NewGameState creates a new game state with the given configuration.
func NewGameState(config Config, rng *rand.Rand) *GameState {
center := Position{Row: config.Rows / 2, Col: config.Cols / 2}
initialRadius := min(config.Rows, config.Cols) / 2
return &GameState{
Config: config,
Grid: NewGrid(config.Rows, config.Cols),
@ -66,6 +75,9 @@ func NewGameState(config Config, rng *rand.Rand) *GameState {
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: center,
ZoneRadius: initialRadius,
ZoneActive: false,
}
}
@ -337,6 +349,9 @@ func (gs *GameState) Clone() *GameState {
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: gs.ZoneCenter,
ZoneRadius: gs.ZoneRadius,
ZoneActive: gs.ZoneActive,
}
// Copy grid

View file

@ -53,6 +53,7 @@ type ReplayTurn struct {
EnergyHeld []int `json:"energy_held"`
Events []Event `json:"events,omitempty"`
Debug map[int]*DebugInfo `json:"debug,omitempty"` // optional bot debug telemetry
ZoneBounds *ZoneBounds `json:"zone_bounds,omitempty"` // active zone bounds if enabled
}
// ReplayBot represents a bot in a replay turn.
@ -70,6 +71,13 @@ type ReplayCoreState struct {
Active bool `json:"active"`
}
// ZoneBounds represents the active zone bounds at a turn.
type ZoneBounds struct {
Center Position `json:"center"`
Radius int `json:"radius"`
Active bool `json:"active"`
}
// ReplayWriter records a match as it progresses.
type ReplayWriter struct {
replay *Replay
@ -141,6 +149,15 @@ func (rw *ReplayWriter) RecordTurn(gs *GameState, debug map[int]*DebugInfo) {
Debug: debug,
}
// Record zone bounds if enabled
if gs.Config.ZoneEnabled {
turn.ZoneBounds = &ZoneBounds{
Center: gs.ZoneCenter,
Radius: gs.ZoneRadius,
Active: gs.ZoneActive,
}
}
// Record all bots (including dead ones for death animation)
for _, b := range gs.Bots {
turn.Bots = append(turn.Bots, ReplayBot{

View file

@ -8,6 +8,7 @@ type TurnPhase int
const (
PhaseMove TurnPhase = iota
PhaseCombat
PhaseZone
PhaseCapture
PhaseCollect
PhaseSpawn
@ -26,6 +27,9 @@ func (gs *GameState) ExecuteTurn() *MatchResult {
// Phase: COMBAT - resolve focus-fire algorithm
gs.executeCombat()
// Phase: ZONE - shrinking zone kills bots outside
gs.executeZone()
// Phase: CAPTURE - enemy bots on undefended cores raze them
gs.executeCaptures()
@ -108,6 +112,41 @@ func (gs *GameState) executeMoves() {
}
}
// executeZone handles the shrinking zone (storm) that forces combat.
func (gs *GameState) executeZone() {
if !gs.Config.ZoneEnabled {
return
}
// Check if zone should start
if !gs.ZoneActive && gs.Turn >= gs.Config.ZoneStartTurn {
gs.ZoneActive = true
}
// Check if zone should shrink
if gs.ZoneActive && (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 {
gs.ZoneRadius = gs.Config.ZoneMinRadius
}
}
}
// Kill bots outside the zone
for _, b := range gs.Bots {
if !b.Alive {
continue
}
// Calculate distance from zone center (accounting for toroidal wrap)
dist2 := gs.Grid.Distance2(b.Position, gs.ZoneCenter)
if dist2 > gs.ZoneRadius*gs.ZoneRadius {
gs.KillBot(b, "zone")
}
}
}
// executeCombat resolves the focus-fire combat algorithm.
func (gs *GameState) executeCombat() {
// For each bot, count enemies within attack radius

View file

@ -170,19 +170,31 @@ type Config struct {
MapID string `json:"map_id,omitempty"`
SeasonID string `json:"season_id,omitempty"`
RulesVersion string `json:"rules_version,omitempty"`
// Zone (storm) configuration
ZoneEnabled bool `json:"zone_enabled"` // whether the shrinking zone is active
ZoneStartTurn int `json:"zone_start_turn"` // turn when zone starts shrinking
ZoneShrinkInterval int `json:"zone_shrink_interval"` // turns between shrink steps
ZoneShrinkStep int `json:"zone_shrink_step"` // tiles to shrink each step
ZoneMinRadius int `json:"zone_min_radius"` // minimum zone radius (stops here)
}
// DefaultConfig returns the default game configuration.
func DefaultConfig() Config {
return Config{
Rows: 60,
Cols: 60,
MaxTurns: 500,
VisionRadius2: 49, // ~7 tiles
AttackRadius2: 5, // ~2.24 tiles
SpawnCost: 3,
EnergyInterval: 10,
CoresPerPlayer: 2,
Rows: 60,
Cols: 60,
MaxTurns: 500,
VisionRadius2: 49, // ~7 tiles
AttackRadius2: 5, // ~2.24 tiles
SpawnCost: 3,
EnergyInterval: 10,
CoresPerPlayer: 2,
ZoneEnabled: false,
ZoneStartTurn: 50,
ZoneShrinkInterval: 5,
ZoneShrinkStep: 2,
ZoneMinRadius: 10,
}
}