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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-04 02:34:15 -04:00
parent df7a3e38c7
commit 9972cb8c84
4 changed files with 218 additions and 32 deletions

View file

@ -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)

View file

@ -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),
}
}

View file

@ -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)
}

View file

@ -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)
}