From b31c306013e5d99cdf1224c14c7ff887139f187d Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 8 May 2026 09:15:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(acb-map-evolver):=20add=20weekly=20automat?= =?UTF-8?q?ed=20run=20wiring=20per=20plan=20=C2=A714.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/acb-evolver/run.go | 141 ++++++++++++++++++++++++++++++++++++ cmd/acb-map-evolver/main.go | 131 +++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/cmd/acb-evolver/run.go b/cmd/acb-evolver/run.go index bfc52d6..cbb9c40 100644 --- a/cmd/acb-evolver/run.go +++ b/cmd/acb-evolver/run.go @@ -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) diff --git a/cmd/acb-map-evolver/main.go b/cmd/acb-map-evolver/main.go index ff12a6d..08f2a22 100644 --- a/cmd/acb-map-evolver/main.go +++ b/cmd/acb-map-evolver/main.go @@ -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 +}