Verified all Phase 9 deliverables already in place:
- PostgreSQL map_votes table (UNIQUE constraint on map_id, voter_id)
- POST /api/vote-map endpoint (+1/-1 votes, rate-limited)
- GET /api/vote/map/{map_id} endpoint for vote counts
- Positional fairness monitoring: tickFairnessAudit with 5-step lifecycle
* updateMapFairnessStats: recompute per-slot win rates
* flagUnfairMaps: probation for >10pp deviation
* retireDislikedMaps: force-retire at < -20 net votes
* pruneLowEngagementMaps: monthly bottom 10% pruning
* promoteClassicMaps: top-5 sustained (3+ months) to classic
- maps/index.json includes NetVotes from aggregation
All tests pass (mapvote, map_fairness, index-builder).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1066 lines
31 KiB
Go
1066 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// BotData represents a bot for the index
|
|
type BotData struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
OwnerID string `json:"owner_id"`
|
|
Description string `json:"description,omitempty"`
|
|
Rating float64 `json:"rating"`
|
|
RatingDeviation float64 `json:"rating_deviation"`
|
|
RatingVolatility float64 `json:"rating_volatility"`
|
|
MatchesPlayed int `json:"matches_played"`
|
|
MatchesWon int `json:"matches_won"`
|
|
HealthStatus string `json:"health_status"`
|
|
Evolved bool `json:"evolved"`
|
|
Island string `json:"island,omitempty"`
|
|
Generation int `json:"generation,omitempty"`
|
|
Archetype string `json:"archetype,omitempty"`
|
|
ParentIDs []string `json:"parent_ids,omitempty"`
|
|
DebugPublic bool `json:"debug_public"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// MatchData represents a match for the index
|
|
type MatchData struct {
|
|
ID string `json:"id"`
|
|
MapID string `json:"map_id"`
|
|
MapName string `json:"map_name,omitempty"`
|
|
WinnerID string `json:"winner_id,omitempty"`
|
|
TurnCount int `json:"turn_count"`
|
|
EndCondition string `json:"end_condition"`
|
|
CombatTurns int `json:"combat_turns"` // turns with ≥1 enemy-kill combat death
|
|
Participants []ParticipantData `json:"participants"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CompletedAt time.Time `json:"completed_at"`
|
|
PlayedAt time.Time `json:"played_at"`
|
|
}
|
|
|
|
// ParticipantData represents a bot in a match with pre-match rating
|
|
type ParticipantData struct {
|
|
BotID string `json:"bot_id"`
|
|
PlayerSlot int `json:"player_slot"`
|
|
Score int `json:"score"`
|
|
Won bool `json:"won"`
|
|
PreMatchRating float64 `json:"pre_match_rating,omitempty"`
|
|
Evolved bool `json:"evolved,omitempty"`
|
|
}
|
|
|
|
// RatingHistoryEntry represents a rating history point
|
|
type RatingHistoryEntry struct {
|
|
BotID string `json:"bot_id"`
|
|
MatchID string `json:"match_id"`
|
|
Rating float64 `json:"rating"`
|
|
RecordedAt time.Time `json:"recorded_at"`
|
|
}
|
|
|
|
// SeriesGameData represents one game within a series
|
|
type SeriesGameData struct {
|
|
MatchID string `json:"match_id"`
|
|
GameNum int `json:"game_number"`
|
|
WinnerID string `json:"winner_id,omitempty"`
|
|
WinnerSlot *int `json:"winner_slot"`
|
|
Turns int `json:"turns,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
// SeriesData represents a series for the index, enriched with bot names and games.
|
|
type SeriesData struct {
|
|
ID int64 `json:"id"`
|
|
BotAID string `json:"bot1_id"`
|
|
BotBID string `json:"bot2_id"`
|
|
BotAName string `json:"bot1_name"`
|
|
BotBName string `json:"bot2_name"`
|
|
Format int `json:"best_of"`
|
|
AWins int `json:"bot1_wins"`
|
|
BWins int `json:"bot2_wins"`
|
|
Status string `json:"status"`
|
|
WinnerID string `json:"winner_id,omitempty"`
|
|
BracketRound string `json:"bracket_round,omitempty"`
|
|
BracketPosition int `json:"bracket_position,omitempty"`
|
|
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Games []SeriesGameData `json:"games"`
|
|
}
|
|
|
|
// SeasonSnapshotData represents a bot's end-of-season snapshot
|
|
type SeasonSnapshotData struct {
|
|
BotID string `json:"bot_id"`
|
|
BotName string `json:"bot_name"`
|
|
Rating float64 `json:"rating"`
|
|
Rank int `json:"rank"`
|
|
Wins int `json:"wins"`
|
|
Losses int `json:"losses"`
|
|
}
|
|
|
|
// ChampionshipSeries is a lightweight series summary for bracket display on the season page.
|
|
type ChampionshipSeries struct {
|
|
ID int64 `json:"id"`
|
|
BotAID string `json:"bot1_id"`
|
|
BotBID string `json:"bot2_id"`
|
|
BotAName string `json:"bot1_name"`
|
|
BotBName string `json:"bot2_name"`
|
|
Format int `json:"best_of"`
|
|
AWins int `json:"bot1_wins"`
|
|
BWins int `json:"bot2_wins"`
|
|
Status string `json:"status"`
|
|
WinnerID string `json:"winner_id,omitempty"`
|
|
Round string `json:"round"`
|
|
BracketPosition int `json:"bracket_position"`
|
|
Games []SeriesGameData `json:"games"`
|
|
}
|
|
|
|
// SeasonData represents a season for the index, enriched with champion name, match count, and snapshots.
|
|
type SeasonData struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Theme string `json:"theme,omitempty"`
|
|
RulesVer string `json:"rules_version"`
|
|
Status string `json:"status"`
|
|
ChampionID string `json:"champion_id,omitempty"`
|
|
ChampionName string `json:"champion_name,omitempty"`
|
|
StartsAt time.Time `json:"starts_at"`
|
|
EndsAt time.Time `json:"ends_at,omitempty"`
|
|
TotalMatches int `json:"total_matches"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Snapshots []SeasonSnapshotData `json:"final_snapshot"`
|
|
ChampionshipBracket []ChampionshipSeries `json:"championship_bracket,omitempty"`
|
|
}
|
|
|
|
// PredictionData represents a prediction for the index
|
|
type PredictionData struct {
|
|
ID int64 `json:"id"`
|
|
MatchID string `json:"match_id"`
|
|
PredictorID string `json:"predictor_id"`
|
|
PredictedBot string `json:"predicted_bot"`
|
|
Correct *bool `json:"correct,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
|
}
|
|
|
|
// PredictorStats represents predictor statistics
|
|
type PredictorStats struct {
|
|
PredictorID string `json:"predictor_id"`
|
|
Correct int `json:"correct"`
|
|
Incorrect int `json:"incorrect"`
|
|
Streak int `json:"streak"`
|
|
BestStreak int `json:"best_streak"`
|
|
}
|
|
|
|
// MapData represents a map for the index
|
|
type MapData struct {
|
|
MapID string `json:"map_id"`
|
|
PlayerCount int `json:"player_count"`
|
|
Status string `json:"status"`
|
|
Engagement float64 `json:"engagement"`
|
|
WallDensity float64 `json:"wall_density"`
|
|
EnergyCount int `json:"energy_count"`
|
|
GridWidth int `json:"grid_width"`
|
|
GridHeight int `json:"grid_height"`
|
|
NetVotes int `json:"net_votes"` // Sum of votes from map_votes table
|
|
CreatedAt time.Time `json:"created_at"`
|
|
RawJSON json.RawMessage `json:"-"`
|
|
}
|
|
|
|
// OpenPredictionMatch represents a pending match open for predictions
|
|
type OpenPredictionMatch struct {
|
|
MatchID string `json:"match_id"`
|
|
BotAID string `json:"bot_a"`
|
|
BotBID string `json:"bot_b"`
|
|
BotAName string `json:"bot_a_name"`
|
|
BotBName string `json:"bot_b_name"`
|
|
ARating float64 `json:"a_rating"`
|
|
BRating float64 `json:"b_rating"`
|
|
AEvolved bool `json:"a_evolved"`
|
|
BEvolved bool `json:"b_evolved"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
IsSeriesMatch bool `json:"is_series_match"`
|
|
HeadToHeadRecord *string `json:"head_to_head_record,omitempty"`
|
|
}
|
|
|
|
// FeedbackEntry represents a community replay annotation from §13.6.
|
|
type FeedbackEntry struct {
|
|
FeedbackID string `json:"feedback_id"`
|
|
MatchID string `json:"match_id"`
|
|
Turn int `json:"turn"`
|
|
Type string `json:"type"`
|
|
Body string `json:"body"`
|
|
Author string `json:"author"`
|
|
Upvotes int `json:"upvotes"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// IndexData contains all data needed for index generation
|
|
type IndexData struct {
|
|
GeneratedAt time.Time
|
|
Bots []BotData
|
|
Matches []MatchData
|
|
RatingHistory []RatingHistoryEntry
|
|
Series []SeriesData
|
|
Seasons []SeasonData
|
|
Predictions []PredictionData
|
|
PredictorStats []PredictorStats
|
|
Maps []MapData
|
|
TopPredictors []PredictorStats
|
|
OpenPredictionMatches []OpenPredictionMatch
|
|
Feedback []FeedbackEntry
|
|
}
|
|
|
|
// fetchAllData retrieves all data from PostgreSQL for index generation
|
|
func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) {
|
|
data := &IndexData{
|
|
GeneratedAt: time.Now().UTC(),
|
|
}
|
|
|
|
var err error
|
|
if data.Bots, err = fetchBots(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Matches, err = fetchMatches(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.RatingHistory, err = fetchRatingHistory(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Series, err = fetchSeries(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Seasons, err = fetchSeasons(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Predictions, err = fetchPredictions(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.PredictorStats, err = fetchPredictorStats(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Maps, err = fetchMaps(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.OpenPredictionMatches, err = fetchOpenPredictions(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if data.Feedback, err = fetchFeedback(ctx, db); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data.TopPredictors = computeTopPredictors(data.PredictorStats)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) {
|
|
query := `
|
|
SELECT bot_id, name, owner, description,
|
|
rating_mu, rating_phi, rating_sigma,
|
|
0, 0, status,
|
|
evolved, island, generation,
|
|
COALESCE(archetype, ''), COALESCE(parent_ids, '[]'::jsonb),
|
|
debug_public,
|
|
created_at, COALESCE(last_active, created_at)
|
|
FROM bots
|
|
WHERE status != 'retired'
|
|
ORDER BY rating_mu DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var bots []BotData
|
|
for rows.Next() {
|
|
var b BotData
|
|
var desc, island sql.NullString
|
|
var gen sql.NullInt64
|
|
var parentIDsJSON []byte
|
|
|
|
err := rows.Scan(
|
|
&b.ID, &b.Name, &b.OwnerID, &desc,
|
|
&b.Rating, &b.RatingDeviation, &b.RatingVolatility,
|
|
&b.MatchesPlayed, &b.MatchesWon, &b.HealthStatus,
|
|
&b.Evolved, &island, &gen,
|
|
&b.Archetype, &parentIDsJSON,
|
|
&b.DebugPublic,
|
|
&b.CreatedAt, &b.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if desc.Valid {
|
|
b.Description = desc.String
|
|
}
|
|
if island.Valid {
|
|
b.Island = island.String
|
|
}
|
|
if gen.Valid {
|
|
b.Generation = int(gen.Int64)
|
|
}
|
|
if len(parentIDsJSON) > 0 {
|
|
json.Unmarshal(parentIDsJSON, &b.ParentIDs)
|
|
}
|
|
|
|
bots = append(bots, b)
|
|
}
|
|
|
|
for i := range bots {
|
|
mp, mw, err := getBotMatchStats(ctx, db, bots[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bots[i].MatchesPlayed = mp
|
|
bots[i].MatchesWon = mw
|
|
}
|
|
|
|
return bots, nil
|
|
}
|
|
|
|
func getBotMatchStats(ctx context.Context, db *sql.DB, botID string) (played, won int, err error) {
|
|
query := `
|
|
SELECT COUNT(*), COUNT(*) FILTER (WHERE mp.player_slot = m.winner)
|
|
FROM match_participants mp
|
|
JOIN matches m ON mp.match_id = m.match_id
|
|
WHERE mp.bot_id = $1 AND m.status = 'completed'
|
|
`
|
|
err = db.QueryRowContext(ctx, query, botID).Scan(&played, &won)
|
|
return
|
|
}
|
|
|
|
func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) {
|
|
query := `
|
|
SELECT m.match_id, m.map_id, m.winner, m.turn_count, m.condition,
|
|
COALESCE(m.combat_turns, 0),
|
|
m.created_at, m.completed_at,
|
|
COALESCE(
|
|
json_agg(
|
|
json_build_object(
|
|
'bot_id', mp.bot_id,
|
|
'player_slot', mp.player_slot,
|
|
'score', mp.score,
|
|
'won', mp.player_slot = m.winner,
|
|
'pre_match_rating', COALESCE(
|
|
(SELECT rh.rating FROM rating_history rh
|
|
WHERE rh.bot_id = mp.bot_id AND rh.match_id = m.match_id
|
|
LIMIT 1), 0),
|
|
'evolved', COALESCE(
|
|
(SELECT b.evolved FROM bots b WHERE b.bot_id = mp.bot_id), false)
|
|
)
|
|
ORDER BY mp.player_slot
|
|
) FILTER (WHERE mp.bot_id IS NOT NULL),
|
|
'[]'::json
|
|
) as participants
|
|
FROM matches m
|
|
LEFT JOIN match_participants mp ON m.match_id = mp.match_id
|
|
WHERE m.status = 'completed'
|
|
GROUP BY m.match_id, m.map_id, m.winner, m.turn_count, m.condition,
|
|
m.combat_turns, m.created_at, m.completed_at
|
|
ORDER BY m.completed_at DESC
|
|
LIMIT 1000
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var matches []MatchData
|
|
for rows.Next() {
|
|
var m MatchData
|
|
var winnerID sql.NullString
|
|
var participantsJSON []byte
|
|
|
|
err := rows.Scan(
|
|
&m.ID, &m.MapID, &winnerID, &m.TurnCount, &m.EndCondition,
|
|
&m.CombatTurns,
|
|
&m.CreatedAt, &m.CompletedAt, &participantsJSON,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if winnerID.Valid {
|
|
m.WinnerID = winnerID.String
|
|
}
|
|
if err := json.Unmarshal(participantsJSON, &m.Participants); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// PlayedAt is used for weekly filtering in blog/stats generation.
|
|
// CompletedAt is the authoritative timestamp; fall back to CreatedAt.
|
|
if !m.CompletedAt.IsZero() {
|
|
m.PlayedAt = m.CompletedAt
|
|
} else {
|
|
m.PlayedAt = m.CreatedAt
|
|
}
|
|
|
|
matches = append(matches, m)
|
|
}
|
|
|
|
return matches, nil
|
|
}
|
|
|
|
func fetchRatingHistory(ctx context.Context, db *sql.DB) ([]RatingHistoryEntry, error) {
|
|
query := `
|
|
SELECT bot_id, match_id, rating, recorded_at
|
|
FROM rating_history
|
|
ORDER BY recorded_at DESC
|
|
LIMIT 10000
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []RatingHistoryEntry
|
|
for rows.Next() {
|
|
var e RatingHistoryEntry
|
|
if err := rows.Scan(&e.BotID, &e.MatchID, &e.Rating, &e.RecordedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) {
|
|
query := `
|
|
SELECT s.id, s.bot_a_id, s.bot_b_id,
|
|
ba.name, bb.name,
|
|
s.format, s.a_wins, s.b_wins, s.status, s.winner_id,
|
|
COALESCE(s.bracket_round, ''), COALESCE(s.bracket_position, 0),
|
|
s.created_at, s.updated_at
|
|
FROM series s
|
|
JOIN bots ba ON s.bot_a_id = ba.bot_id
|
|
JOIN bots bb ON s.bot_b_id = bb.bot_id
|
|
ORDER BY s.created_at DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var series []SeriesData
|
|
for rows.Next() {
|
|
var s SeriesData
|
|
var winnerID sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&s.ID, &s.BotAID, &s.BotBID,
|
|
&s.BotAName, &s.BotBName,
|
|
&s.Format, &s.AWins, &s.BWins, &s.Status, &winnerID,
|
|
&s.BracketRound, &s.BracketPosition,
|
|
&s.CreatedAt, &s.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if winnerID.Valid {
|
|
s.WinnerID = winnerID.String
|
|
}
|
|
series = append(series, s)
|
|
}
|
|
|
|
for i := range series {
|
|
games, err := fetchSeriesGames(ctx, db, series[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
series[i].Games = games
|
|
}
|
|
|
|
return series, nil
|
|
}
|
|
|
|
func fetchSeriesGames(ctx context.Context, db *sql.DB, seriesID int64) ([]SeriesGameData, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT sg.match_id, sg.game_num, sg.winner_id,
|
|
COALESCE(m.turn_count, 0), m.completed_at,
|
|
CASE WHEN sg.winner_id IS NOT NULL THEN
|
|
(SELECT mp.player_slot FROM match_participants mp
|
|
WHERE mp.match_id = sg.match_id AND mp.bot_id = sg.winner_id)
|
|
END
|
|
FROM series_games sg
|
|
LEFT JOIN matches m ON sg.match_id = m.match_id
|
|
WHERE sg.series_id = $1
|
|
ORDER BY sg.game_num
|
|
`, seriesID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var games []SeriesGameData
|
|
for rows.Next() {
|
|
var g SeriesGameData
|
|
var winnerID sql.NullString
|
|
var winnerSlot sql.NullInt64
|
|
var turns sql.NullInt64
|
|
var completedAt sql.NullTime
|
|
|
|
err := rows.Scan(&g.MatchID, &g.GameNum, &winnerID, &turns, &completedAt, &winnerSlot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if winnerID.Valid {
|
|
g.WinnerID = winnerID.String
|
|
}
|
|
if winnerSlot.Valid {
|
|
slot := int(winnerSlot.Int64)
|
|
g.WinnerSlot = &slot
|
|
}
|
|
if turns.Valid && turns.Int64 > 0 {
|
|
g.Turns = int(turns.Int64)
|
|
}
|
|
if completedAt.Valid {
|
|
g.CompletedAt = &completedAt.Time
|
|
}
|
|
games = append(games, g)
|
|
}
|
|
|
|
return games, nil
|
|
}
|
|
|
|
func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) {
|
|
query := `
|
|
SELECT s.id, s.name, s.theme, s.rules_version, s.status,
|
|
s.champion_id, b.name,
|
|
s.starts_at, s.ends_at, s.created_at
|
|
FROM seasons s
|
|
LEFT JOIN bots b ON s.champion_id = b.bot_id
|
|
ORDER BY s.starts_at DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var seasons []SeasonData
|
|
for rows.Next() {
|
|
var s SeasonData
|
|
var theme, championID, championName sql.NullString
|
|
var endsAt sql.NullTime
|
|
|
|
err := rows.Scan(
|
|
&s.ID, &s.Name, &theme, &s.RulesVer, &s.Status,
|
|
&championID, &championName,
|
|
&s.StartsAt, &endsAt, &s.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if theme.Valid {
|
|
s.Theme = theme.String
|
|
}
|
|
if championID.Valid {
|
|
s.ChampionID = championID.String
|
|
}
|
|
if championName.Valid {
|
|
s.ChampionName = championName.String
|
|
}
|
|
if endsAt.Valid {
|
|
s.EndsAt = endsAt.Time
|
|
}
|
|
seasons = append(seasons, s)
|
|
}
|
|
|
|
// Enrich each season with match count, snapshots, and championship bracket
|
|
for i := range seasons {
|
|
seasons[i].TotalMatches, _ = getSeasonMatchCount(ctx, db, seasons[i].ID)
|
|
snapshots, err := fetchSeasonSnapshots(ctx, db, seasons[i].ID)
|
|
if err == nil && len(snapshots) > 0 {
|
|
seasons[i].Snapshots = snapshots
|
|
}
|
|
bracket, err := fetchChampionshipBracket(ctx, db, seasons[i].ID)
|
|
if err == nil && len(bracket) > 0 {
|
|
seasons[i].ChampionshipBracket = bracket
|
|
}
|
|
}
|
|
|
|
return seasons, nil
|
|
}
|
|
|
|
func getSeasonMatchCount(ctx context.Context, db *sql.DB, seasonID int64) (int, error) {
|
|
// Count matches from series in this season
|
|
var count int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT sg.match_id)
|
|
FROM series_games sg
|
|
JOIN series s ON sg.series_id = s.id
|
|
WHERE s.season_id = $1 AND sg.match_id IS NOT NULL
|
|
`, seasonID).Scan(&count)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func fetchSeasonSnapshots(ctx context.Context, db *sql.DB, seasonID int64) ([]SeasonSnapshotData, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT ss.bot_id, b.name, ss.rating, ss.rank, ss.wins, ss.losses
|
|
FROM season_snapshots ss
|
|
JOIN bots b ON ss.bot_id = b.bot_id
|
|
WHERE ss.season_id = $1
|
|
ORDER BY ss.rank
|
|
`, seasonID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var snapshots []SeasonSnapshotData
|
|
for rows.Next() {
|
|
var snap SeasonSnapshotData
|
|
if err := rows.Scan(&snap.BotID, &snap.BotName, &snap.Rating, &snap.Rank, &snap.Wins, &snap.Losses); err != nil {
|
|
return nil, err
|
|
}
|
|
snapshots = append(snapshots, snap)
|
|
}
|
|
|
|
return snapshots, nil
|
|
}
|
|
|
|
func fetchChampionshipBracket(ctx context.Context, db *sql.DB, seasonID int64) ([]ChampionshipSeries, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name,
|
|
s.format, s.a_wins, s.b_wins, s.status, s.winner_id,
|
|
COALESCE(s.bracket_round, 'quarterfinal'), COALESCE(s.bracket_position, 0)
|
|
FROM series s
|
|
JOIN bots ba ON s.bot_a_id = ba.bot_id
|
|
JOIN bots bb ON s.bot_b_id = bb.bot_id
|
|
WHERE s.season_id = $1 AND s.bracket_round IS NOT NULL
|
|
ORDER BY
|
|
CASE s.bracket_round
|
|
WHEN 'quarterfinal' THEN 0
|
|
WHEN 'semifinal' THEN 1
|
|
WHEN 'final' THEN 2
|
|
END,
|
|
s.bracket_position
|
|
`, seasonID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []ChampionshipSeries
|
|
for rows.Next() {
|
|
var cs ChampionshipSeries
|
|
var winnerID sql.NullString
|
|
if err := rows.Scan(&cs.ID, &cs.BotAID, &cs.BotAName, &cs.BotBID, &cs.BotBName,
|
|
&cs.Format, &cs.AWins, &cs.BWins, &cs.Status, &winnerID,
|
|
&cs.Round, &cs.BracketPosition); err != nil {
|
|
return nil, err
|
|
}
|
|
if winnerID.Valid {
|
|
cs.WinnerID = winnerID.String
|
|
}
|
|
result = append(result, cs)
|
|
}
|
|
|
|
// Fetch games for each series
|
|
for i := range result {
|
|
games, err := fetchSeriesGames(ctx, db, result[i].ID)
|
|
if err == nil {
|
|
result[i].Games = games
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func fetchPredictions(ctx context.Context, db *sql.DB) ([]PredictionData, error) {
|
|
query := `
|
|
SELECT id, match_id, predictor_id, predicted_bot, correct, created_at, resolved_at
|
|
FROM predictions
|
|
ORDER BY created_at DESC
|
|
LIMIT 1000
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var predictions []PredictionData
|
|
for rows.Next() {
|
|
var p PredictionData
|
|
var correct sql.NullBool
|
|
var resolvedAt sql.NullTime
|
|
|
|
err := rows.Scan(
|
|
&p.ID, &p.MatchID, &p.PredictorID, &p.PredictedBot,
|
|
&correct, &p.CreatedAt, &resolvedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if correct.Valid {
|
|
p.Correct = &correct.Bool
|
|
}
|
|
if resolvedAt.Valid {
|
|
p.ResolvedAt = &resolvedAt.Time
|
|
}
|
|
predictions = append(predictions, p)
|
|
}
|
|
|
|
return predictions, nil
|
|
}
|
|
|
|
func fetchPredictorStats(ctx context.Context, db *sql.DB) ([]PredictorStats, error) {
|
|
query := `
|
|
SELECT predictor_id, correct, incorrect, streak, best_streak
|
|
FROM predictor_stats
|
|
ORDER BY (correct::float / NULLIF(correct + incorrect, 0)) DESC NULLS LAST
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stats []PredictorStats
|
|
for rows.Next() {
|
|
var s PredictorStats
|
|
if err := rows.Scan(&s.PredictorID, &s.Correct, &s.Incorrect, &s.Streak, &s.BestStreak); err != nil {
|
|
return nil, err
|
|
}
|
|
stats = append(stats, s)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) {
|
|
query := `
|
|
SELECT m.map_id, m.player_count, m.status, m.engagement, m.wall_density,
|
|
m.energy_count, m.grid_width, m.grid_height, m.created_at, m.map_json,
|
|
COALESCE(v.vote_sum, 0) as net_votes
|
|
FROM maps m
|
|
LEFT JOIN (
|
|
SELECT map_id, SUM(vote)::int as vote_sum
|
|
FROM map_votes
|
|
GROUP BY map_id
|
|
) v ON m.map_id = v.map_id
|
|
WHERE m.status IN ('active', 'probation', 'classic')
|
|
ORDER BY m.engagement DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var maps []MapData
|
|
for rows.Next() {
|
|
var m MapData
|
|
if err := rows.Scan(
|
|
&m.MapID, &m.PlayerCount, &m.Status, &m.Engagement, &m.WallDensity,
|
|
&m.EnergyCount, &m.GridWidth, &m.GridHeight, &m.CreatedAt, &m.RawJSON,
|
|
&m.NetVotes,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
maps = append(maps, m)
|
|
}
|
|
|
|
return maps, nil
|
|
}
|
|
|
|
// fetchOpenPredictions retrieves pending matches that are "predictable":
|
|
// - Both bots are in the top 20
|
|
// - It's a rivalry match (at least 3 previous h2h matches)
|
|
// - It's a series match
|
|
// - An evolved bot faces a top-10 human-written bot
|
|
func fetchOpenPredictions(ctx context.Context, db *sql.DB) ([]OpenPredictionMatch, error) {
|
|
// Get all pending matches with their participants
|
|
query := `
|
|
SELECT m.match_id, m.created_at,
|
|
mp1.bot_id as bot_a_id, b1.name as bot_a_name,
|
|
(b1.rating_mu - 2*b1.rating_phi) as bot_a_rating,
|
|
b1.evolved as bot_a_evolved,
|
|
mp2.bot_id as bot_b_id, b2.name as bot_b_name,
|
|
(b2.rating_mu - 2*b2.rating_phi) as bot_b_rating,
|
|
b2.evolved as bot_b_evolved,
|
|
COALESCE(EXISTS(
|
|
SELECT 1 FROM series_games sg
|
|
WHERE sg.match_id = m.match_id
|
|
), false) as is_series_match
|
|
FROM matches m
|
|
JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0
|
|
JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1
|
|
JOIN bots b1 ON mp1.bot_id = b1.bot_id
|
|
JOIN bots b2 ON mp2.bot_id = b2.bot_id
|
|
WHERE m.status = 'pending'
|
|
ORDER BY m.created_at ASC
|
|
LIMIT 50
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query pending matches: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var allMatches []OpenPredictionMatch
|
|
for rows.Next() {
|
|
var m OpenPredictionMatch
|
|
var isSeries bool
|
|
err := rows.Scan(
|
|
&m.MatchID, &m.CreatedAt,
|
|
&m.BotAID, &m.BotAName, &m.ARating, &m.AEvolved,
|
|
&m.BotBID, &m.BotBName, &m.BRating, &m.BEvolved,
|
|
&isSeries,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan pending match: %w", err)
|
|
}
|
|
m.IsSeriesMatch = isSeries
|
|
allMatches = append(allMatches, m)
|
|
}
|
|
|
|
if len(allMatches) == 0 {
|
|
return []OpenPredictionMatch{}, nil
|
|
}
|
|
|
|
// Get top 20 bot IDs for top-20 vs top-20 check
|
|
topBotIDs := make(map[string]bool)
|
|
topRows, err := db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots
|
|
WHERE status = 'active'
|
|
ORDER BY rating_mu DESC
|
|
LIMIT 20
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query top bots: %w", err)
|
|
}
|
|
defer topRows.Close()
|
|
for topRows.Next() {
|
|
var botID string
|
|
if err := topRows.Scan(&botID); err != nil {
|
|
return nil, err
|
|
}
|
|
topBotIDs[botID] = true
|
|
}
|
|
|
|
// Get top 10 bot IDs for evolved vs top-10 check
|
|
top10BotIDs := make(map[string]bool)
|
|
top10Rows, err := db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots
|
|
WHERE status = 'active' AND evolved = false
|
|
ORDER BY rating_mu DESC
|
|
LIMIT 10
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query top 10 bots: %w", err)
|
|
}
|
|
defer top10Rows.Close()
|
|
for top10Rows.Next() {
|
|
var botID string
|
|
if err := top10Rows.Scan(&botID); err != nil {
|
|
return nil, err
|
|
}
|
|
top10BotIDs[botID] = true
|
|
}
|
|
|
|
// Build pair frequency map for rivalry detection (count completed h2h matches)
|
|
pairFrequency := make(map[string]int)
|
|
freqRows, err := db.QueryContext(ctx, `
|
|
SELECT mp1.bot_id, mp2.bot_id, COUNT(*)
|
|
FROM matches m
|
|
JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0
|
|
JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1
|
|
WHERE m.status = 'completed'
|
|
GROUP BY mp1.bot_id, mp2.bot_id
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query pair frequency: %w", err)
|
|
}
|
|
defer freqRows.Close()
|
|
for freqRows.Next() {
|
|
var botA, botB string
|
|
var count int
|
|
if err := freqRows.Scan(&botA, &botB, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
pairFrequency[botA+":"+botB] = count
|
|
}
|
|
|
|
// Filter matches that are "predictable"
|
|
var predictableMatches []OpenPredictionMatch
|
|
for _, m := range allMatches {
|
|
isPredictable := false
|
|
|
|
// Check: both bots in top 20
|
|
if topBotIDs[m.BotAID] && topBotIDs[m.BotBID] {
|
|
isPredictable = true
|
|
}
|
|
|
|
// Check: rivalry match (at least 3 previous h2h matches)
|
|
if freq, ok := pairFrequency[m.BotAID+":"+m.BotBID]; ok && freq >= 3 {
|
|
isPredictable = true
|
|
}
|
|
|
|
// Check: series match
|
|
if m.IsSeriesMatch {
|
|
isPredictable = true
|
|
}
|
|
|
|
// Check: evolved bot vs top-10 human-written bot
|
|
if m.AEvolved && top10BotIDs[m.BotBID] {
|
|
isPredictable = true
|
|
}
|
|
if m.BEvolved && top10BotIDs[m.BotAID] {
|
|
isPredictable = true
|
|
}
|
|
|
|
if isPredictable {
|
|
// Calculate head-to-head record
|
|
h2hRecord := computeHeadToHeadRecord(ctx, db, m.BotAID, m.BotBID)
|
|
m.HeadToHeadRecord = &h2hRecord
|
|
predictableMatches = append(predictableMatches, m)
|
|
}
|
|
|
|
// Limit to next 10 matches
|
|
if len(predictableMatches) >= 10 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return predictableMatches, nil
|
|
}
|
|
|
|
// computeHeadToHeadRecord returns the head-to-head record between two bots
|
|
func computeHeadToHeadRecord(ctx context.Context, db *sql.DB, botAID, botBID string) string {
|
|
var aWins, bWins int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE m.winner = 0) as a_wins,
|
|
COUNT(*) FILTER (WHERE m.winner = 1) as b_wins
|
|
FROM matches m
|
|
JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0
|
|
JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1
|
|
WHERE mp1.bot_id = $1 AND mp2.bot_id = $2 AND m.status = 'completed'
|
|
`, botAID, botBID).Scan(&aWins, &bWins)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d-%d", aWins, bWins)
|
|
}
|
|
|
|
func fetchFeedback(ctx context.Context, db *sql.DB) ([]FeedbackEntry, error) {
|
|
query := `
|
|
SELECT feedback_id, match_id, turn, type, body, author, upvotes, created_at
|
|
FROM replay_feedback
|
|
ORDER BY upvotes DESC, created_at DESC
|
|
LIMIT 5000
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []FeedbackEntry
|
|
for rows.Next() {
|
|
var e FeedbackEntry
|
|
if err := rows.Scan(&e.FeedbackID, &e.MatchID, &e.Turn, &e.Type, &e.Body, &e.Author, &e.Upvotes, &e.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func computeTopPredictors(stats []PredictorStats) []PredictorStats {
|
|
if len(stats) > 50 {
|
|
return stats[:50]
|
|
}
|
|
return stats
|
|
}
|
|
|
|
// persistPlaylists writes generated playlist definitions and their match associations
|
|
// to the playlists and playlist_matches tables. It uses upsert semantics so playlists
|
|
// are updated in place without creating duplicates.
|
|
func persistPlaylists(ctx context.Context, db *sql.DB, playlists []persistedPlaylist) error {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, pl := range playlists {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO playlists (slug, title, description, category, is_auto, updated_at)
|
|
VALUES ($1, $2, $3, $4, TRUE, NOW())
|
|
ON CONFLICT (slug) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
description = EXCLUDED.description,
|
|
category = EXCLUDED.category,
|
|
updated_at = NOW()
|
|
`, pl.Slug, pl.Title, pl.Description, pl.Category)
|
|
if err != nil {
|
|
return fmt.Errorf("persist playlist %s: %w", pl.Slug, err)
|
|
}
|
|
|
|
// Delete old match associations and re-insert
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM playlist_matches WHERE playlist_slug = $1`, pl.Slug)
|
|
if err != nil {
|
|
return fmt.Errorf("clear playlist_matches for %s: %w", pl.Slug, err)
|
|
}
|
|
|
|
for _, pm := range pl.Matches {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO playlist_matches (playlist_slug, match_id, sort_order, curation_tag)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, pl.Slug, pm.MatchID, pm.SortOrder, pm.CurationTag)
|
|
if err != nil {
|
|
return fmt.Errorf("persist playlist_match %s/%s: %w", pl.Slug, pm.MatchID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
type persistedPlaylist struct {
|
|
Slug string
|
|
Title string
|
|
Description string
|
|
Category string
|
|
Matches []persistedPlaylistMatch
|
|
}
|
|
|
|
type persistedPlaylistMatch struct {
|
|
MatchID string
|
|
SortOrder int
|
|
CurationTag string
|
|
}
|