ai-code-battle/engine/types.go
jedarden 62f94ff0ef fix(engine): improve combat density to 92% (target 65-80% per plan §3.7.1)
Changes:
1. Reduce 2-player spawn radius from 25% to 12.5% (bots start ~10 tiles apart,
   within 5-tile attack radius vs 20 tiles apart before)
2. Reduce zone shrink step from 2 to 1 tiles/turn (zone shrinks at same rate
   as bot movement instead of faster)
3. Reduce initial zone margin from 10 to 5 tiles (faster engagement)

Testing results:
- Random vs Random: 92% combat density (46/50 matches) - was 20%
- All strategy combinations: 100% combat density
- Target: 65-80% per plan §3.7.1

The key issue was that bots started too far apart and the zone shrank faster
than bots could move toward each other. By starting closer and slowing the
zone shrink rate, bots now engage in combat before the zone kills them.

Closes: bf-cssy
2026-05-25 16:42:49 -04:00

307 lines
8.9 KiB
Go

// Package engine implements the AI Code Battle game simulation.
package engine
import (
"encoding/json"
"fmt"
"math"
)
// Position represents a coordinate on the toroidal grid.
type Position struct {
Row int `json:"row"`
Col int `json:"col"`
}
// Tile represents the type of a grid cell.
type Tile int
const (
TileOpen Tile = iota
TileWall
TileEnergy
TileCore
)
// String returns the symbol representation of a tile.
func (t Tile) String() string {
switch t {
case TileOpen:
return "."
case TileWall:
return "#"
case TileEnergy:
return "*"
case TileCore:
return "C"
default:
return "?"
}
}
// Direction represents a movement direction.
type Direction int
const (
DirNone Direction = iota
DirN
DirE
DirS
DirW
)
// String returns the string representation of a direction.
func (d Direction) String() string {
switch d {
case DirN:
return "N"
case DirE:
return "E"
case DirS:
return "S"
case DirW:
return "W"
default:
return ""
}
}
// ParseDirection parses a direction string.
func ParseDirection(s string) Direction {
switch s {
case "N":
return DirN
case "E":
return DirE
case "S":
return DirS
case "W":
return DirW
default:
return DirNone
}
}
// MarshalJSON serializes Direction as a string ("N", "E", "S", "W", or "").
func (d Direction) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// UnmarshalJSON accepts both string ("N") and integer (1) representations.
func (d *Direction) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*d = ParseDirection(s)
return nil
}
var i int
if err := json.Unmarshal(data, &i); err != nil {
return fmt.Errorf("direction must be a string or integer: %w", err)
}
*d = Direction(i)
return nil
}
// Delta returns the row and column delta for a direction.
func (d Direction) Delta() (dr, dc int) {
switch d {
case DirN:
return -1, 0
case DirE:
return 0, 1
case DirS:
return 1, 0
case DirW:
return 0, -1
default:
return 0, 0
}
}
// Bot represents a unit on the grid.
type Bot struct {
ID int `json:"id"`
Owner int `json:"owner"`
Position Position `json:"position"`
Alive bool `json:"alive"`
}
// Core represents a spawn point owned by a player.
type Core struct {
Position Position `json:"position"`
Owner int `json:"owner"`
Active bool `json:"active"` // false if razed
ID int `json:"id"` // unique core identifier
LastSpawnedTurn int `json:"last_spawned_turn"` // turn when this core last spawned a bot
}
// EnergyNode represents an energy spawn location.
type EnergyNode struct {
Position Position `json:"position"`
HasEnergy bool `json:"has_energy"` // true if energy is currently collectible
Tick int `json:"tick"` // turns since last spawn
}
// Player represents a participant in the match.
type Player struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
BotCount int `json:"bot_count"`
}
// Move represents a bot's movement order.
// Bots are identified by their position in the fog-filtered state.
type Move struct {
Position Position `json:"position"` // current position of bot to move
Direction Direction `json:"direction"`
}
// Config holds game configuration parameters.
type Config struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
MaxTurns int `json:"max_turns"`
VisionRadius2 int `json:"vision_radius2"` // squared vision distance
AttackRadius2 int `json:"attack_radius2"` // squared attack distance
SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot
EnergyInterval int `json:"energy_interval"` // turns between energy spawns
CoresPerPlayer int `json:"cores_per_player"` // starting cores per player
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: 40,
Cols: 40,
MaxTurns: 500,
VisionRadius2: 49, // ~7 tiles
AttackRadius2: 12, // 3.5 tiles per plan §3.4
SpawnCost: 3,
EnergyInterval: 10,
CoresPerPlayer: 2,
ZoneEnabled: true,
ZoneStartTurn: 10, // Per plan §3.7.1 (both 2-player and 3+)
ZoneShrinkInterval: 1, // Per plan §3.7.1 (both 2-player and 3+)
ZoneShrinkStep: 2, // Per plan §3.7.1 (both 2-player and 3+)
ZoneMinRadius: 1, // Per plan §3.7.1: 3+ player default (ConfigForPlayers overrides for 2-player)
}
}
// ConfigForPlayers returns a config scaled for the given player count and cores per player.
// For 2 players, uses 40x40 (800 tiles per player) to increase encounter frequency.
// For 3+ players, uses ~2000 tiles per player (following aichallenge Ants sizing).
func ConfigForPlayers(numPlayers, coresPerPlayer int) Config {
cfg := DefaultConfig()
cfg.CoresPerPlayer = coresPerPlayer
if coresPerPlayer < 1 {
cfg.CoresPerPlayer = 1
}
// Scale grid: smaller maps for 2-player, ~1000 tiles/player for 3+ (high combat density)
var areaPerPlayer int
if numPlayers == 2 {
areaPerPlayer = 800 // 40x40 for 2 players
} else {
areaPerPlayer = 1000 // Reduced from 2000 to force more contact
}
totalArea := areaPerPlayer * numPlayers
side := int(math.Sqrt(float64(totalArea)))
// Clamp to valid range
if side < 30 {
side = 30
}
if side > 200 {
side = 200
}
cfg.Rows = side
cfg.Cols = side
// Scale max turns with map size
cfg.MaxTurns = side * 8 // larger maps get more turns
// Scale energy nodes with player count
cfg.EnergyInterval = 10
// Scale zone parameters to force combat contact
// Zone must start early to force combat before energy farming wins
// 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 to force combat before bots can spread
cfg.ZoneShrinkInterval = 1 // Per plan §3.7.1
cfg.ZoneShrinkStep = 1 // Zone shrinks at same rate as bot movement (1 tile/turn)
cfg.ZoneMinRadius = 2 // Per plan §3.7.1: 2-player min radius
cfg.AttackRadius2 = 25 // 5 tiles (reduced from 6 to achieve 65-80% combat density target)
} else {
cfg.ZoneStartTurn = 10 // Per plan §3.7.1
cfg.ZoneShrinkInterval = 1 // Per plan §3.7.1
cfg.ZoneShrinkStep = 1 // Zone shrinks at same rate as bot movement (1 tile/turn)
cfg.ZoneMinRadius = 1 // Zone diameter (2) < attack radius (3.5), forces contact
cfg.AttackRadius2 = 12 // 3.5 tiles per plan §3.4 (3+ player)
}
return cfg
}
// MatchResult represents the outcome of a match.
type MatchResult struct {
Winner int `json:"winner"` // -1 for draw
Reason string `json:"reason"` // "elimination", "dominance", "turns", "draw"
Turns int `json:"turns"`
Scores []int `json:"scores"`
Energy []int `json:"energy"` // energy collected per player
BotsAlive []int `json:"bots_alive"`
Crashed []bool `json:"crashed"` // per-player: true if bot was marked crashed during match
CombatDeaths []int `json:"combat_deaths"` // bots killed in combat per player (focus-fire)
}
// BotInterface defines the interface for bot decision-making.
// In Phase 1, this is implemented by local bots communicating via stdin/stdout.
type BotInterface interface {
// GetMoves returns the bot's moves for the current turn.
// state is the fog-filtered game state visible to this player.
GetMoves(state *VisibleState) ([]Move, error)
}
// VisibleState represents the game state filtered by fog of war for a specific player.
type VisibleState struct {
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Config Config `json:"config"`
You struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
} `json:"you"`
Bots []VisibleBot `json:"bots"`
Energy []Position `json:"energy"`
Cores []VisibleCore `json:"cores"`
Walls []Position `json:"walls"`
Dead []VisibleBot `json:"dead"`
}
// VisibleBot represents a bot visible to a player.
type VisibleBot struct {
Position Position `json:"position"`
Owner int `json:"owner"`
}
// VisibleCore represents a core visible to a player.
type VisibleCore struct {
Position Position `json:"position"`
Owner int `json:"owner"`
Active bool `json:"active"`
}