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:
parent
01da007045
commit
b31c306013
2 changed files with 272 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue