Tab/space alignment consistency from running gofmt on all packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1152 lines
35 KiB
Go
1152 lines
35 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"math/rand"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// tickSeriesScheduler schedules remaining games for active series.
|
|
// For each active series with unplayed games, it schedules the next game
|
|
// in round-robin order, feeding the match into the job queue.
|
|
// It also marks series as completed when a bot reaches the winning threshold.
|
|
func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) {
|
|
// 0. Propagate match results to series tables (winner_id, a_wins/b_wins)
|
|
if err := m.updateSeriesGameResults(ctx); err != nil {
|
|
log.Printf("series-scheduler: update results error: %v", err)
|
|
}
|
|
|
|
// 1. Finalize any completed series (check if winner reached threshold)
|
|
if err := m.finalizeCompletedSeries(ctx); err != nil {
|
|
log.Printf("series-scheduler: finalize error: %v", err)
|
|
}
|
|
|
|
// 2. Schedule next game for active series that need one
|
|
if err := m.scheduleNextSeriesGames(ctx); err != nil {
|
|
log.Printf("series-scheduler: schedule error: %v", err)
|
|
}
|
|
|
|
// 3. Auto-create series for top bots (one per bot per day, best-of-5)
|
|
if err := m.autoCreateSeries(ctx); err != nil {
|
|
log.Printf("series-scheduler: auto-create error: %v", err)
|
|
}
|
|
|
|
// 4. Advance championship bracket (semifinals/finals)
|
|
if err := m.advanceChampionshipBracket(ctx); err != nil {
|
|
log.Printf("series-scheduler: bracket advance error: %v", err)
|
|
}
|
|
}
|
|
|
|
// updateSeriesGameResults finds completed series matches that haven't had their
|
|
// winner recorded yet. It updates series_games.winner_id and increments
|
|
// a_wins or b_wins on the series table. Drawn games (m.winner IS NULL) are
|
|
// marked so the series can continue to the next game.
|
|
func (m *Matchmaker) updateSeriesGameResults(ctx context.Context) error {
|
|
// First, handle draws: completed matches with no winner
|
|
_, err := m.db.ExecContext(ctx, `
|
|
UPDATE series_games SET winner_id = 'draw'
|
|
FROM matches m
|
|
WHERE series_games.match_id = m.match_id
|
|
AND series_games.winner_id IS NULL
|
|
AND m.status = 'completed'
|
|
AND m.winner IS NULL
|
|
`)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to process drawn games: %v", err)
|
|
}
|
|
|
|
// Then, process games with a winner
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT sg.series_id, sg.game_num, sg.match_id, m.winner
|
|
FROM series_games sg
|
|
JOIN matches m ON sg.match_id = m.match_id
|
|
WHERE sg.winner_id IS NULL
|
|
AND m.status = 'completed'
|
|
AND m.winner IS NOT NULL
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query completed series games: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type pendingUpdate struct {
|
|
SeriesID int64
|
|
GameNum int
|
|
MatchID string
|
|
Winner int
|
|
}
|
|
var updates []pendingUpdate
|
|
|
|
for rows.Next() {
|
|
var u pendingUpdate
|
|
if err := rows.Scan(&u.SeriesID, &u.GameNum, &u.MatchID, &u.Winner); err != nil {
|
|
return fmt.Errorf("scan series game: %w", err)
|
|
}
|
|
updates = append(updates, u)
|
|
}
|
|
|
|
for _, u := range updates {
|
|
var winnerBotID string
|
|
err := m.db.QueryRowContext(ctx, `
|
|
SELECT bot_id FROM match_participants
|
|
WHERE match_id = $1 AND player_slot = $2
|
|
`, u.MatchID, u.Winner).Scan(&winnerBotID)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: could not find winner bot for match %s slot %d: %v", u.MatchID, u.Winner, err)
|
|
continue
|
|
}
|
|
|
|
var botAID string
|
|
err = m.db.QueryRowContext(ctx, `SELECT bot_a_id FROM series WHERE id = $1`, u.SeriesID).Scan(&botAID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, err = m.db.ExecContext(ctx, `
|
|
UPDATE series_games SET winner_id = $1
|
|
WHERE series_id = $2 AND game_num = $3
|
|
`, winnerBotID, u.SeriesID, u.GameNum)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to update series_game winner: %v", err)
|
|
continue
|
|
}
|
|
|
|
if winnerBotID == botAID {
|
|
_, err = m.db.ExecContext(ctx, `
|
|
UPDATE series SET a_wins = a_wins + 1, updated_at = NOW() WHERE id = $1
|
|
`, u.SeriesID)
|
|
} else {
|
|
_, err = m.db.ExecContext(ctx, `
|
|
UPDATE series SET b_wins = b_wins + 1, updated_at = NOW() WHERE id = $1
|
|
`, u.SeriesID)
|
|
}
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to increment wins for series %d: %v", u.SeriesID, err)
|
|
continue
|
|
}
|
|
|
|
log.Printf("series-scheduler: series %d game %d result recorded — winner=%s", u.SeriesID, u.GameNum, winnerBotID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// finalizeCompletedSeries checks active series where one bot has already won enough games.
|
|
func (m *Matchmaker) finalizeCompletedSeries(ctx context.Context) error {
|
|
// Find active series where a_wins or b_wins >= ceil(format/2)
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins
|
|
FROM series
|
|
WHERE status = 'active'
|
|
AND (a_wins >= ((format + 1) / 2) OR b_wins >= ((format + 1) / 2))
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query completed series: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type completedSeries struct {
|
|
ID int64
|
|
BotAID string
|
|
BotBID string
|
|
Format int
|
|
AWins int
|
|
BWins int
|
|
}
|
|
var completed []completedSeries
|
|
|
|
for rows.Next() {
|
|
var s completedSeries
|
|
if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins); err != nil {
|
|
return fmt.Errorf("scan series: %w", err)
|
|
}
|
|
completed = append(completed, s)
|
|
}
|
|
|
|
for _, s := range completed {
|
|
winsNeeded := (s.Format + 1) / 2
|
|
var winnerID string
|
|
if s.AWins >= winsNeeded {
|
|
winnerID = s.BotAID
|
|
} else {
|
|
winnerID = s.BotBID
|
|
}
|
|
|
|
_, err := m.db.ExecContext(ctx, `
|
|
UPDATE series
|
|
SET status = 'completed', winner_id = $1, updated_at = NOW()
|
|
WHERE id = $2 AND status = 'active'
|
|
`, winnerID, s.ID)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to finalize series %d: %v", s.ID, err)
|
|
continue
|
|
}
|
|
log.Printf("series-scheduler: finalized series %d, winner=%s (%d-%d)", s.ID, winnerID, s.AWins, s.BWins)
|
|
}
|
|
|
|
// Also finalize series where all games are played but neither side reached
|
|
// the threshold (can happen with draws). Winner is whoever has more wins,
|
|
// or NULL if equal.
|
|
allPlayed, err := m.db.QueryContext(ctx, `
|
|
SELECT s.id, s.bot_a_id, s.bot_b_id, s.a_wins, s.b_wins, s.format
|
|
FROM series s
|
|
WHERE s.status = 'active'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM series_games sg
|
|
WHERE sg.series_id = s.id AND sg.winner_id IS NULL
|
|
)
|
|
AND EXISTS (SELECT 1 FROM series_games sg WHERE sg.series_id = s.id)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query all-played series: %w", err)
|
|
}
|
|
defer allPlayed.Close()
|
|
|
|
for allPlayed.Next() {
|
|
var apID int64
|
|
var apBotA, apBotB string
|
|
var apAWins, apBWins, apFormat int
|
|
if err := allPlayed.Scan(&apID, &apBotA, &apBotB, &apAWins, &apBWins, &apFormat); err != nil {
|
|
continue
|
|
}
|
|
winsNeeded := (apFormat + 1) / 2
|
|
if apAWins >= winsNeeded || apBWins >= winsNeeded {
|
|
continue // already handled above
|
|
}
|
|
var winnerID *string
|
|
if apAWins > apBWins {
|
|
winnerID = &apBotA
|
|
} else if apBWins > apAWins {
|
|
winnerID = &apBotB
|
|
}
|
|
_, err := m.db.ExecContext(ctx, `
|
|
UPDATE series SET status = 'completed', winner_id = $1, updated_at = NOW()
|
|
WHERE id = $2 AND status = 'active'
|
|
`, winnerID, apID)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to finalize all-played series %d: %v", apID, err)
|
|
continue
|
|
}
|
|
log.Printf("series-scheduler: finalized all-played series %d (%d-%d), winner=%v", apID, apAWins, apBWins, winnerID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// scheduleNextSeriesGames finds active series with unplayed games and schedules the next one.
|
|
func (m *Matchmaker) scheduleNextSeriesGames(ctx context.Context) error {
|
|
// Find active series where the next sequential game has no match_id yet
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins,
|
|
COALESCE(MAX(sg.game_num), 0) AS last_game_num
|
|
FROM series s
|
|
LEFT JOIN series_games sg ON s.id = sg.series_id AND sg.match_id IS NOT NULL
|
|
WHERE s.status = 'active'
|
|
GROUP BY s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins
|
|
HAVING COUNT(sg.id) < s.format
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query pending series: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type pendingSeries struct {
|
|
ID int64
|
|
BotAID string
|
|
BotBID string
|
|
Format int
|
|
AWins int
|
|
BWins int
|
|
LastGameNum int
|
|
}
|
|
var pending []pendingSeries
|
|
|
|
for rows.Next() {
|
|
var s pendingSeries
|
|
if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins, &s.LastGameNum); err != nil {
|
|
return fmt.Errorf("scan pending series: %w", err)
|
|
}
|
|
|
|
// Skip if series is already decided
|
|
winsNeeded := (s.Format + 1) / 2
|
|
if s.AWins >= winsNeeded || s.BWins >= winsNeeded {
|
|
continue
|
|
}
|
|
|
|
// Check that both bots are active and not on crash cooldown (§4.5, §6.1)
|
|
var aActive, bActive bool
|
|
err := m.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))`, s.BotAID).Scan(&aActive)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))`, s.BotBID).Scan(&bActive)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !aActive || !bActive {
|
|
continue
|
|
}
|
|
|
|
pending = append(pending, s)
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
for _, s := range pending {
|
|
nextGameNum := s.LastGameNum + 1
|
|
|
|
// Check if this game already has a pending match (not yet completed)
|
|
var existingMatch int
|
|
err := m.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM series_games
|
|
WHERE series_id = $1 AND game_num = $2 AND match_id IS NOT NULL
|
|
`, s.ID, nextGameNum).Scan(&existingMatch)
|
|
if err != nil || existingMatch > 0 {
|
|
continue // already scheduled or played
|
|
}
|
|
|
|
// Check if there's already a pending/running job for this series
|
|
var pendingJobs int
|
|
err = m.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM series_games sg
|
|
JOIN matches m ON sg.match_id = m.match_id
|
|
JOIN jobs j ON j.match_id = m.match_id
|
|
WHERE sg.series_id = $1 AND j.status IN ('pending', 'running')
|
|
`, s.ID).Scan(&pendingJobs)
|
|
if err != nil || pendingJobs > 0 {
|
|
continue // a game is already in progress for this series
|
|
}
|
|
|
|
if err := m.scheduleSeriesGame(ctx, s.ID, s.BotAID, s.BotBID, nextGameNum, rng); err != nil {
|
|
log.Printf("series-scheduler: failed to schedule game %d for series %d: %v", nextGameNum, s.ID, err)
|
|
continue
|
|
}
|
|
log.Printf("series-scheduler: scheduled game %d for series %d (%s vs %s)", nextGameNum, s.ID, s.BotAID, s.BotBID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// scheduleSeriesGame creates a match and job for one game in a series.
|
|
// It selects maps with varied characteristics per game number (§14.7) and
|
|
// alternates player slots for fairness.
|
|
func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, botAID, botBID string, gameNum int, rng *rand.Rand) error {
|
|
// Fetch bot endpoints and secrets
|
|
var endpointA, secretA, endpointB, secretB string
|
|
err := m.db.QueryRowContext(ctx,
|
|
`SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botAID).Scan(&endpointA, &secretA)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch bot %s: %w", botAID, err)
|
|
}
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botBID).Scan(&endpointB, &secretB)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch bot %s: %w", botBID, err)
|
|
}
|
|
|
|
// Decrypt secrets
|
|
if m.cfg.EncryptionKey != "" {
|
|
if dec, err := decryptSecret(secretA, m.cfg.EncryptionKey); err == nil {
|
|
secretA = dec
|
|
}
|
|
if dec, err := decryptSecret(secretB, m.cfg.EncryptionKey); err == nil {
|
|
secretB = dec
|
|
}
|
|
}
|
|
|
|
matchID, err := generateID("m_", 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jobID, err := generateID("j_", 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Select a map with varied characteristics per game number (§14.7)
|
|
mapID, rows, cols, mapSeed := m.selectSeriesMap(ctx, gameNum, rng)
|
|
|
|
// Alternate player slots per game for round-robin fairness
|
|
slotA, slotB := 0, 1
|
|
if gameNum%2 == 0 {
|
|
slotA, slotB = 1, 0
|
|
}
|
|
|
|
type botConfig struct {
|
|
BotID string `json:"bot_id"`
|
|
Endpoint string `json:"endpoint"`
|
|
Secret string `json:"secret"`
|
|
Slot int `json:"slot"`
|
|
}
|
|
type jobConfig struct {
|
|
MatchID string `json:"match_id"`
|
|
SeriesID int64 `json:"series_id,omitempty"`
|
|
GameNum int `json:"game_num,omitempty"`
|
|
MapSeed int64 `json:"map_seed"`
|
|
MaxTurns int `json:"max_turns"`
|
|
Rows int `json:"rows"`
|
|
Cols int `json:"cols"`
|
|
Bots []botConfig `json:"bots"`
|
|
}
|
|
|
|
config := jobConfig{
|
|
MatchID: matchID,
|
|
SeriesID: seriesID,
|
|
GameNum: gameNum,
|
|
MapSeed: mapSeed,
|
|
MaxTurns: 500,
|
|
Rows: rows,
|
|
Cols: cols,
|
|
Bots: []botConfig{
|
|
{BotID: botAID, Endpoint: endpointA, Secret: secretA, Slot: slotA},
|
|
{BotID: botBID, Endpoint: endpointB, Secret: secretB, Slot: slotB},
|
|
},
|
|
}
|
|
configJSON, _ := json.Marshal(config)
|
|
|
|
tx, err := m.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO matches (match_id, map_id, map_seed, status) VALUES ($1, $2, $3, 'pending')`,
|
|
matchID, mapID, mapSeed)
|
|
if err != nil {
|
|
return fmt.Errorf("insert match: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES ($1, $2, $3), ($1, $4, $5)`,
|
|
matchID, botAID, slotA, botBID, slotB)
|
|
if err != nil {
|
|
return fmt.Errorf("insert participants: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO jobs (job_id, match_id, status, config_json) VALUES ($1, $2, 'pending', $3)`,
|
|
jobID, matchID, configJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("insert job: %w", err)
|
|
}
|
|
|
|
// Create the series_games row
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO series_games (series_id, match_id, game_num, winner_id)
|
|
VALUES ($1, $2, $3, NULL)
|
|
`, seriesID, matchID, gameNum)
|
|
if err != nil {
|
|
return fmt.Errorf("insert series_game: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Enqueue in Valkey
|
|
if err := m.rdb.LPush(ctx, valkeyJobQueue, jobID).Err(); err != nil {
|
|
return fmt.Errorf("valkey push: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// selectSeriesMap picks a map with varied characteristics per game number.
|
|
// Per §14.7: Game 1 = highest engagement, Game 2 = highest wall density,
|
|
// Game 3 = lowest wall density, Game 4+ = random from pool.
|
|
// Returns (mapID, rows, cols, seed). Falls back to random seed if maps table is empty.
|
|
func (m *Matchmaker) selectSeriesMap(ctx context.Context, gameNum int, rng *rand.Rand) (string, int, int, int64) {
|
|
var orderBy string
|
|
switch {
|
|
case gameNum == 1:
|
|
orderBy = "engagement DESC NULLS LAST"
|
|
case gameNum == 2:
|
|
orderBy = "wall_density DESC NULLS LAST"
|
|
case gameNum == 3:
|
|
orderBy = "wall_density ASC NULLS LAST"
|
|
default:
|
|
orderBy = "RANDOM()"
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT map_id, grid_width, grid_height FROM maps
|
|
WHERE player_count = 2 AND status IN ('active', 'classic')
|
|
ORDER BY %s LIMIT 1
|
|
`, orderBy)
|
|
|
|
var mapID string
|
|
var gridW, gridH int
|
|
err := m.db.QueryRowContext(ctx, query).Scan(&mapID, &gridW, &gridH)
|
|
if err != nil {
|
|
// No maps in table — generate from seed
|
|
seed := rng.Int63()
|
|
return fmt.Sprintf("map_%d", seed%100000), 60, 60, seed
|
|
}
|
|
|
|
return mapID, gridH, gridW, rng.Int63()
|
|
}
|
|
|
|
// autoCreateSeries creates best-of-5 series between top-20 active bots,
|
|
// one per bot per day.
|
|
func (m *Matchmaker) autoCreateSeries(ctx context.Context) error {
|
|
// Find top-20 active bots by rating (excluding crash-cooldown bots)
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots
|
|
WHERE status = 'active' AND evolved = false
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW())
|
|
ORDER BY rating_mu DESC
|
|
LIMIT 20
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query top bots: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var topBots []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return err
|
|
}
|
|
topBots = append(topBots, id)
|
|
}
|
|
|
|
if len(topBots) < 2 {
|
|
return nil
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
for _, botID := range topBots {
|
|
// Check if this bot already has an active or pending series created today
|
|
var todaySeries int
|
|
err := m.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM series
|
|
WHERE (bot_a_id = $1 OR bot_b_id = $1)
|
|
AND created_at >= NOW() - INTERVAL '24 hours'
|
|
AND status IN ('active', 'pending')
|
|
`, botID).Scan(&todaySeries)
|
|
if err != nil || todaySeries > 0 {
|
|
continue
|
|
}
|
|
|
|
// Pick an opponent — closest rating that isn't this bot and doesn't have an active series
|
|
var opponentID string
|
|
err = m.db.QueryRowContext(ctx, `
|
|
SELECT b.bot_id FROM bots b
|
|
WHERE b.bot_id != $1
|
|
AND b.status = 'active'
|
|
AND (b.cooldown_until IS NULL OR b.cooldown_until < NOW())
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM series s
|
|
WHERE ((s.bot_a_id = $1 AND s.bot_b_id = b.bot_id)
|
|
OR (s.bot_a_id = b.bot_id AND s.bot_b_id = $1))
|
|
AND s.status IN ('active', 'pending')
|
|
)
|
|
ORDER BY ABS(b.rating_mu - (SELECT rating_mu FROM bots WHERE bot_id = $1)) ASC
|
|
LIMIT 1
|
|
`, botID).Scan(&opponentID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
continue
|
|
}
|
|
return fmt.Errorf("find opponent for %s: %w", botID, err)
|
|
}
|
|
|
|
// Determine format based on ratings — closer ratings get longer series
|
|
var botRating, oppRating float64
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT rating_mu FROM bots WHERE bot_id = $1`, botID).Scan(&botRating)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT rating_mu FROM bots WHERE bot_id = $1`, opponentID).Scan(&oppRating)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
format := 5 // default best-of-5
|
|
ratingGap := botRating - oppRating
|
|
if ratingGap < 0 {
|
|
ratingGap = -ratingGap
|
|
}
|
|
if ratingGap < 50 {
|
|
format = 7 // close ratings → best-of-7
|
|
} else if ratingGap >= 200 {
|
|
format = 3 // large gap → best-of-3
|
|
}
|
|
|
|
// Randomize who is bot_a vs bot_b
|
|
botAID, botBID := botID, opponentID
|
|
if rng.Intn(2) == 0 {
|
|
botAID, botBID = botBID, botAID
|
|
}
|
|
|
|
// 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)
|
|
|
|
_, err = m.db.ExecContext(ctx, `
|
|
INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, updated_at)
|
|
VALUES ($1, $2, $3, 'active', 0, 0, $4, NOW())
|
|
`, botAID, botBID, format, seasonID)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to create series (%s vs %s): %v", botAID, botBID, err)
|
|
continue
|
|
}
|
|
log.Printf("series-scheduler: created best-of-%d series: %s vs %s", format, botAID, botBID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// tickSeasonReset checks for seasons that have ended and performs:
|
|
// 1. Snapshot current ELO ratings into season_snapshots
|
|
// 2. Apply decay formula to all bot ratings
|
|
// 3. Close the old season and start a new one
|
|
func (m *Matchmaker) tickSeasonReset(ctx context.Context) {
|
|
// Find active seasons that have passed their ends_at
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT id, name, theme, rules_version FROM seasons
|
|
WHERE status = 'active' AND ends_at IS NOT NULL AND ends_at <= NOW()
|
|
`)
|
|
if err != nil {
|
|
log.Printf("season-reset: query error: %v", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type endingSeason struct {
|
|
ID int64
|
|
Name string
|
|
Theme string
|
|
RulesVersion string
|
|
}
|
|
var ending []endingSeason
|
|
|
|
for rows.Next() {
|
|
var s endingSeason
|
|
var theme sql.NullString
|
|
if err := rows.Scan(&s.ID, &s.Name, &theme, &s.RulesVersion); err != nil {
|
|
log.Printf("season-reset: scan error: %v", err)
|
|
return
|
|
}
|
|
if theme.Valid {
|
|
s.Theme = theme.String
|
|
}
|
|
ending = append(ending, s)
|
|
}
|
|
|
|
for _, s := range ending {
|
|
if err := m.processSeasonEnd(ctx, s.ID, s.Name); err != nil {
|
|
log.Printf("season-reset: failed to process season %d (%s): %v", s.ID, s.Name, err)
|
|
continue
|
|
}
|
|
log.Printf("season-reset: processed season %d (%s) — snapshot + decay complete", s.ID, s.Name)
|
|
}
|
|
|
|
// Check if there's no active season and auto-start one
|
|
m.autoStartSeason(ctx)
|
|
}
|
|
|
|
// processSeasonEnd handles the end-of-season workflow for one season.
|
|
func (m *Matchmaker) processSeasonEnd(ctx context.Context, seasonID int64, seasonName string) error {
|
|
tx, err := m.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// 1. Snapshot current ratings into season_snapshots
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses)
|
|
SELECT $1, b.bot_id,
|
|
ROW_NUMBER() OVER (ORDER BY b.rating_mu DESC)::int,
|
|
b.rating_mu,
|
|
COALESCE(mp.wins, 0),
|
|
COALESCE(mp.losses, 0)
|
|
FROM bots b
|
|
LEFT JOIN (
|
|
SELECT bot_id,
|
|
COUNT(*) FILTER (WHERE player_slot = m.winner) AS wins,
|
|
COUNT(*) FILTER (WHERE player_slot != m.winner) AS losses
|
|
FROM match_participants mp
|
|
JOIN matches m ON mp.match_id = m.match_id
|
|
WHERE m.status = 'completed'
|
|
GROUP BY bot_id
|
|
) mp ON mp.bot_id = b.bot_id
|
|
WHERE b.status != 'retired'
|
|
ORDER BY b.rating_mu DESC
|
|
`, seasonID)
|
|
if err != nil {
|
|
return fmt.Errorf("snapshot ratings: %w", err)
|
|
}
|
|
|
|
// 2. Determine champion (rank 1)
|
|
var championID string
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT bot_id FROM season_snapshots
|
|
WHERE season_id = $1 AND rank = 1
|
|
`, seasonID).Scan(&championID)
|
|
if err != nil {
|
|
log.Printf("season-reset: could not determine champion for season %d: %v", seasonID, err)
|
|
}
|
|
|
|
// 3. Mark season as completed
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE seasons SET status = 'completed', champion_id = $1 WHERE id = $2
|
|
`, championID, seasonID)
|
|
if err != nil {
|
|
return fmt.Errorf("complete season: %w", err)
|
|
}
|
|
|
|
// 4. Apply decay to all non-retired bots
|
|
// Formula: new_mu = default + (current_mu - default) * decay_factor
|
|
// This pulls ratings toward 1500 but preserves relative ordering
|
|
decayFactor := m.cfg.SeasonDecayFactor
|
|
defaultMu := 1500.0
|
|
defaultPhi := 350.0
|
|
defaultSigma := 0.06
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE bots SET
|
|
rating_mu = $1 + (rating_mu - $1) * $2,
|
|
rating_phi = $3,
|
|
rating_sigma = $4
|
|
WHERE status != 'retired'
|
|
`, defaultMu, decayFactor, defaultPhi, defaultSigma)
|
|
if err != nil {
|
|
return fmt.Errorf("apply decay: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 5. Create championship bracket for top 8 (§14.9)
|
|
if err := m.createChampionshipBracket(ctx, seasonID); err != nil {
|
|
log.Printf("season-reset: championship bracket creation failed for season %d: %v", seasonID, err)
|
|
}
|
|
|
|
log.Printf("season-reset: season %d (%s) complete — champion=%s, decay=%.0f%%",
|
|
seasonID, seasonName, championID, decayFactor*100)
|
|
|
|
return nil
|
|
}
|
|
|
|
// autoStartSeason creates a new season if no active season exists.
|
|
func (m *Matchmaker) autoStartSeason(ctx context.Context) {
|
|
var activeCount int
|
|
err := m.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM seasons WHERE status = 'active'`).Scan(&activeCount)
|
|
if err != nil || activeCount > 0 {
|
|
return
|
|
}
|
|
|
|
// Determine next season number
|
|
var maxNum int
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(MAX(id), 0) FROM seasons`).Scan(&maxNum)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
nextNum := maxNum + 1
|
|
seasonName := fmt.Sprintf("Season %d", nextNum)
|
|
themes := []string{"The Labyrinth", "Energy Rush", "Fog of War", "The Colosseum", "Shifting Sands"}
|
|
theme := themes[(nextNum-1)%len(themes)]
|
|
rulesVersion := fmt.Sprintf("%d.0", nextNum)
|
|
|
|
_, err = m.db.ExecContext(ctx, `
|
|
INSERT INTO seasons (name, theme, rules_version, status, starts_at, ends_at)
|
|
VALUES ($1, $2, $3, 'active', NOW(), NOW() + INTERVAL '28 days')
|
|
`, seasonName, theme, rulesVersion)
|
|
if err != nil {
|
|
log.Printf("season-reset: failed to create new season: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("season-reset: auto-started %s (%s) — ends in 28 days", seasonName, theme)
|
|
}
|
|
|
|
// advanceChampionshipBracket checks if any quarterfinal or semifinal series
|
|
// have completed and creates the next round of series.
|
|
func (m *Matchmaker) advanceChampionshipBracket(ctx context.Context) error {
|
|
// Find completed quarterfinal series whose winners haven't been placed into semifinals yet
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT s.id, s.season_id, s.bot_a_id, s.bot_b_id, s.winner_id, s.bracket_position
|
|
FROM series s
|
|
WHERE s.bracket_round = 'quarterfinal'
|
|
AND s.status = 'completed'
|
|
AND s.winner_id IS NOT NULL
|
|
AND s.season_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM series sf
|
|
WHERE sf.season_id = s.season_id
|
|
AND sf.bracket_round = 'semifinal'
|
|
AND sf.bracket_position = FLOOR(s.bracket_position / 2)
|
|
)
|
|
ORDER BY s.bracket_position
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query completed quarterfinals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type completedQF struct {
|
|
SeriesID int64
|
|
SeasonID int64
|
|
WinnerID string
|
|
Position int
|
|
}
|
|
var completed []completedQF
|
|
|
|
for rows.Next() {
|
|
var qf completedQF
|
|
var botAID, botBID, winnerID string
|
|
var position int
|
|
if err := rows.Scan(&qf.SeriesID, &qf.SeasonID, &botAID, &botBID, &winnerID, &position); err != nil {
|
|
return fmt.Errorf("scan quarterfinal: %w", err)
|
|
}
|
|
qf.WinnerID = winnerID
|
|
qf.Position = position
|
|
completed = append(completed, qf)
|
|
}
|
|
|
|
// Group by season and create semifinal matchups
|
|
type semifinalPair struct {
|
|
seasonID int64
|
|
position int
|
|
winners []string
|
|
}
|
|
pairs := make(map[string]*semifinalPair)
|
|
for _, qf := range completed {
|
|
sfPos := qf.Position / 2 // QF 0,1 → SF 0; QF 2,3 → SF 1
|
|
key := fmt.Sprintf("%d-%d", qf.SeasonID, sfPos)
|
|
if pairs[key] == nil {
|
|
pairs[key] = &semifinalPair{seasonID: qf.SeasonID, position: sfPos}
|
|
}
|
|
pairs[key].winners = append(pairs[key].winners, qf.WinnerID)
|
|
}
|
|
|
|
for _, pair := range pairs {
|
|
if len(pair.winners) < 2 {
|
|
continue
|
|
}
|
|
_, err := m.db.ExecContext(ctx, `
|
|
INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at)
|
|
VALUES ($1, $2, 7, 'active', 0, 0, $3, 'semifinal', $4, NOW())
|
|
`, pair.winners[0], pair.winners[1], pair.seasonID, pair.position)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to create semifinal (%s vs %s): %v", pair.winners[0], pair.winners[1], err)
|
|
continue
|
|
}
|
|
log.Printf("series-scheduler: created championship semifinal: %s vs %s", pair.winners[0], pair.winners[1])
|
|
}
|
|
|
|
// Check for completed semifinals → create final
|
|
sfRows, err := m.db.QueryContext(ctx, `
|
|
SELECT s.id, s.season_id, s.winner_id, s.bracket_position
|
|
FROM series s
|
|
WHERE s.bracket_round = 'semifinal'
|
|
AND s.status = 'completed'
|
|
AND s.winner_id IS NOT NULL
|
|
AND s.season_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM series f
|
|
WHERE f.season_id = s.season_id AND f.bracket_round = 'final'
|
|
)
|
|
ORDER BY s.bracket_position
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query completed semifinals: %w", err)
|
|
}
|
|
defer sfRows.Close()
|
|
|
|
type completedSF struct {
|
|
SeasonID int64
|
|
WinnerID string
|
|
}
|
|
var sfWinners []completedSF
|
|
for sfRows.Next() {
|
|
var sf completedSF
|
|
var id int64
|
|
var pos int
|
|
if err := sfRows.Scan(&id, &sf.SeasonID, &sf.WinnerID, &pos); err != nil {
|
|
return fmt.Errorf("scan semifinal: %w", err)
|
|
}
|
|
sfWinners = append(sfWinners, sf)
|
|
}
|
|
|
|
if len(sfWinners) >= 2 && sfWinners[0].SeasonID == sfWinners[1].SeasonID {
|
|
_, err := m.db.ExecContext(ctx, `
|
|
INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at)
|
|
VALUES ($1, $2, 7, 'active', 0, 0, $3, 'final', 0, NOW())
|
|
`, sfWinners[0].WinnerID, sfWinners[1].WinnerID, sfWinners[0].SeasonID)
|
|
if err != nil {
|
|
log.Printf("series-scheduler: failed to create championship final: %v", err)
|
|
} else {
|
|
log.Printf("series-scheduler: created championship final: %s vs %s", sfWinners[0].WinnerID, sfWinners[1].WinnerID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createChampionshipBracket creates best-of-7 series for the top 8 bots
|
|
// in a single-elimination bracket at season end (§14.9).
|
|
// Bracket seeding: #1 vs #8, #4 vs #5, #3 vs #6, #2 vs #7
|
|
func (m *Matchmaker) createChampionshipBracket(ctx context.Context, seasonID int64) error {
|
|
// Check if championship series already exist for this season
|
|
var existing int
|
|
err := m.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM series WHERE season_id = $1 AND format = 7
|
|
`, seasonID).Scan(&existing)
|
|
if err != nil || existing > 0 {
|
|
return nil // already created
|
|
}
|
|
|
|
// Get top 8 active bots by rating (excluding crash-cooldown bots)
|
|
rows, err := m.db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots
|
|
WHERE status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW())
|
|
ORDER BY rating_mu DESC
|
|
LIMIT 8
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("query top 8: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var botIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return err
|
|
}
|
|
botIDs = append(botIDs, id)
|
|
}
|
|
|
|
if len(botIDs) < 8 {
|
|
log.Printf("season-reset: not enough active bots (%d) for championship bracket, need 8", len(botIDs))
|
|
return nil
|
|
}
|
|
|
|
// Standard bracket seeding: #1v8, #4v5, #3v6, #2v7
|
|
// This ensures top seeds face weakest opponents and #1/#2 can only meet in finals
|
|
bracket := []struct {
|
|
a, b string
|
|
position int
|
|
}{
|
|
{botIDs[0], botIDs[7], 0}, // #1 vs #8
|
|
{botIDs[3], botIDs[4], 1}, // #4 vs #5
|
|
{botIDs[2], botIDs[5], 2}, // #3 vs #6
|
|
{botIDs[1], botIDs[6], 3}, // #2 vs #7
|
|
}
|
|
|
|
for _, matchup := range bracket {
|
|
_, err := m.db.ExecContext(ctx, `
|
|
INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at)
|
|
VALUES ($1, $2, 7, 'active', 0, 0, $3, 'quarterfinal', $4, NOW())
|
|
`, matchup.a, matchup.b, seasonID, matchup.position)
|
|
if err != nil {
|
|
log.Printf("season-reset: failed to create championship quarterfinal series (%s vs %s): %v",
|
|
matchup.a, matchup.b, err)
|
|
continue
|
|
}
|
|
log.Printf("season-reset: created championship quarterfinal series: %s vs %s (bo7)",
|
|
matchup.a, matchup.b)
|
|
}
|
|
|
|
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)
|
|
}
|