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:
parent
df7a3e38c7
commit
9972cb8c84
4 changed files with 218 additions and 32 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue