feat(acb-map-evolver): add weekly automated run wiring per plan §14.6

- Implement runWeeklyLoop() function that waits for scheduled time and
  runs evolution for all player counts (2, 3, 4, 6) weekly
- Add --weekly flag to enable weekly mode (default: Sunday 03:00 UTC)
- Add --weekly-schedule flag for custom schedule (WEEKDAY:HH:MM format)
- Add ACB_WEEKLY_SCHEDULE env var for configuration

feat(acb-evolver): add weekly map evolution ticker

- Add MapEvolutionEnabled and MapEvolutionSchedule to RunConfig
- Add --enable-map-evolution flag to acb-evolver run subcommand
- Add startMapEvolutionTicker() goroutine that runs weekly
- Ticker executes acb-map-evolver --once to trigger map breeding
- Configurable via ACB_MAP_EVOLUTION_ENABLED and ACB_MAP_EVOLUTION_SCHEDULE

This integrates map evolution into the bot evolver's deployment,
allowing weekly automated map evolution based on engagement scores
as specified in plan §14.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-08 09:15:00 -04:00
parent 01da007045
commit b31c306013
2 changed files with 272 additions and 0 deletions

View file

@ -24,6 +24,7 @@ import (
"log"
"math/rand"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
@ -86,6 +87,21 @@ type RunConfig struct {
// PagesBaseURL is the Cloudflare Pages base URL for reading static indexes
// such as community_hints.json. Empty disables community hint loading.
PagesBaseURL string
// Map evolution ticker (§14.6)
MapEvolutionEnabled bool // whether to trigger weekly map evolution
MapEvolutionSchedule WeeklySchedule // when to run map evolution
}
// 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
// PagesBaseURL is the Cloudflare Pages base URL for reading static indexes
// such as community_hints.json. Empty disables community hint loading.
PagesBaseURL string
}
// DefaultRunConfig returns production-ready defaults.
@ -113,6 +129,12 @@ func DefaultRunConfig() RunConfig {
DeclarativeConfigRepo: envOrDefault("ACB_DECLARATIVE_CONFIG_REPO", "https://forgejo.ardenone.com/infra/ardenone-cluster.git"),
DeclarativeConfigBranch: envOrDefault("ACB_DECLARATIVE_CONFIG_BRANCH", "main"),
Languages: []string{"go", "python", "rust", "typescript", "java", "php"},
MapEvolutionEnabled: envOrDefault("ACB_MAP_EVOLUTION_ENABLED", "false") == "true",
MapEvolutionSchedule: WeeklySchedule{
Weekday: time.Sunday, // Default: Sunday 03:00 UTC
Hour: 3,
Minute: 0,
},
}
}
@ -142,6 +164,7 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) {
verbose := fs.Bool("v", false, "verbose output")
dryRun := fs.Bool("dry-run", false, "simulate without deploying")
maxCycles := fs.Int("max-cycles", 0, "stop after N cycles (0 = unlimited)")
enableMapEvolution := fs.Bool("enable-map-evolution", false, "enable weekly map evolution ticker")
if err := fs.Parse(args); err != nil {
os.Exit(1)
@ -171,6 +194,21 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) {
if *singleLang != "" {
cfg.Languages = []string{*singleLang}
}
if *enableMapEvolution {
cfg.MapEvolutionEnabled = true
}
// Parse weekly schedule from env (format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00)
if v := os.Getenv("ACB_MAP_EVOLUTION_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.MapEvolutionSchedule.Weekday = time.Weekday(weekday)
cfg.MapEvolutionSchedule.Hour = hour
cfg.MapEvolutionSchedule.Minute = minute
}
}
}
// Track last evolution time per island for cooldown
lastEvolved := make(map[string]time.Time)
@ -204,6 +242,11 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) {
go startRetirementTicker(ctx, db, store, cfg, &stats, *verbose)
}
// Start weekly map evolution ticker (§14.6)
if cfg.MapEvolutionEnabled {
go startMapEvolutionTicker(ctx, db, cfg, *verbose)
}
langIdx := 0
islandIdx := 0
@ -788,6 +831,104 @@ func startRetirementTicker(ctx context.Context, db *sql.DB, store *evolverdb.Sto
}
}
// startMapEvolutionTicker runs weekly map evolution (§14.6).
// This triggers the acb-map-evolver to evolve maps based on engagement scores.
func startMapEvolutionTicker(ctx context.Context, db *sql.DB, cfg RunConfig, verbose bool) {
schedule := cfg.MapEvolutionSchedule
log.Printf("starting map evolution ticker (schedule: %s %02d:%02d UTC)",
schedule.Weekday, schedule.Hour, schedule.Minute)
// Calculate first scheduled run time
nextRun := nextMapEvolutionTime(schedule)
log.Printf("map evolution: first 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 {
select {
case <-ctx.Done():
log.Printf("stopping map evolution ticker")
return
case <-time.After(waitDuration):
}
}
// Run map evolution
log.Printf("map evolution: starting weekly map evolution run")
if err := runMapEvolution(ctx, db, verbose); err != nil {
log.Printf("map evolution: error: %v", err)
} else {
log.Printf("map evolution: weekly run complete")
}
// Calculate next scheduled run (7 days later)
nextRun = nextRun.Add(7 * 24 * time.Hour)
log.Printf("map evolution: next run scheduled for %s",
nextRun.Format(time.RFC3339))
// Check for cancellation before sleeping again
select {
case <-ctx.Done():
log.Printf("stopping map evolution ticker")
return
default:
}
}
}
// nextMapEvolutionTime calculates the next occurrence of the map evolution schedule.
func nextMapEvolutionTime(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
}
// runMapEvolution executes the map evolution by running the acb-map-evolver binary
// with the --once flag to trigger a single evolution run for all player counts.
func runMapEvolution(ctx context.Context, db *sql.DB, verbose bool) error {
// Check if acb-map-evolver binary is available
cmd := exec.CommandContext(ctx, "acb-map-evolver", "--once")
cmd.Env = append(os.Environ(),
fmt.Sprintf("ACB_DATABASE_URL=%s", os.Getenv("ACB_DATABASE_URL")),
)
if verbose {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("map evolution: executing acb-map-evolver --once")
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("acb-map-evolver failed: %w, output: %s", err, string(output))
}
if verbose && len(output) > 0 {
log.Printf("map evolution: %s", string(output))
}
return nil
}
// printStats displays evolution loop statistics.
func printStats(stats *RunStats) {
elapsed := time.Since(stats.StartTime)

View file

@ -29,6 +29,15 @@ type Config struct {
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.
@ -113,6 +122,14 @@ func main() {
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 {
@ -141,6 +158,11 @@ func parseConfig() *Config {
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.
@ -152,6 +174,17 @@ func parseConfig() *Config {
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 {
@ -177,6 +210,22 @@ func parseConfig() *Config {
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":
@ -192,10 +241,19 @@ func parseConfig() *Config {
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
}
}
@ -1257,3 +1315,76 @@ func generateMapID(rng *rand.Rand) string {
}
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
}