From 9972cb8c841386dfa9648c189e613c977047edda Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 4 May 2026 02:34:15 -0400 Subject: [PATCH] feat(matchmaker): add best-of-5 weekly featured and best-of-7 championship series scheduling - Add 'featured' boolean column to series table for weekly featured series - Add tickFeaturedSeries ticker that runs Friday 20:00 UTC to create bo5 featured series - Featured series: query top 20 bots by rating, select 4 rivalry pairs by ELO proximity - Best-of-7 championship bracket already implemented via createChampionshipBracket - Add FeaturedSchedSecs config (default: 3600s check interval) Co-Authored-By: Claude Opus 4.7 --- cmd/acb-api/db.go | 5 + cmd/acb-matchmaker/main.go | 66 ++++++----- cmd/acb-matchmaker/series_season.go | 178 ++++++++++++++++++++++++++++ cmd/acb-matchmaker/tickers.go | 1 + 4 files changed, 218 insertions(+), 32 deletions(-) diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index 5d5ed30..fc430f6 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS series ( season_id BIGINT REFERENCES seasons(id), bracket_round VARCHAR(32), -- 'quarterfinal', 'semifinal', 'final' for championship bracket_position INTEGER, -- position within the bracket round (0-based) + featured BOOLEAN NOT NULL DEFAULT FALSE, -- weekly featured series (Friday 20:00 UTC) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -50,6 +51,7 @@ CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id); CREATE INDEX IF NOT EXISTS idx_series_status ON series(status); CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id); CREATE INDEX IF NOT EXISTS idx_series_bracket ON series(season_id, bracket_round) WHERE bracket_round IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_series_featured ON series(featured, created_at DESC) WHERE featured = TRUE; -- Add bracket columns if they don't exist (idempotent migration) DO $$ BEGIN @@ -59,6 +61,9 @@ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_position') THEN ALTER TABLE series ADD COLUMN bracket_position INTEGER; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'featured') THEN + ALTER TABLE series ADD COLUMN featured BOOLEAN NOT NULL DEFAULT FALSE; + END IF; END $$; -- Add missing foreign key constraints (CREATE TABLE IF NOT EXISTS doesn't add FKs to existing tables) diff --git a/cmd/acb-matchmaker/main.go b/cmd/acb-matchmaker/main.go index e3e699d..5a0e7ca 100644 --- a/cmd/acb-matchmaker/main.go +++ b/cmd/acb-matchmaker/main.go @@ -19,22 +19,23 @@ import ( ) type Config struct { - DatabaseURL string - ValkeyAddr string - ValkeyPassword string - EncryptionKey string // AES-256-GCM key for shared secret decryption - DiscordWebhook string - SlackWebhook string - MatchmakerSecs int - HealthCheckSecs int - ReaperSecs int - SeriesSchedSecs int - SeasonResetSecs int - FairnessAuditSecs int - BotTimeoutSecs int - StaleJobMinutes int - MaxConsecFails int - SeasonDecayFactor float64 + DatabaseURL string + ValkeyAddr string + ValkeyPassword string + EncryptionKey string // AES-256-GCM key for shared secret decryption + DiscordWebhook string + SlackWebhook string + MatchmakerSecs int + HealthCheckSecs int + ReaperSecs int + SeriesSchedSecs int + SeasonResetSecs int + FairnessAuditSecs int + FeaturedSchedSecs int // featured series check interval (Friday 20:00 UTC) + BotTimeoutSecs int + StaleJobMinutes int + MaxConsecFails int + SeasonDecayFactor float64 } type Matchmaker struct { @@ -46,22 +47,23 @@ type Matchmaker struct { func loadConfig() Config { return Config{ - DatabaseURL: envOr("ACB_DATABASE_URL", "postgres://localhost:5432/acb?sslmode=disable"), - ValkeyAddr: envOr("ACB_VALKEY_ADDR", "localhost:6379"), - ValkeyPassword: os.Getenv("ACB_VALKEY_PASSWORD"), - EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"), - DiscordWebhook: os.Getenv("ACB_DISCORD_WEBHOOK"), - SlackWebhook: os.Getenv("ACB_SLACK_WEBHOOK"), - MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60), - HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900), - ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300), - SeriesSchedSecs: envInt("ACB_SERIES_SCHED_INTERVAL", 120), - SeasonResetSecs: envInt("ACB_SEASON_RESET_INTERVAL", 300), - FairnessAuditSecs: envInt("ACB_FAIRNESS_AUDIT_INTERVAL", 3600), - BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5), - StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15), - MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3), - SeasonDecayFactor: envFloat("ACB_SEASON_DECAY_FACTOR", 0.7), + DatabaseURL: envOr("ACB_DATABASE_URL", "postgres://localhost:5432/acb?sslmode=disable"), + ValkeyAddr: envOr("ACB_VALKEY_ADDR", "localhost:6379"), + ValkeyPassword: os.Getenv("ACB_VALKEY_PASSWORD"), + EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"), + DiscordWebhook: os.Getenv("ACB_DISCORD_WEBHOOK"), + SlackWebhook: os.Getenv("ACB_SLACK_WEBHOOK"), + MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60), + HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900), + ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300), + SeriesSchedSecs: envInt("ACB_SERIES_SCHED_INTERVAL", 120), + SeasonResetSecs: envInt("ACB_SEASON_RESET_INTERVAL", 300), + FairnessAuditSecs: envInt("ACB_FAIRNESS_AUDIT_INTERVAL", 3600), + FeaturedSchedSecs: envInt("ACB_FEATURED_SCHED_INTERVAL", 3600), // check hourly + BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5), + StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15), + MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3), + SeasonDecayFactor: envFloat("ACB_SEASON_DECAY_FACTOR", 0.7), } } diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index b024e58..8cef425 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "log" + "math" "math/rand" + "sort" "time" ) @@ -972,3 +974,179 @@ func (m *Matchmaker) createChampionshipBracket(ctx context.Context, seasonID int return nil } + +// tickFeaturedSeries creates best-of-5 weekly featured series on Friday at 20:00 UTC. +// It selects top 20 bots by rating and creates 4 rivalry pairs by ELO proximity. +// Plan ยง14.7: weekly featured matches between top-ranked bot rivalries. +func (m *Matchmaker) tickFeaturedSeries(ctx context.Context) { + // Check if current time is Friday at 20:00 UTC (within a 1-hour window) + now := time.Now().UTC() + if now.Weekday() != time.Friday { + return + } + hour := now.Hour() + if hour < 20 || hour >= 21 { + return // Only run during the 20:00-20:59 UTC window + } + + // Check if featured series were already created this week + var thisWeekCount int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM series + WHERE featured = TRUE + AND created_at >= date_trunc('week', NOW()) + INTERVAL '4 days' + INTERVAL '20 hours' + AND created_at < date_trunc('week', NOW()) + INTERVAL '4 days' + INTERVAL '21 hours' + `).Scan(&thisWeekCount) + if err != nil { + log.Printf("featured-series: check existing error: %v", err) + return + } + if thisWeekCount > 0 { + return // Already created featured series this Friday + } + + // Query top 20 active bots by rating (excluding crash-cooldown bots) + rows, err := m.db.QueryContext(ctx, ` + SELECT bot_id, rating_mu FROM bots + WHERE status = 'active' + AND (cooldown_until IS NULL OR cooldown_until < NOW()) + ORDER BY rating_mu DESC + LIMIT 20 + `) + if err != nil { + log.Printf("featured-series: query top bots error: %v", err) + return + } + defer rows.Close() + + type botRating struct { + ID string + Rating float64 + } + var topBots []botRating + for rows.Next() { + var br botRating + if err := rows.Scan(&br.ID, &br.Rating); err != nil { + log.Printf("featured-series: scan bot error: %v", err) + continue + } + topBots = append(topBots, br) + } + + if len(topBots) < 8 { + log.Printf("featured-series: not enough active bots (%d), need at least 8", len(topBots)) + return + } + + // Select 4 rivalry pairs by ELO proximity + // Sort bots by rating + sort.Slice(topBots, func(i, j int) bool { + return topBots[i].Rating > topBots[j].Rating + }) + + // Create pairs by adjacent ratings (closest ELO proximity) + // Pairing: #1-#2, #3-#4, #5-#6, #7-#8 (top rivalries) + // Or use a more sophisticated pairing based on historical match count + type botPair struct { + A string + B string + } + var pairs []botPair + + // Try to find actual rivalries first (bots that have played each other multiple times) + for i := 0; i < len(topBots)-1; i++ { + if len(pairs) >= 4 { + break + } + for j := i + 1; j < len(topBots); j++ { + // Check if these bots have a rivalry history (3+ matches) + var matchCount int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM match_participants mp1 + JOIN match_participants mp2 ON mp1.match_id = mp2.match_id + WHERE mp1.bot_id = $1 AND mp2.bot_id = $2 + `, topBots[i].ID, topBots[j].ID).Scan(&matchCount) + if err == nil && matchCount >= 3 { + pairs = append(pairs, botPair{A: topBots[i].ID, B: topBots[j].ID}) + // Mark these as used + topBots[i].ID = "" + topBots[j].ID = "" + break + } + } + } + + // Fill remaining slots with closest ELO pairs from remaining bots + remaining := make([]botRating, 0) + for _, br := range topBots { + if br.ID != "" { + remaining = append(remaining, br) + } + } + + // Pair adjacent bots by rating (closest ELO) + for i := 0; i < len(remaining)-1 && len(pairs) < 4; i += 2 { + if i+1 < len(remaining) { + pairs = append(pairs, botPair{A: remaining[i].ID, B: remaining[i+1].ID}) + } + } + + // Ensure we have exactly 4 pairs + if len(pairs) > 4 { + pairs = pairs[:4] + } else if len(pairs) < 4 { + // Fill with remaining adjacent pairs + for i := len(pairs); i < 4 && i+1 < len(remaining); i++ { + pairs = append(pairs, botPair{A: remaining[i].ID, B: remaining[i+1].ID}) + } + } + + rng := rand.New(rand.NewSource(now.UnixNano())) + + // Get the active season ID (if any) + var seasonID sql.NullInt64 + m.db.QueryRowContext(ctx, + `SELECT id FROM seasons WHERE status = 'active' ORDER BY starts_at DESC LIMIT 1`).Scan(&seasonID) + + // Create bo5 featured series for each pair + for i, pair := range pairs { + if pair.A == "" || pair.B == "" { + continue + } + + // Randomize who is bot_a vs bot_b + botAID, botBID := pair.A, pair.B + if rng.Intn(2) == 0 { + botAID, botBID = pair.B, pair.A + } + + _, err := m.db.ExecContext(ctx, ` + INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, featured, updated_at) + VALUES ($1, $2, 5, 'active', 0, 0, $3, TRUE, NOW()) + `, botAID, botBID, seasonID) + if err != nil { + log.Printf("featured-series: failed to create series %d (%s vs %s): %v", i+1, botAID, botBID, err) + continue + } + log.Printf("featured-series: created weekly featured bo5 series %d: %s vs %s", i+1, botAID, botBID) + } + + log.Printf("featured-series: created %d weekly featured bo5 series for Friday %s", len(pairs), now.Format("2006-01-02")) +} + +// isFriday20UTC checks if the given time is within the Friday 20:00 UTC window. +func isFriday20UTC(t time.Time) bool { + // Convert to UTC + utc := t.UTC() + // Check if it's Friday (weekday 5) + if utc.Weekday() != time.Friday { + return false + } + // Check if hour is 20 (8 PM UTC) + return utc.Hour() == 20 +} + +// ratingDistance returns the absolute difference in ratings between two bots. +func ratingDistance(r1, r2 float64) float64 { + return math.Abs(r1 - r2) +} diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index 927b220..57726cc 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -35,6 +35,7 @@ func (m *Matchmaker) StartTickers(ctx context.Context) { go m.runTicker(ctx, "stale-reaper", time.Duration(m.cfg.ReaperSecs)*time.Second, m.tickStaleReaper) go m.runTicker(ctx, "series-scheduler", time.Duration(m.cfg.SeriesSchedSecs)*time.Second, m.tickSeriesScheduler) go m.runTicker(ctx, "season-reset", time.Duration(m.cfg.SeasonResetSecs)*time.Second, m.tickSeasonReset) + go m.runTicker(ctx, "featured-series", time.Duration(m.cfg.FeaturedSchedSecs)*time.Second, m.tickFeaturedSeries) go m.runTicker(ctx, "fairness-audit", time.Duration(m.cfg.FairnessAuditSecs)*time.Second, m.tickFairnessAudit) }