Tab/space alignment consistency from running gofmt on all packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1392 lines
38 KiB
Go
1392 lines
38 KiB
Go
// 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
|
||
MinSeedCount int
|
||
EvolutionPeriod time.Duration
|
||
Once bool
|
||
Weekly bool
|
||
WeeklySchedule WeeklySchedule
|
||
}
|
||
|
||
// WeeklySchedule configures when the weekly evolution run fires.
|
||
type WeeklySchedule struct {
|
||
Weekday time.Weekday // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||
Hour int // 0-23 (UTC)
|
||
Minute int // 0-59
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// allPlayerCounts are the valid player counts the matchmaker supports.
|
||
var allPlayerCounts = []int{2, 3, 4, 6}
|
||
|
||
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()
|
||
|
||
evolver := NewMapEvolver(db, cfg)
|
||
|
||
// Seed the maps table on startup before entering the evolution loop.
|
||
// This is idempotent: it only generates maps when the count for a given
|
||
// player count is below MinSeedCount.
|
||
seedCtx, seedCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
for _, pc := range allPlayerCounts {
|
||
if err := evolver.seedIfEmpty(seedCtx, pc); err != nil {
|
||
log.Printf("warn: seed player_count=%d: %v", pc, err)
|
||
}
|
||
}
|
||
seedCancel()
|
||
|
||
if cfg.Once {
|
||
// One-shot mode: run evolution once for all player counts and exit
|
||
log.Printf("map-evolver: running one-shot evolution for all player counts")
|
||
totalCreated := 0
|
||
for _, pc := range allPlayerCounts {
|
||
cfg.PlayerCount = pc
|
||
iterCtx, iterCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
results, err := evolver.Run(iterCtx)
|
||
iterCancel()
|
||
if err != nil {
|
||
log.Printf("evolution error player_count=%d: %v", pc, err)
|
||
continue
|
||
}
|
||
log.Printf("player_count=%d: %d new maps created", pc, len(results))
|
||
totalCreated += len(results)
|
||
}
|
||
log.Printf("map-evolver: one-shot evolution complete, %d total maps created", totalCreated)
|
||
return
|
||
}
|
||
|
||
if cfg.Weekly {
|
||
// Weekly mode: run evolution on a weekly schedule
|
||
log.Printf("map-evolver: entering weekly evolution mode (schedule: %s %02d:%02d UTC)",
|
||
cfg.WeeklySchedule.Weekday, cfg.WeeklySchedule.Hour, cfg.WeeklySchedule.Minute)
|
||
runWeeklyLoop(evolver, cfg)
|
||
return
|
||
}
|
||
|
||
log.Printf("map-evolver: entering continuous evolution loop (period=%s)", cfg.EvolutionPeriod)
|
||
|
||
for {
|
||
for _, pc := range allPlayerCounts {
|
||
cfg.PlayerCount = pc
|
||
iterCtx, iterCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
results, err := evolver.Run(iterCtx)
|
||
iterCancel()
|
||
if err != nil {
|
||
log.Printf("evolution error player_count=%d: %v", pc, err)
|
||
continue
|
||
}
|
||
log.Printf("player_count=%d: %d new maps created", pc, len(results))
|
||
}
|
||
time.Sleep(cfg.EvolutionPeriod)
|
||
}
|
||
}
|
||
|
||
func parseConfig() *Config {
|
||
cfg := &Config{
|
||
DatabaseURL: os.Getenv("ACB_DATABASE_URL"),
|
||
PlayerCount: 2,
|
||
NumOffspring: 5,
|
||
MinEngagement: 5.0,
|
||
MaxAttempts: 10,
|
||
ValidateSmoke: true,
|
||
MinSeedCount: 20,
|
||
EvolutionPeriod: 30 * time.Minute,
|
||
WeeklySchedule: WeeklySchedule{
|
||
Weekday: time.Sunday, // Default: Sunday
|
||
Hour: 3, // Default: 03:00 UTC
|
||
Minute: 0,
|
||
},
|
||
}
|
||
|
||
// Allow env var overrides before flag parsing.
|
||
if v := os.Getenv("ACB_MIN_SEED_COUNT"); v != "" {
|
||
fmt.Sscanf(v, "%d", &cfg.MinSeedCount)
|
||
}
|
||
if v := os.Getenv("ACB_EVOLUTION_PERIOD"); v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil {
|
||
cfg.EvolutionPeriod = d
|
||
}
|
||
}
|
||
// Weekly schedule from env (format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00)
|
||
if v := os.Getenv("ACB_WEEKLY_SCHEDULE"); v != "" {
|
||
var weekday, hour, minute int
|
||
if _, err := fmt.Sscanf(v, "%d:%d:%d", &weekday, &hour, &minute); err == nil {
|
||
if weekday >= 0 && weekday <= 6 && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||
cfg.WeeklySchedule.Weekday = time.Weekday(weekday)
|
||
cfg.WeeklySchedule.Hour = hour
|
||
cfg.WeeklySchedule.Minute = minute
|
||
}
|
||
}
|
||
}
|
||
|
||
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 "--min-seed-count":
|
||
if i+1 < len(os.Args[1:]) {
|
||
fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.MinSeedCount)
|
||
}
|
||
case "--evolution-period":
|
||
if i+1 < len(os.Args[1:]) {
|
||
if d, err := time.ParseDuration(os.Args[1:][i+1]); err == nil {
|
||
cfg.EvolutionPeriod = d
|
||
}
|
||
}
|
||
case "--weekly":
|
||
cfg.Weekly = true
|
||
case "--weekly-schedule":
|
||
if i+1 < len(os.Args[1:]) {
|
||
// Parse format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00
|
||
var weekday, hour, minute int
|
||
if _, err := fmt.Sscanf(os.Args[1:][i+1], "%d:%d:%d", &weekday, &hour, &minute); err == nil {
|
||
if weekday >= 0 && weekday <= 6 && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||
cfg.WeeklySchedule.Weekday = time.Weekday(weekday)
|
||
cfg.WeeklySchedule.Hour = hour
|
||
cfg.WeeklySchedule.Minute = minute
|
||
} else {
|
||
log.Printf("Invalid weekly schedule format: %s (expected WEEKDAY:HH:MM, e.g., 0:03:00)", os.Args[1:][i+1])
|
||
}
|
||
}
|
||
}
|
||
case "--dry-run":
|
||
cfg.DryRun = true
|
||
case "--no-smoke":
|
||
cfg.ValidateSmoke = false
|
||
case "--once":
|
||
cfg.Once = true
|
||
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 breed per iteration [default: 5]")
|
||
fmt.Println(" --min-engagement F Minimum engagement threshold for parents [default: 5.0]")
|
||
fmt.Println(" --min-seed-count N Seed this many maps per player count on startup [default: 20]")
|
||
fmt.Println(" --evolution-period D Sleep duration between evolution cycles [default: 30m]")
|
||
fmt.Println(" --weekly Enable weekly automated evolution mode")
|
||
fmt.Println(" --weekly-schedule S Weekly schedule (WEEKDAY:HH:MM, e.g., 0:03:00 for Sunday 03:00 UTC)")
|
||
fmt.Println(" Weekday: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat")
|
||
fmt.Println(" --dry-run Generate maps but don't save to database")
|
||
fmt.Println(" --no-smoke Skip smoke-test validation")
|
||
fmt.Println(" --once Run evolution once for all player counts and exit")
|
||
fmt.Println(" --help Show this help")
|
||
fmt.Println("")
|
||
fmt.Println("Environment variables:")
|
||
fmt.Println(" ACB_DATABASE_URL PostgreSQL connection string")
|
||
fmt.Println(" ACB_MIN_SEED_COUNT Minimum maps to seed per player count [default: 20]")
|
||
fmt.Println(" ACB_EVOLUTION_PERIOD Sleep duration between cycles [default: 30m]")
|
||
fmt.Println(" ACB_WEEKLY_SCHEDULE Weekly schedule (WEEKDAY:HH:MM) [default: 0:03:00]")
|
||
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 for cfg.PlayerCount.
|
||
func (e *MapEvolver) Run(ctx context.Context) ([]*Map, error) {
|
||
// Ensure the table is seeded before attempting to select parents.
|
||
if err := e.seedIfEmpty(ctx, e.cfg.PlayerCount); err != nil {
|
||
return nil, fmt.Errorf("seeding player_count=%d: %w", e.cfg.PlayerCount, err)
|
||
}
|
||
|
||
// 1. Select parent maps
|
||
parents, err := e.selectParents(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("selecting parents: %w", err)
|
||
}
|
||
if len(parents) < 2 {
|
||
log.Printf("player_count=%d: only %d parent maps available, skipping evolution cycle",
|
||
e.cfg.PlayerCount, len(parents))
|
||
return nil, nil
|
||
}
|
||
|
||
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.
|
||
// seedIfEmpty generates MinSeedCount random maps for playerCount if the table
|
||
// has fewer than that many active/probation rows. Idempotent: safe to call on
|
||
// every startup regardless of current state.
|
||
func (e *MapEvolver) seedIfEmpty(ctx context.Context, playerCount int) error {
|
||
var count int
|
||
err := e.db.QueryRowContext(ctx,
|
||
`SELECT count(*) FROM maps WHERE player_count = $1 AND status != 'retired'`,
|
||
playerCount,
|
||
).Scan(&count)
|
||
if err != nil {
|
||
return fmt.Errorf("counting maps: %w", err)
|
||
}
|
||
if count >= e.cfg.MinSeedCount {
|
||
return nil
|
||
}
|
||
|
||
needed := e.cfg.MinSeedCount - count
|
||
log.Printf("seeding %d maps for player_count=%d (have %d, want %d)",
|
||
needed, playerCount, count, e.cfg.MinSeedCount)
|
||
|
||
rows, cols := gridForPlayers(playerCount)
|
||
inserted := 0
|
||
for inserted < needed {
|
||
m := generateMap(playerCount, rows, cols, 0.15, 20, e.rng)
|
||
if m == nil || !e.validate(m) {
|
||
continue
|
||
}
|
||
m.ID = generateMapID(e.rng)
|
||
if err := e.saveMapIdempotent(ctx, m); err != nil {
|
||
log.Printf("warn: failed to seed map: %v", err)
|
||
} else {
|
||
inserted++
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// saveMapIdempotent inserts a map, ignoring conflicts on map_id.
|
||
func (e *MapEvolver) saveMapIdempotent(ctx context.Context, m *Map) error {
|
||
mapJSON, err := json.Marshal(m)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = e.db.ExecContext(ctx, `
|
||
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)
|
||
ON CONFLICT (map_id) DO NOTHING`,
|
||
m.ID, m.Players, m.WallDensity, len(m.EnergyNodes), m.Cols, m.Rows, mapJSON,
|
||
)
|
||
return err
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// gridForPlayers returns default grid dimensions for a given player count.
|
||
// For 2-player matches, uses 40x40 (down from 60x60) to increase encounter frequency.
|
||
// For 3+ players, targets ~2000 tiles per player.
|
||
func gridForPlayers(n int) (rows, cols int) {
|
||
if n <= 2 {
|
||
return 40, 40
|
||
}
|
||
side := int(math.Sqrt(float64(2000 * n)))
|
||
if side < 40 {
|
||
side = 40
|
||
}
|
||
if side > 200 {
|
||
side = 200
|
||
}
|
||
return side, side
|
||
}
|
||
|
||
// generateMap creates a random symmetric map using cellular-automata wall generation.
|
||
// Returns nil if a connected map cannot be produced within maxAttempts tries.
|
||
func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes int, rng *rand.Rand) *Map {
|
||
const maxAttempts = 20
|
||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||
m := generateMapOnce(numPlayers, rows, cols, wallDensity, numEnergyNodes, rng)
|
||
if m != nil {
|
||
return m
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func generateMapOnce(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes int, rng *rand.Rand) *Map {
|
||
m := &Map{
|
||
Players: numPlayers,
|
||
Rows: rows,
|
||
Cols: cols,
|
||
WallDensity: wallDensity,
|
||
Walls: make([]Position, 0),
|
||
Cores: make([]Core, 0),
|
||
EnergyNodes: make([]Position, 0),
|
||
}
|
||
|
||
wrap := func(r, c int) Position {
|
||
return Position{Row: ((r % rows) + rows) % rows, Col: ((c % cols) + cols) % cols}
|
||
}
|
||
|
||
centerRow, centerCol := rows/2, cols/2
|
||
|
||
// Place cores with rotational symmetry.
|
||
for p := 0; p < numPlayers; p++ {
|
||
angle := float64(p) * 2.0 * math.Pi / float64(numPlayers)
|
||
r := centerRow + int(float64(centerRow)*0.35*math.Cos(angle))
|
||
c := centerCol + int(float64(centerCol)*0.35*math.Sin(angle))
|
||
m.Cores = append(m.Cores, Core{Position: wrap(r, c), Owner: p})
|
||
}
|
||
|
||
// Place energy nodes with rotational symmetry.
|
||
used := make(PositionSet)
|
||
for _, core := range m.Cores {
|
||
used[core.Position] = true
|
||
}
|
||
nodesPerSector := numEnergyNodes / numPlayers
|
||
for i := 0; i < nodesPerSector; i++ {
|
||
for attempt := 0; attempt < 100; attempt++ {
|
||
angle := rng.Float64() * 2.0 * math.Pi / float64(numPlayers)
|
||
// Tiered radius: bias toward center to force contested energy collection.
|
||
// 30% central (forces both players to midfield), 40% mid, 30% home.
|
||
var radius float64
|
||
switch {
|
||
case i < nodesPerSector*3/10:
|
||
radius = 0.05 + rng.Float64()*0.15 // 0.05–0.20: contested central zone
|
||
case i < nodesPerSector*7/10:
|
||
radius = 0.20 + rng.Float64()*0.20 // 0.20–0.40: mid-zone
|
||
default:
|
||
radius = 0.40 + rng.Float64()*0.20 // 0.40–0.60: home zone
|
||
}
|
||
r := centerRow + int(float64(centerRow)*radius*math.Cos(angle))
|
||
c := centerCol + int(float64(centerCol)*radius*math.Sin(angle))
|
||
pos := wrap(r, c)
|
||
if used[pos] {
|
||
continue
|
||
}
|
||
used[pos] = true
|
||
for p := 0; p < numPlayers; p++ {
|
||
rotAngle := angle + float64(p)*2.0*math.Pi/float64(numPlayers)
|
||
rr := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle))
|
||
rc := centerCol + int(float64(centerCol)*radius*math.Sin(rotAngle))
|
||
m.EnergyNodes = append(m.EnergyNodes, wrap(rr, rc))
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
// Build protected set around cores and energy nodes.
|
||
protected := make(PositionSet)
|
||
for _, core := range m.Cores {
|
||
for dr := -3; dr <= 3; dr++ {
|
||
for dc := -3; dc <= 3; dc++ {
|
||
protected[wrap(core.Position.Row+dr, core.Position.Col+dc)] = true
|
||
}
|
||
}
|
||
}
|
||
for _, en := range m.EnergyNodes {
|
||
for dr := -1; dr <= 1; dr++ {
|
||
for dc := -1; dc <= 1; dc++ {
|
||
protected[wrap(en.Row+dr, en.Col+dc)] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Seed grid at ~40% random fill.
|
||
grid := make([][]bool, rows)
|
||
for r := 0; r < rows; r++ {
|
||
grid[r] = make([]bool, cols)
|
||
for c := 0; c < cols; c++ {
|
||
if !protected[Position{Row: r, Col: c}] && rng.Float64() < 0.40 {
|
||
grid[r][c] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cellular automata smoothing (4 iterations).
|
||
for iter := 0; iter < 4; iter++ {
|
||
next := make([][]bool, rows)
|
||
for r := 0; r < rows; r++ {
|
||
next[r] = make([]bool, cols)
|
||
for c := 0; c < cols; c++ {
|
||
if protected[Position{Row: r, Col: c}] {
|
||
continue
|
||
}
|
||
n := 0
|
||
for nr := -1; nr <= 1; nr++ {
|
||
for nc := -1; nc <= 1; nc++ {
|
||
if nr == 0 && nc == 0 {
|
||
continue
|
||
}
|
||
rr := ((r+nr)%rows + rows) % rows
|
||
cc := ((c+nc)%cols + cols) % cols
|
||
if grid[rr][cc] {
|
||
n++
|
||
}
|
||
}
|
||
}
|
||
if grid[r][c] {
|
||
next[r][c] = n >= 4
|
||
} else {
|
||
next[r][c] = n >= 5
|
||
}
|
||
}
|
||
}
|
||
grid = next
|
||
}
|
||
|
||
// Enforce rotational symmetry from sector 0.
|
||
sectorAngle := 2.0 * math.Pi / float64(numPlayers)
|
||
for r := 0; r < rows; r++ {
|
||
for c := 0; c < cols; c++ {
|
||
if protected[Position{Row: r, Col: c}] {
|
||
continue
|
||
}
|
||
dr := float64(r - centerRow)
|
||
dc := float64(c - centerCol)
|
||
angle := math.Atan2(dc, dr)
|
||
if angle < 0 {
|
||
angle += 2.0 * math.Pi
|
||
}
|
||
sector := int(angle / sectorAngle)
|
||
if sector >= numPlayers {
|
||
sector = numPlayers - 1
|
||
}
|
||
if sector != 0 {
|
||
rotAngle := -float64(sector) * sectorAngle
|
||
cosA, sinA := math.Cos(rotAngle), math.Sin(rotAngle)
|
||
srcR := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA))
|
||
srcC := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA))
|
||
sr := ((srcR % rows) + rows) % rows
|
||
sc := ((srcC % cols) + cols) % cols
|
||
grid[r][c] = grid[sr][sc]
|
||
}
|
||
}
|
||
}
|
||
|
||
// Carve corridors from each core to the map center.
|
||
// Creates 3-wide lanes that funnel bots into contact at midfield.
|
||
for _, core := range m.Cores {
|
||
carveCorridor(grid, core.Position.Row, core.Position.Col, centerRow, centerCol, rows, cols)
|
||
}
|
||
// Open a 5×5 arena at the center so all corridors connect.
|
||
for dr := -2; dr <= 2; dr++ {
|
||
for dc := -2; dc <= 2; dc++ {
|
||
rr := ((centerRow+dr)%rows + rows) % rows
|
||
cc := ((centerCol+dc)%cols + cols) % cols
|
||
grid[rr][cc] = false
|
||
}
|
||
}
|
||
|
||
// Collect wall positions and thin to target density.
|
||
totalTiles := rows * cols
|
||
targetWalls := int(float64(totalTiles) * wallDensity)
|
||
var walls []Position
|
||
for r := 0; r < rows; r++ {
|
||
for c := 0; c < cols; c++ {
|
||
if grid[r][c] {
|
||
walls = append(walls, Position{Row: r, Col: c})
|
||
}
|
||
}
|
||
}
|
||
if len(walls) > targetWalls {
|
||
rng.Shuffle(len(walls), func(i, j int) { walls[i], walls[j] = walls[j], walls[i] })
|
||
walls = walls[:targetWalls]
|
||
}
|
||
m.Walls = walls
|
||
|
||
return m
|
||
}
|
||
|
||
// carveCorridor opens a 3-wide path from (r0,c0) to (r1,c1) using integer stepping.
|
||
// Perpendicular width is 1 tile on each side of the center line.
|
||
func carveCorridor(grid [][]bool, r0, c0, r1, c1, rows, cols int) {
|
||
dr := r1 - r0
|
||
dc := c1 - c0
|
||
steps := dr
|
||
if steps < 0 {
|
||
steps = -steps
|
||
}
|
||
if dc < 0 && -dc > steps {
|
||
steps = -dc
|
||
} else if dc > steps {
|
||
steps = dc
|
||
}
|
||
if steps == 0 {
|
||
return
|
||
}
|
||
horizontal := dc < 0 && -dc > (dr+1) || dc > 0 && dc > (dr+1) // wider in col direction
|
||
if dr < 0 {
|
||
dr = -dr
|
||
}
|
||
// recompute: use originals
|
||
origDR := r1 - r0
|
||
origDC := c1 - c0
|
||
for step := 0; step <= steps; step++ {
|
||
r := r0 + origDR*step/steps
|
||
c := c0 + origDC*step/steps
|
||
// Widen perpendicular to primary movement direction
|
||
if !horizontal {
|
||
// Mostly vertical: widen horizontally
|
||
for wc := -1; wc <= 1; wc++ {
|
||
rr := ((r)%rows + rows) % rows
|
||
cc := ((c+wc)%cols + cols) % cols
|
||
grid[rr][cc] = false
|
||
}
|
||
} else {
|
||
// Mostly horizontal: widen vertically
|
||
for wr := -1; wr <= 1; wr++ {
|
||
rr := ((r+wr)%rows + rows) % rows
|
||
cc := ((c)%cols + cols) % cols
|
||
grid[rr][cc] = false
|
||
}
|
||
}
|
||
}
|
||
_ = dr // suppress unused warning
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// runWeeklyLoop runs map evolution on a weekly schedule.
|
||
// It waits until the next scheduled time (weekday:hour:minute UTC), then runs
|
||
// evolution for all player counts, and repeats every 7 days.
|
||
func runWeeklyLoop(evolver *MapEvolver, cfg *Config) {
|
||
schedule := cfg.WeeklySchedule
|
||
|
||
// Calculate first scheduled run time
|
||
nextRun := nextScheduledTime(schedule)
|
||
log.Printf("map-evolver: first weekly run scheduled for %s (in %v)",
|
||
nextRun.Format(time.RFC3339), time.Until(nextRun).Round(time.Second))
|
||
|
||
for {
|
||
// Sleep until the scheduled time
|
||
waitDuration := time.Until(nextRun)
|
||
if waitDuration > 0 {
|
||
log.Printf("map-evolver: sleeping %v until next scheduled run at %s",
|
||
waitDuration.Round(time.Second), nextRun.Format(time.RFC3339))
|
||
time.Sleep(waitDuration)
|
||
}
|
||
|
||
// Run evolution for all player counts
|
||
log.Printf("map-evolver: starting weekly evolution run for all player counts")
|
||
totalCreated := 0
|
||
for _, pc := range allPlayerCounts {
|
||
evolver.cfg.PlayerCount = pc
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||
results, err := evolver.Run(ctx)
|
||
cancel()
|
||
|
||
if err != nil {
|
||
log.Printf("map-evolver: player_count=%d error: %v", pc, err)
|
||
continue
|
||
}
|
||
|
||
log.Printf("map-evolver: player_count=%d: %d new maps created", pc, len(results))
|
||
totalCreated += len(results)
|
||
}
|
||
log.Printf("map-evolver: weekly evolution run complete, %d total maps created", totalCreated)
|
||
|
||
// Calculate next scheduled run (7 days later)
|
||
nextRun = nextRun.Add(7 * 24 * time.Hour)
|
||
log.Printf("map-evolver: next weekly run scheduled for %s",
|
||
nextRun.Format(time.RFC3339))
|
||
}
|
||
}
|
||
|
||
// nextScheduledTime calculates the next occurrence of the weekly schedule.
|
||
// If the current time is before the scheduled time today, it returns today's time.
|
||
// If the current time is after the scheduled time today, it returns next week's time.
|
||
func nextScheduledTime(schedule WeeklySchedule) time.Time {
|
||
now := time.Now().UTC()
|
||
|
||
// Start with today at the scheduled time
|
||
scheduled := time.Date(now.Year(), now.Month(), now.Day(),
|
||
schedule.Hour, schedule.Minute, 0, 0, time.UTC)
|
||
|
||
// Check if we're on the correct weekday
|
||
daysUntil := int(schedule.Weekday) - int(now.Weekday())
|
||
if daysUntil < 0 {
|
||
daysUntil += 7
|
||
}
|
||
|
||
// Add the days until the scheduled weekday
|
||
scheduled = scheduled.AddDate(0, 0, daysUntil)
|
||
|
||
// If the scheduled time has already passed today, move to next week
|
||
if scheduled.Before(now) || scheduled.Equal(now) {
|
||
scheduled = scheduled.Add(7 * 24 * time.Hour)
|
||
}
|
||
|
||
return scheduled
|
||
}
|