ai-code-battle/engine/game.go
jedarden 1df567b15f feat(engine): add zone bounds to VisibleState for bot awareness
Per plan §3.7, the shrinking zone is a forcing function that should
force combat engagement. Previously, bots could not see the current
zone state (center, radius, active) and would move away from the zone
center, dying without understanding why.

Changes:
- Add Zone field to VisibleState (types.go)
- Populate zone bounds in GetVisibleState() when zone enabled (game.go)

This allows HTTP and local bots to see the zone and react strategically
(e.g., move toward center to avoid zone death while engaging enemies).

Test: zone bounds now appear in VisibleState JSON with correct values.

Closes: bf-tfyy
2026-05-26 01:52:30 -04:00

431 lines
10 KiB
Go

package engine
import (
"encoding/json"
"fmt"
"math/rand"
)
// GameState represents the complete state of a match.
type GameState struct {
Config Config
Grid *Grid
Bots []*Bot
Cores []*Core
Energy []*EnergyNode
Players []*Player
Turn int
MatchID string
NextBotID int
NextCoreID int
rng *rand.Rand
// Turn state
Moves map[int]Move // bot ID -> move
DeadBots []*Bot // bots that died this turn (for fog display)
Events []Event // events that occurred this turn
Dominance map[int]int // player -> consecutive turns with 80%+ bots
// Stalemate detection
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
CombatDeaths []int // combat deaths per player (tracked for final stats)
}
// Event represents something that happened during a turn.
type Event struct {
Type string `json:"type"`
Turn int `json:"turn"`
Details interface{} `json:"details"`
}
// Event types
const (
EventBotSpawned = "bot_spawned"
EventBotDied = "bot_died"
EventEnergyCollected = "energy_collected"
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),
Bots: make([]*Bot, 0),
Cores: make([]*Core, 0),
Energy: make([]*EnergyNode, 0),
Players: make([]*Player, 0),
Turn: 0,
MatchID: generateMatchID(rng),
NextBotID: 0,
rng: rng,
Moves: make(map[int]Move),
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: center,
ZoneRadius: initialRadius,
ZoneActive: false,
CombatDeaths: make([]int, 0), // Will be sized when players are added
}
}
// generateMatchID creates a random match identifier.
func generateMatchID(rng *rand.Rand) string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rng.Intn(len(chars))]
}
return "m_" + string(b)
}
// AddPlayer adds a new player to the game.
func (gs *GameState) AddPlayer() *Player {
p := &Player{
ID: len(gs.Players),
Energy: 0,
Score: 0,
}
gs.Players = append(gs.Players, p)
gs.Dominance[p.ID] = 0
gs.CombatDeaths = append(gs.CombatDeaths, 0) // Track combat deaths for this player
return p
}
// AddCore adds a core for a player at the given position.
func (gs *GameState) AddCore(owner int, pos Position) *Core {
c := &Core{
Position: gs.Grid.WrapPos(pos),
Owner: owner,
Active: true,
ID: gs.NextCoreID,
}
gs.NextCoreID++
gs.Cores = append(gs.Cores, c)
gs.Grid.SetPos(c.Position, TileCore)
// Player starts with 1 point per core
if owner < len(gs.Players) {
gs.Players[owner].Score++
}
return c
}
// AddEnergyNode adds an energy node at the given position.
func (gs *GameState) AddEnergyNode(pos Position) *EnergyNode {
en := &EnergyNode{
Position: gs.Grid.WrapPos(pos),
HasEnergy: false,
Tick: 0,
}
gs.Energy = append(gs.Energy, en)
return en
}
// SpawnBot spawns a new bot for a player at the given position.
func (gs *GameState) SpawnBot(owner int, pos Position) *Bot {
b := &Bot{
ID: gs.NextBotID,
Owner: owner,
Position: gs.Grid.WrapPos(pos),
Alive: true,
}
gs.NextBotID++
gs.Bots = append(gs.Bots, b)
if owner < len(gs.Players) {
gs.Players[owner].BotCount++
}
gs.Events = append(gs.Events, Event{
Type: EventBotSpawned,
Turn: gs.Turn,
Details: map[string]interface{}{
"bot_id": b.ID,
"owner": owner,
"pos": b.Position,
},
})
return b
}
// GetPlayerBots returns all living bots for a player.
func (gs *GameState) GetPlayerBots(playerID int) []*Bot {
var bots []*Bot
for _, b := range gs.Bots {
if b.Alive && b.Owner == playerID {
bots = append(bots, b)
}
}
return bots
}
// GetLivingBotCount returns the count of living bots.
func (gs *GameState) GetLivingBotCount() int {
count := 0
for _, b := range gs.Bots {
if b.Alive {
count++
}
}
return count
}
// GetPlayerLivingBotCount returns the count of living bots for a player.
func (gs *GameState) GetPlayerLivingBotCount(playerID int) int {
count := 0
for _, b := range gs.Bots {
if b.Alive && b.Owner == playerID {
count++
}
}
return count
}
// GetLivingPlayers returns IDs of players with at least one living bot.
func (gs *GameState) GetLivingPlayers() []int {
alive := make(map[int]bool)
for _, b := range gs.Bots {
if b.Alive {
alive[b.Owner] = true
}
}
var result []int
for pid := range alive {
result = append(result, pid)
}
return result
}
// KillBot marks a bot as dead and records the event.
func (gs *GameState) KillBot(bot *Bot, reason string) {
if !bot.Alive {
return
}
bot.Alive = false
gs.DeadBots = append(gs.DeadBots, bot)
if bot.Owner < len(gs.Players) {
gs.Players[bot.Owner].BotCount--
}
gs.Events = append(gs.Events, Event{
Type: EventBotDied,
Turn: gs.Turn,
Details: map[string]interface{}{
"bot_id": bot.ID,
"owner": bot.Owner,
"pos": bot.Position,
"reason": reason,
},
})
}
// SubmitMove records a move for a bot at a given position.
func (gs *GameState) SubmitMove(pos Position, dir Direction) {
// Find the bot at this position
for _, b := range gs.Bots {
if b.Alive && b.Position == pos {
gs.Moves[b.ID] = Move{Position: pos, Direction: dir}
return
}
}
}
// ClearTurnState clears the per-turn state.
func (gs *GameState) ClearTurnState() {
gs.Moves = make(map[int]Move)
gs.DeadBots = make([]*Bot, 0)
gs.Events = make([]Event, 0)
}
// GetVisibleState returns the game state filtered by fog of war for a player.
func (gs *GameState) GetVisibleState(playerID int) *VisibleState {
vs := &VisibleState{
MatchID: gs.MatchID,
Turn: gs.Turn,
Config: gs.Config,
}
vs.You.ID = playerID
vs.You.Energy = gs.Players[playerID].Energy
vs.You.Score = gs.Players[playerID].Score
// Get positions of player's bots
playerPositions := make([]Position, 0)
for _, b := range gs.Bots {
if b.Alive && b.Owner == playerID {
playerPositions = append(playerPositions, b.Position)
}
}
// Calculate visible tiles
visible := gs.Grid.VisibleFrom(playerPositions, gs.Config.VisionRadius2)
// Filter bots
vs.Bots = make([]VisibleBot, 0)
for _, b := range gs.Bots {
if b.Alive && visible[b.Position] {
vs.Bots = append(vs.Bots, VisibleBot{
Position: b.Position,
Owner: b.Owner,
})
}
}
// Filter dead bots (visible for one turn)
for _, b := range gs.DeadBots {
if visible[b.Position] {
vs.Dead = append(vs.Dead, VisibleBot{
Position: b.Position,
Owner: b.Owner,
})
}
}
// Filter energy nodes with energy
vs.Energy = make([]Position, 0)
for _, en := range gs.Energy {
if en.HasEnergy && visible[en.Position] {
vs.Energy = append(vs.Energy, en.Position)
}
}
// Filter cores
vs.Cores = make([]VisibleCore, 0)
for _, c := range gs.Cores {
if visible[c.Position] {
vs.Cores = append(vs.Cores, VisibleCore{
Position: c.Position,
Owner: c.Owner,
Active: c.Active,
})
}
}
// Filter walls
vs.Walls = make([]Position, 0)
for p := range gs.Grid.Walls {
if visible[p] {
vs.Walls = append(vs.Walls, p)
}
}
// Include zone bounds if enabled
if gs.Config.ZoneEnabled {
vs.Zone = &ZoneBounds{
Center: gs.ZoneCenter,
Radius: gs.ZoneRadius,
Active: gs.ZoneActive,
}
}
return vs
}
// ToJSON returns a JSON representation of the game state.
func (gs *GameState) ToJSON() ([]byte, error) {
return json.MarshalIndent(gs, "", " ")
}
// Clone creates a deep copy of the game state.
func (gs *GameState) Clone() *GameState {
newGS := &GameState{
Config: gs.Config,
Grid: NewGrid(gs.Config.Rows, gs.Config.Cols),
Bots: make([]*Bot, len(gs.Bots)),
Cores: make([]*Core, len(gs.Cores)),
Energy: make([]*EnergyNode, len(gs.Energy)),
Players: make([]*Player, len(gs.Players)),
Turn: gs.Turn,
MatchID: gs.MatchID,
NextBotID: gs.NextBotID,
NextCoreID: gs.NextCoreID,
rng: gs.rng,
Moves: make(map[int]Move),
DeadBots: make([]*Bot, 0),
Events: make([]Event, 0),
Dominance: make(map[int]int),
ZoneCenter: gs.ZoneCenter,
ZoneRadius: gs.ZoneRadius,
ZoneActive: gs.ZoneActive,
}
// Copy grid
for p := range gs.Grid.Walls {
newGS.Grid.Walls[p] = true
}
for row := 0; row < gs.Config.Rows; row++ {
for col := 0; col < gs.Config.Cols; col++ {
newGS.Grid.Tiles[row][col] = gs.Grid.Tiles[row][col]
}
}
// Copy bots
for i, b := range gs.Bots {
newGS.Bots[i] = &Bot{
ID: b.ID,
Owner: b.Owner,
Position: b.Position,
Alive: b.Alive,
}
}
// Copy cores
for i, c := range gs.Cores {
newGS.Cores[i] = &Core{
Position: c.Position,
Owner: c.Owner,
Active: c.Active,
ID: c.ID,
LastSpawnedTurn: c.LastSpawnedTurn,
}
}
// Copy energy nodes
for i, en := range gs.Energy {
newGS.Energy[i] = &EnergyNode{
Position: en.Position,
HasEnergy: en.HasEnergy,
Tick: en.Tick,
}
}
// Copy players
for i, p := range gs.Players {
newGS.Players[i] = &Player{
ID: p.ID,
Energy: p.Energy,
Score: p.Score,
BotCount: p.BotCount,
}
}
// Copy dominance
for k, v := range gs.Dominance {
newGS.Dominance[k] = v
}
return newGS
}
// String returns a string representation of the game state.
func (gs *GameState) String() string {
return fmt.Sprintf("GameState{Turn: %d, Players: %d, Bots: %d, Living: %d}",
gs.Turn, len(gs.Players), len(gs.Bots), gs.GetLivingBotCount())
}