From 16d474aceba91a9694c52f19adbd7ee92ac3a6e9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 24 May 2026 10:01:34 -0400 Subject: [PATCH] feat(engine): add shrinking play-zone (storm) for combat density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- engine/game.go | 15 +++++++++++++++ engine/replay.go | 17 +++++++++++++++++ engine/turn.go | 39 +++++++++++++++++++++++++++++++++++++++ engine/types.go | 28 ++++++++++++++++++++-------- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/engine/game.go b/engine/game.go index 6939b25..5380adc 100644 --- a/engine/game.go +++ b/engine/game.go @@ -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 diff --git a/engine/replay.go b/engine/replay.go index 26b5814..40ddd73 100644 --- a/engine/replay.go +++ b/engine/replay.go @@ -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{ diff --git a/engine/turn.go b/engine/turn.go index 4b2e155..5a60b5e 100644 --- a/engine/turn.go +++ b/engine/turn.go @@ -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 diff --git a/engine/types.go b/engine/types.go index 7252c5f..3bbbe1b 100644 --- a/engine/types.go +++ b/engine/types.go @@ -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, } }