ai-code-battle/cmd/acb-map-evolver/main.go
jedarden a5859df795 Add map evolution pipeline (cmd/acb-map-evolver)
Phase 9 implementation: Map Evolution (§14.6)
- Parent selection weighted by engagement × vote multiplier
- Crossover with sector-based wall inheritance
- Symmetry-preserving mutation (wall flips, energy node shifts)
- Validation: connectivity, wall density bounds, energy node access
- Smoke test validation (simplified: connectivity + energy count checks)
- PostgreSQL maps, map_votes, map_fairness tables for lifecycle management
- Maps stored with status: active, probation, retired, classic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 02:35:33 -04:00

872 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command acb-map-evolver evolves maps through breeding and mutation.
// It selects high-engagement parent maps, breeds offspring via crossover,
// applies mutations, validates connectivity, and smoke-tests with bots.
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"os"
"time"
_ "github.com/lib/pq"
)
// Config holds command-line configuration.
type Config struct {
DatabaseURL string
PlayerCount int
NumOffspring int
DryRun bool
MinEngagement float64
MaxAttempts int
ValidateSmoke bool
}
// Map represents a game map.
type Map struct {
ID string `json:"id"`
Players int `json:"players"`
Rows int `json:"rows"`
Cols int `json:"cols"`
WallDensity float64 `json:"wall_density"`
Walls []Position `json:"walls"`
Cores []Core `json:"cores"`
EnergyNodes []Position `json:"energy_nodes"`
}
// Position represents a grid coordinate.
type Position struct {
Row int `json:"row"`
Col int `json:"col"`
}
// Core represents a spawn point.
type Core struct {
Position Position `json:"position"`
Owner int `json:"owner"`
}
// PositionSet is a set of positions.
type PositionSet map[Position]bool
// ParentMap represents a parent map with its engagement score.
type ParentMap struct {
Map *Map
Engagement float64
VoteMult float64
}
func main() {
cfg := parseConfig()
if cfg == nil {
os.Exit(1)
}
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Run evolution
evolver := NewMapEvolver(db, cfg)
results, err := evolver.Run(ctx)
if err != nil {
log.Fatalf("Evolution failed: %v", err)
}
log.Printf("Evolution complete: %d new maps created", len(results))
for _, m := range results {
log.Printf(" - %s (engagement: %.2f)", m.ID, m.WallDensity)
}
}
func parseConfig() *Config {
cfg := &Config{
DatabaseURL: os.Getenv("ACB_DATABASE_URL"),
PlayerCount: 2,
NumOffspring: 5,
MinEngagement: 5.0,
MaxAttempts: 10,
ValidateSmoke: true,
}
for i, arg := range os.Args[1:] {
switch arg {
case "--player-count":
if i+1 < len(os.Args[1:]) {
fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.PlayerCount)
}
case "--num-offspring":
if i+1 < len(os.Args[1:]) {
fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.NumOffspring)
}
case "--min-engagement":
if i+1 < len(os.Args[1:]) {
fmt.Sscanf(os.Args[1:][i+1], "%f", &cfg.MinEngagement)
}
case "--dry-run":
cfg.DryRun = true
case "--no-smoke":
cfg.ValidateSmoke = false
case "--help", "-h":
fmt.Println("Usage: acb-map-evolver [options]")
fmt.Println("")
fmt.Println("Options:")
fmt.Println(" --player-count N Player count tier (2, 3, 4, or 6) [default: 2]")
fmt.Println(" --num-offspring N Number of maps to create [default: 5]")
fmt.Println(" --min-engagement F Minimum engagement threshold for parents [default: 5.0]")
fmt.Println(" --dry-run Generate maps but don't save to database")
fmt.Println(" --no-smoke Skip smoke-test validation")
fmt.Println(" --help Show this help")
return nil
}
}
if cfg.DatabaseURL == "" && !cfg.DryRun {
log.Fatal("ACB_DATABASE_URL environment variable is required")
}
return cfg
}
// MapEvolver handles map evolution.
type MapEvolver struct {
db *sql.DB
cfg *Config
rng *rand.Rand
}
// NewMapEvolver creates a new map evolver.
func NewMapEvolver(db *sql.DB, cfg *Config) *MapEvolver {
return &MapEvolver{
db: db,
cfg: cfg,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// Run executes the evolution pipeline.
func (e *MapEvolver) Run(ctx context.Context) ([]*Map, error) {
// 1. Select parent maps
parents, err := e.selectParents(ctx)
if err != nil {
return nil, fmt.Errorf("selecting parents: %w", err)
}
if len(parents) < 2 {
return nil, fmt.Errorf("need at least 2 parent maps, found %d", len(parents))
}
log.Printf("Selected %d parent maps", len(parents))
// 2. Breed offspring
var offspring []*Map
for i := 0; i < e.cfg.NumOffspring; i++ {
for attempt := 0; attempt < e.cfg.MaxAttempts; attempt++ {
child := e.breed(parents)
if child == nil {
continue
}
// 3. Validate
if !e.validate(child) {
continue
}
// 4. Smoke test (if enabled)
if e.cfg.ValidateSmoke && !e.smokeTest(child) {
continue
}
offspring = append(offspring, child)
break
}
}
// 5. Save to database
if !e.cfg.DryRun {
for _, m := range offspring {
if err := e.saveMap(ctx, m); err != nil {
log.Printf("Failed to save map %s: %v", m.ID, err)
}
}
}
return offspring, nil
}
// selectParents retrieves top maps by engagement × vote multiplier.
func (e *MapEvolver) selectParents(ctx context.Context) ([]*ParentMap, error) {
query := `
SELECT m.map_id, m.map_json, COALESCE(ms.engagement, 0) as engagement,
CASE
WHEN COALESCE(votes.net_votes, 0) > 10 THEN 1.5
WHEN COALESCE(votes.net_votes, 0) < 0 THEN 0.5
ELSE 1.0
END as vote_mult
FROM maps m
LEFT JOIN map_scores ms ON m.map_id = ms.map_id
LEFT JOIN (
SELECT map_id, SUM(vote) as net_votes
FROM map_votes
GROUP BY map_id
) votes ON m.map_id = votes.map_id
WHERE m.player_count = $1
AND m.status IN ('active', 'classic')
ORDER BY COALESCE(ms.engagement, 0) *
CASE WHEN COALESCE(votes.net_votes, 0) > 10 THEN 1.5
WHEN COALESCE(votes.net_votes, 0) < 0 THEN 0.5
ELSE 1.0 END DESC
LIMIT 20
`
rows, err := e.db.QueryContext(ctx, query, e.cfg.PlayerCount)
if err != nil {
return nil, err
}
defer rows.Close()
var parents []*ParentMap
for rows.Next() {
var id string
var mapJSON []byte
var engagement float64
var voteMult float64
if err := rows.Scan(&id, &mapJSON, &engagement, &voteMult); err != nil {
return nil, err
}
var m Map
if err := json.Unmarshal(mapJSON, &m); err != nil {
log.Printf("Failed to unmarshal map %s: %v", id, err)
continue
}
m.ID = id
parents = append(parents, &ParentMap{
Map: &m,
Engagement: engagement,
VoteMult: voteMult,
})
}
return parents, nil
}
// breed creates a new map from parent maps via crossover and mutation.
func (e *MapEvolver) breed(parents []*ParentMap) *Map {
// Weighted random selection based on engagement × vote multiplier
p1 := e.selectWeighted(parents)
p2 := e.selectWeighted(parents)
for p2 == p1 && len(parents) > 1 {
p2 = e.selectWeighted(parents)
}
// Create child from crossover
child := e.crossover(p1.Map, p2.Map)
// Apply mutations
e.mutate(child)
// Generate new ID
child.ID = generateMapID(e.rng)
child.Players = e.cfg.PlayerCount
return child
}
// selectWeighted selects a parent with probability proportional to engagement × vote multiplier.
func (e *MapEvolver) selectWeighted(parents []*ParentMap) *ParentMap {
totalWeight := 0.0
for _, p := range parents {
w := p.Engagement * p.VoteMult
if w < 0.1 {
w = 0.1 // Minimum weight
}
totalWeight += w
}
r := e.rng.Float64() * totalWeight
cumulative := 0.0
for _, p := range parents {
w := p.Engagement * p.VoteMult
if w < 0.1 {
w = 0.1
}
cumulative += w
if r <= cumulative {
return p
}
}
return parents[len(parents)-1]
}
// crossover combines two parent maps into a child.
func (e *MapEvolver) crossover(p1, p2 *Map) *Map {
child := &Map{
Rows: p1.Rows,
Cols: p1.Cols,
Players: e.cfg.PlayerCount,
WallDensity: (p1.WallDensity + p2.WallDensity) / 2,
Walls: make([]Position, 0),
Cores: make([]Core, 0),
EnergyNodes: make([]Position, 0),
}
// Use cores from p1 (they should be symmetric anyway)
child.Cores = p1.Cores
centerRow := child.Rows / 2
centerCol := child.Cols / 2
sectorAngle := 2.0 * math.Pi / float64(child.Players)
// Build wall sets
walls1 := make(PositionSet)
for _, w := range p1.Walls {
walls1[w] = true
}
walls2 := make(PositionSet)
for _, w := range p2.Walls {
walls2[w] = true
}
// Crossover: for each position in sector 0, pick wall from p1 or p2
// Then mirror to all sectors
for r := 0; r < child.Rows; r++ {
for c := 0; c < child.Cols; c++ {
dr := float64(r) - float64(centerRow)
dc := float64(c) - float64(centerCol)
angle := math.Atan2(dc, dr)
if angle < 0 {
angle += 2.0 * math.Pi
}
sector := int(angle / sectorAngle)
if sector >= child.Players {
sector = child.Players - 1
}
// Only process sector 0, then mirror
if sector != 0 {
continue
}
pos := Position{Row: r, Col: c}
isWall := false
if walls1[pos] && walls2[pos] {
// Both have wall: keep it
isWall = true
} else if walls1[pos] || walls2[pos] {
// One has wall: 50% chance
isWall = e.rng.Float64() < 0.5
}
if isWall {
// Mirror wall to all sectors
for s := 0; s < child.Players; s++ {
rotAngle := float64(s) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := math.Sin(rotAngle)
rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA))
rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA))
rr = ((rr % child.Rows) + child.Rows) % child.Rows
rc = ((rc % child.Cols) + child.Cols) % child.Cols
child.Walls = append(child.Walls, Position{Row: rr, Col: rc})
}
}
}
}
// Crossover energy nodes: take from both parents
seenNodes := make(PositionSet)
for _, en := range p1.EnergyNodes {
if !seenNodes[en] {
child.EnergyNodes = append(child.EnergyNodes, en)
seenNodes[en] = true
}
}
for _, en := range p2.EnergyNodes {
if !seenNodes[en] && e.rng.Float64() < 0.5 {
child.EnergyNodes = append(child.EnergyNodes, en)
seenNodes[en] = true
}
}
// Update wall density
child.WallDensity = float64(len(child.Walls)) / float64(child.Rows*child.Cols)
return child
}
// mutate applies random mutations to a map.
func (e *MapEvolver) mutate(m *Map) {
wallSet := make(PositionSet)
for _, w := range m.Walls {
wallSet[w] = true
}
protected := make(PositionSet)
for _, core := range m.Cores {
for dr := -3; dr <= 3; dr++ {
for dc := -3; dc <= 3; dc++ {
nr := ((core.Position.Row + dr) % m.Rows + m.Rows) % m.Rows
nc := ((core.Position.Col + dc) % m.Cols + m.Cols) % m.Cols
protected[Position{Row: nr, Col: nc}] = true
}
}
}
for _, en := range m.EnergyNodes {
protected[en] = true
}
// Mutate walls: flip 5-10% of tiles
mutationRate := 0.05 + e.rng.Float64()*0.05
centerRow := m.Rows / 2
centerCol := m.Cols / 2
sectorAngle := 2.0 * math.Pi / float64(m.Players)
// Collect positions to mutate in sector 0
var toFlip []Position
for r := 0; r < m.Rows; r++ {
for c := 0; c < m.Cols; c++ {
dr := float64(r) - float64(centerRow)
dc := float64(c) - float64(centerCol)
angle := math.Atan2(dc, dr)
if angle < 0 {
angle += 2.0 * math.Pi
}
sector := int(angle / sectorAngle)
if sector >= m.Players {
sector = m.Players - 1
}
if sector != 0 {
continue
}
pos := Position{Row: r, Col: c}
if protected[pos] {
continue
}
if e.rng.Float64() < mutationRate {
toFlip = append(toFlip, pos)
}
}
}
// Apply flips with mirroring
for _, pos := range toFlip {
isWall := wallSet[pos]
// Remove existing walls at all mirrored positions
for s := 0; s < m.Players; s++ {
dr := float64(pos.Row) - float64(centerRow)
dc := float64(pos.Col) - float64(centerCol)
rotAngle := float64(s) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := math.Sin(rotAngle)
rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA))
rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA))
rr = ((rr % m.Rows) + m.Rows) % m.Rows
rc = ((rc % m.Cols) + m.Cols) % m.Cols
mirrorPos := Position{Row: rr, Col: rc}
if isWall {
// Remove wall
delete(wallSet, mirrorPos)
} else {
// Add wall
wallSet[mirrorPos] = true
}
}
}
// Rebuild wall list
m.Walls = make([]Position, 0, len(wallSet))
for pos := range wallSet {
m.Walls = append(m.Walls, pos)
}
// Shift 1-3 energy nodes by 1-3 tiles (with symmetry)
numShifts := 1 + e.rng.Intn(3)
for i := 0; i < numShifts && len(m.EnergyNodes) > 0; i++ {
idx := e.rng.Intn(len(m.EnergyNodes))
oldPos := m.EnergyNodes[idx]
// Find sector of this node
dr := float64(oldPos.Row) - float64(centerRow)
dc := float64(oldPos.Col) - float64(centerCol)
angle := math.Atan2(dc, dr)
if angle < 0 {
angle += 2.0 * math.Pi
}
sector := int(angle / sectorAngle)
if sector >= m.Players {
sector = m.Players - 1
}
// Only shift if in sector 0
if sector != 0 {
continue
}
// Shift by 1-3 tiles in a random direction
shiftDist := 1 + e.rng.Intn(3)
shiftAngle := e.rng.Float64() * 2 * math.Pi
// Remove old position and all mirrors
newNodes := make([]Position, 0)
nodeSet := make(PositionSet)
for _, en := range m.EnergyNodes {
nodeSet[en] = true
}
for s := 0; s < m.Players; s++ {
rotAngle := float64(s) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := math.Sin(rotAngle)
rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA))
rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA))
delete(nodeSet, Position{Row: rr, Col: rc})
}
// Calculate new position in sector 0
newR := int(math.Round(float64(oldPos.Row) + float64(shiftDist)*math.Cos(shiftAngle)))
newC := int(math.Round(float64(oldPos.Col) + float64(shiftDist)*math.Sin(shiftAngle)))
newR = ((newR % m.Rows) + m.Rows) % m.Rows
newC = ((newC % m.Cols) + m.Cols) % m.Cols
// Add new position and all mirrors
newDR := float64(newR) - float64(centerRow)
newDC := float64(newC) - float64(centerCol)
for s := 0; s < m.Players; s++ {
rotAngle := float64(s) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := math.Sin(rotAngle)
rr := int(math.Round(float64(centerRow) + newDR*cosA - newDC*sinA))
rc := int(math.Round(float64(centerCol) + newDR*sinA + newDC*cosA))
rr = ((rr % m.Rows) + m.Rows) % m.Rows
rc = ((rc % m.Cols) + m.Cols) % m.Cols
newPos := Position{Row: rr, Col: rc}
if !wallSet[newPos] {
nodeSet[newPos] = true
}
}
for pos := range nodeSet {
newNodes = append(newNodes, pos)
}
m.EnergyNodes = newNodes
break // Only one shift per mutation run
}
// Update wall density
m.WallDensity = float64(len(m.Walls)) / float64(m.Rows*m.Cols)
// Apply smoothing (2 iterations of cellular automata)
e.smoothWalls(m, protected)
}
// smoothWalls applies cellular automata smoothing to walls.
// This is a simplified version that preserves existing walls while allowing
// for some natural clustering through the mutation process.
func (e *MapEvolver) smoothWalls(m *Map, protected PositionSet) {
// For now, skip the full cellular automata smoothing as it's too aggressive
// when combined with the mutation. The mutation already provides enough variation.
// The full CA smoothing is better used in initial map generation.
// Just ensure symmetry is maintained after mutation
centerRow := m.Rows / 2
centerCol := m.Cols / 2
sectorAngle := 2.0 * math.Pi / float64(m.Players)
// Build wall set
wallSet := make(PositionSet)
for _, w := range m.Walls {
wallSet[w] = true
}
// Collect walls in sector 0
sector0Walls := make(PositionSet)
for pos := range wallSet {
dr := float64(pos.Row) - float64(centerRow)
dc := float64(pos.Col) - float64(centerCol)
angle := math.Atan2(dc, dr)
if angle < 0 {
angle += 2.0 * math.Pi
}
sector := int(angle / sectorAngle)
if sector >= m.Players {
sector = m.Players - 1
}
if sector == 0 && !protected[pos] {
sector0Walls[pos] = true
}
}
// Rebuild walls from sector 0 with proper mirroring
newWallSet := make(PositionSet)
for pos := range sector0Walls {
dr := float64(pos.Row) - float64(centerRow)
dc := float64(pos.Col) - float64(centerCol)
// Mirror to all sectors
for s := 0; s < m.Players; s++ {
rotAngle := float64(s) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := math.Sin(rotAngle)
rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA))
rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA))
rr = ((rr % m.Rows) + m.Rows) % m.Rows
rc = ((rc % m.Cols) + m.Cols) % m.Cols
mirrorPos := Position{Row: rr, Col: rc}
if !protected[mirrorPos] {
newWallSet[mirrorPos] = true
}
}
}
m.Walls = make([]Position, 0, len(newWallSet))
for pos := range newWallSet {
m.Walls = append(m.Walls, pos)
}
m.WallDensity = float64(len(m.Walls)) / float64(m.Rows * m.Cols)
}
// validate checks if a map meets all validation criteria.
func (e *MapEvolver) validate(m *Map) bool {
// Check wall density bounds
if m.WallDensity < 0.05 || m.WallDensity > 0.30 {
return false
}
// Check connectivity
if !e.checkConnectivity(m) {
return false
}
// Check open area per player
totalTiles := m.Rows * m.Cols
wallCount := len(m.Walls)
openTiles := totalTiles - wallCount
openPerPlayer := openTiles / m.Players
if openPerPlayer < 900 || openPerPlayer > 5000 {
return false
}
// Check each core can reach at least 3 energy nodes
for _, core := range m.Cores {
reachable := e.countReachableEnergyNodes(m, core.Position)
if reachable < 3 {
return false
}
}
return true
}
// checkConnectivity verifies all passable tiles are reachable from cores.
func (e *MapEvolver) checkConnectivity(m *Map) bool {
if len(m.Cores) == 0 {
return false
}
// Build wall set
wallSet := make(PositionSet)
for _, w := range m.Walls {
wallSet[w] = true
}
// Count passable tiles
passable := make(PositionSet)
for r := 0; r < m.Rows; r++ {
for c := 0; c < m.Cols; c++ {
pos := Position{Row: r, Col: c}
if !wallSet[pos] {
passable[pos] = true
}
}
}
// BFS from first core
start := m.Cores[0].Position
if wallSet[start] {
return false
}
visited := make(PositionSet)
queue := []Position{start}
visited[start] = true
count := 1
dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
for _, d := range dirs {
nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows
nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols
np := Position{Row: nr, Col: nc}
if passable[np] && !visited[np] {
visited[np] = true
queue = append(queue, np)
count++
}
}
}
return count == len(passable)
}
// countReachableEnergyNodes counts energy nodes reachable from a starting position.
func (e *MapEvolver) countReachableEnergyNodes(m *Map, start Position) int {
wallSet := make(PositionSet)
for _, w := range m.Walls {
wallSet[w] = true
}
energySet := make(PositionSet)
for _, en := range m.EnergyNodes {
energySet[en] = true
}
visited := make(PositionSet)
queue := []Position{start}
visited[start] = true
count := 0
dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
if energySet[curr] {
count++
}
for _, d := range dirs {
nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows
nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols
np := Position{Row: nr, Col: nc}
if !wallSet[np] && !visited[np] {
visited[np] = true
queue = append(queue, np)
}
}
}
return count
}
// smokeTest runs quick matches to verify the map produces reasonable engagement.
func (e *MapEvolver) smokeTest(m *Map) bool {
// For now, use a simplified check: verify the map has reasonable properties
// A full smoke test would run 3 matches with built-in bots
// Check that map has enough energy nodes
minEnergy := m.Players * 3
if len(m.EnergyNodes) < minEnergy {
return false
}
// Check that walls don't block paths between cores
for i, core1 := range m.Cores {
for j, core2 := range m.Cores {
if i >= j {
continue
}
if !e.canReach(m, core1.Position, core2.Position) {
return false
}
}
}
return true
}
// canReach checks if two positions are reachable from each other.
func (e *MapEvolver) canReach(m *Map, start, end Position) bool {
wallSet := make(PositionSet)
for _, w := range m.Walls {
wallSet[w] = true
}
visited := make(PositionSet)
queue := []Position{start}
visited[start] = true
dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
if curr == end {
return true
}
for _, d := range dirs {
nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows
nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols
np := Position{Row: nr, Col: nc}
if !wallSet[np] && !visited[np] {
visited[np] = true
queue = append(queue, np)
}
}
}
return false
}
// saveMap stores a map in the database.
func (e *MapEvolver) saveMap(ctx context.Context, m *Map) error {
mapJSON, err := json.Marshal(m)
if err != nil {
return err
}
query := `
INSERT INTO maps (map_id, player_count, status, engagement, wall_density, energy_count, grid_width, grid_height, map_json)
VALUES ($1, $2, 'active', 0, $3, $4, $5, $6, $7)
`
_, err = e.db.ExecContext(ctx, query,
m.ID,
m.Players,
m.WallDensity,
len(m.EnergyNodes),
m.Cols,
m.Rows,
mapJSON,
)
return err
}
// generateMapID creates a random map ID.
func generateMapID(rng *rand.Rand) string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rng.Intn(len(chars))]
}
return "map_" + string(b)
}