feat(predictions): resolve predictions on match completion, add API endpoints and frontend

- Worker resolves open predictions after writing match results (resolvePredictions + upsertPredictorStats)
- API endpoints: POST /api/predict, GET /api/predictions/open, GET /api/predictions/history
- Frontend /watch/predictions page with polling, prediction submission, and history display
- predictor_stats table tracks streaks and accuracy per predictor
- Series format selection: fix threshold from >200 to >=200 for bo3 eligibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 14:08:15 -04:00
parent 0d887ebeb2
commit 347ae4f1df
8 changed files with 929 additions and 77 deletions

View file

@ -1 +1 @@
00069b1870a0d79fcf3af56bf8885fd75b2e4258
0d887ebeb2f2e3db51f92adc2225646f2b451fe2

View file

@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS predictions (
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
predictor_id VARCHAR(64) NOT NULL,
predicted_bot VARCHAR(16) NOT NULL,
confidence SMALLINT CHECK (confidence >= 1 AND confidence <= 100),
correct BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
@ -39,16 +40,18 @@ CREATE TABLE IF NOT EXISTS series (
b_wins INTEGER NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
winner_id VARCHAR(16),
season_id BIGINT REFERENCES seasons(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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 TABLE IF NOT EXISTS series_games (
id BIGSERIAL PRIMARY KEY,
series_id BIGINT NOT NULL REFERENCES series(id),
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
match_id VARCHAR(32) REFERENCES matches(match_id),
game_num INTEGER NOT NULL,
winner_id VARCHAR(16),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

View file

@ -49,6 +49,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// Predictions
mux.HandleFunc("POST /api/predict", s.handlePredict)
mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions)
mux.HandleFunc("GET /api/predictions/history", s.handlePredictionHistory)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@ -413,6 +414,11 @@ func (s *Server) handleJobResult(w http.ResponseWriter, r *http.Request) {
// Note: Rating updates are handled by the worker separately via the rating endpoint
// or can be computed here if the ratings are provided in the request
// Resolve predictions for this match
if err := s.resolvePredictions(ctx, tx, matchID, req.WinnerID); err != nil {
log.Printf("failed to resolve predictions for match %s: %v", matchID, err)
}
if err := tx.Commit(); err != nil {
log.Printf("failed to commit transaction: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
@ -741,7 +747,7 @@ func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) {
}
// handlePredict handles POST /api/predict
// Accepts {match_id, bot_id, confidence} and writes to predictions table.
// Accepts {match_id, bot_id, confidence, predictor_id} and writes to predictions table.
// Rejects if the match has already started (status != 'pending').
func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@ -753,6 +759,7 @@ func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
MatchID string `json:"match_id"`
BotID string `json:"bot_id"`
Predictor string `json:"predictor_id"`
Confidence *int `json:"confidence"` // optional 1-100
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
@ -764,6 +771,11 @@ func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
return
}
if req.Confidence != nil && (*req.Confidence < 1 || *req.Confidence > 100) {
writeError(w, http.StatusBadRequest, "confidence must be between 1 and 100")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
@ -807,23 +819,28 @@ func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) {
// Insert prediction (UNIQUE constraint handles duplicates)
var predictionID int64
err = s.db.QueryRowContext(ctx, `
INSERT INTO predictions (match_id, predictor_id, predicted_bot)
VALUES ($1, $2, $3)
ON CONFLICT (match_id, predictor_id) DO UPDATE SET predicted_bot = $3
INSERT INTO predictions (match_id, predictor_id, predicted_bot, confidence)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, predictor_id) DO UPDATE SET predicted_bot = $3, confidence = $4
RETURNING id
`, req.MatchID, req.Predictor, req.BotID).Scan(&predictionID)
`, req.MatchID, req.Predictor, req.BotID, req.Confidence).Scan(&predictionID)
if err != nil {
log.Printf("failed to insert prediction: %v", err)
writeError(w, http.StatusInternalServerError, "failed to submit prediction")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
resp := map[string]interface{}{
"id": predictionID,
"match_id": req.MatchID,
"predicted": req.BotID,
"predictor": req.Predictor,
})
}
if req.Confidence != nil {
resp["confidence"] = *req.Confidence
}
writeJSON(w, http.StatusCreated, resp)
}
// handleOpenPredictions handles GET /api/predictions/open
@ -896,6 +913,165 @@ func (s *Server) handleOpenPredictions(w http.ResponseWriter, r *http.Request) {
})
}
// resolvePredictions marks open predictions as correct/incorrect and updates predictor_stats.
func (s *Server) resolvePredictions(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error {
var rows *sql.Rows
var err error
if winnerBotID == "" {
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = false, resolved_at = NOW()
WHERE match_id = $1 AND correct IS NULL
RETURNING predictor_id, correct
`, matchID)
} else {
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = (predicted_bot = $1), resolved_at = NOW()
WHERE match_id = $2 AND correct IS NULL
RETURNING predictor_id, correct
`, winnerBotID, matchID)
}
if err != nil {
return fmt.Errorf("failed to resolve predictions: %w", err)
}
defer rows.Close()
for rows.Next() {
var predictorID string
var correct bool
if err := rows.Scan(&predictorID, &correct); err != nil {
return fmt.Errorf("failed to scan resolved prediction: %w", err)
}
if err := s.upsertPredictorStats(ctx, tx, predictorID, correct); err != nil {
return fmt.Errorf("failed to update predictor_stats for %s: %w", predictorID, err)
}
}
return nil
}
// upsertPredictorStats updates the predictor_stats row for a single resolution.
func (s *Server) upsertPredictorStats(ctx context.Context, tx *sql.Tx, predictorID string, correct bool) error {
if correct {
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak, updated_at)
VALUES ($1, 1, 1, 1, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
correct = predictor_stats.correct + 1,
streak = predictor_stats.streak + 1,
best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
updated_at = NOW()
`, predictorID)
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, incorrect, streak, best_streak, updated_at)
VALUES ($1, 1, 0, 0, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
incorrect = predictor_stats.incorrect + 1,
streak = 0,
updated_at = NOW()
`, predictorID)
return err
}
// handlePredictionHistory handles GET /api/predictions/history
// Returns resolved predictions for a predictor, used for polling resolution status.
func (s *Server) handlePredictionHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
predictorID := r.URL.Query().Get("predictor_id")
if predictorID == "" {
writeError(w, http.StatusBadRequest, "predictor_id is required")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 20
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx, `
SELECT p.id, p.match_id, p.predicted_bot,
COALESCE(wb.name, p.predicted_bot) AS predicted_name,
p.correct, p.confidence, p.created_at, p.resolved_at,
m.status AS match_status, m.winner,
COALESCE(CASE WHEN m.winner IS NOT NULL THEN
(SELECT b.name FROM match_participants mp2 JOIN bots b ON mp2.bot_id = b.bot_id
WHERE mp2.match_id = m.match_id AND mp2.player_slot = m.winner)
END, '') AS winner_name
FROM predictions p
JOIN matches m ON p.match_id = m.match_id
LEFT JOIN bots wb ON p.predicted_bot = wb.bot_id
WHERE p.predictor_id = $1
ORDER BY COALESCE(p.resolved_at, p.created_at) DESC
LIMIT $2
`, predictorID, limit)
if err != nil {
log.Printf("database error fetching prediction history: %v", err)
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type PredictionEntry struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
PredictedBot string `json:"predicted_bot"`
PredictedName string `json:"predicted_name"`
Correct *bool `json:"correct"`
Confidence *int `json:"confidence,omitempty"`
CreatedAt string `json:"created_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
MatchStatus string `json:"match_status"`
WinnerName string `json:"winner_name,omitempty"`
}
var predictions []PredictionEntry
for rows.Next() {
var p PredictionEntry
var createdAt time.Time
var resolvedAt sql.NullTime
var winnerName sql.NullString
var winnerSlot sql.NullInt64
if err := rows.Scan(&p.ID, &p.MatchID, &p.PredictedBot, &p.PredictedName,
&p.Correct, &p.Confidence, &createdAt, &resolvedAt,
&p.MatchStatus, &winnerSlot, &winnerName); err != nil {
log.Printf("error scanning prediction: %v", err)
continue
}
p.CreatedAt = createdAt.Format(time.RFC3339)
if resolvedAt.Valid {
s := resolvedAt.Time.Format(time.RFC3339)
p.ResolvedAt = &s
}
if winnerName.Valid {
p.WinnerName = winnerName.String
}
predictions = append(predictions, p)
}
if predictions == nil {
predictions = []PredictionEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"predictions": predictions,
})
}
// handleUIFeedback handles POST /api/ui-feedback
// Accepts Agentation UI feedback (annotations, issues, etc.).
// Stores in database or logs to disk.

View file

@ -184,6 +184,8 @@ func (m *Matchmaker) scheduleNextSeriesGames(ctx context.Context) error {
}
// 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
@ -217,8 +219,14 @@ func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, bot
return err
}
mapSeed := rng.Int63()
mapID := fmt.Sprintf("map_%d", mapSeed%100000)
// 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"`
@ -243,11 +251,11 @@ func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, bot
GameNum: gameNum,
MapSeed: mapSeed,
MaxTurns: 500,
Rows: 60,
Cols: 60,
Rows: rows,
Cols: cols,
Bots: []botConfig{
{BotID: botAID, Endpoint: endpointA, Secret: secretA, Slot: 0},
{BotID: botBID, Endpoint: endpointB, Secret: secretB, Slot: 1},
{BotID: botAID, Endpoint: endpointA, Secret: secretA, Slot: slotA},
{BotID: botBID, Endpoint: endpointB, Secret: secretB, Slot: slotB},
},
}
configJSON, _ := json.Marshal(config)
@ -266,8 +274,8 @@ func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, bot
}
_, err = tx.ExecContext(ctx,
`INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES ($1, $2, 0), ($1, $3, 1)`,
matchID, botAID, botBID)
`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)
}
@ -300,6 +308,41 @@ func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, bot
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 = 'active'
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 {
@ -385,7 +428,7 @@ func (m *Matchmaker) autoCreateSeries(ctx context.Context) error {
}
if ratingGap < 50 {
format = 7 // close ratings → best-of-7
} else if ratingGap > 200 {
} else if ratingGap >= 200 {
format = 3 // large gap → best-of-3
}
@ -401,14 +444,13 @@ func (m *Matchmaker) autoCreateSeries(ctx context.Context) error {
`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, updated_at)
VALUES ($1, $2, $3, 'active', 0, 0, NOW())
`, botAID, botBID, format)
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
}
_ = seasonID // use in future season-series linking
log.Printf("series-scheduler: created best-of-%d series: %s vs %s", format, botAID, botBID)
}
@ -538,6 +580,11 @@ func (m *Matchmaker) processSeasonEnd(ctx context.Context, seasonID int64, seaso
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)
@ -578,3 +625,69 @@ func (m *Matchmaker) autoStartSeason(ctx context.Context) {
log.Printf("season-reset: auto-started %s (%s) — ends in 28 days", seasonName, theme)
}
// 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
rows, err := m.db.QueryContext(ctx, `
SELECT bot_id FROM bots
WHERE status = 'active'
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 := [][2]string{
{botIDs[0], botIDs[7]}, // #1 vs #8
{botIDs[3], botIDs[4]}, // #4 vs #5
{botIDs[2], botIDs[5]}, // #3 vs #6
{botIDs[1], botIDs[6]}, // #2 vs #7
}
round := [4]string{"Quarterfinals", "Quarterfinals", "Quarterfinals", "Quarterfinals"}
for i, 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, updated_at)
VALUES ($1, $2, 7, 'active', 0, 0, $3, NOW())
`, matchup[0], matchup[1], seasonID)
if err != nil {
log.Printf("season-reset: failed to create championship %s series (%s vs %s): %v",
round[i], matchup[0], matchup[1], err)
continue
}
log.Printf("season-reset: created championship %s series: %s vs %s (bo7)",
round[i], matchup[0], matchup[1])
}
return nil
}

View file

@ -0,0 +1,329 @@
package main
import (
"testing"
)
func TestConfigEnvFloat(t *testing.T) {
tests := []struct {
key string
value string
fallback float64
want float64
}{
{"ACB_TEST_FLOAT", "0.5", 0.7, 0.5},
{"ACB_TEST_FLOAT", "1.0", 0.7, 1.0},
{"ACB_TEST_FLOAT", "", 0.7, 0.7},
{"ACB_TEST_FLOAT", "invalid", 0.7, 0.7},
}
for _, tc := range tests {
t.Run(tc.value, func(t *testing.T) {
t.Setenv(tc.key, tc.value)
got := envFloat(tc.key, tc.fallback)
if got != tc.want {
t.Errorf("envFloat(%q, %v) = %v, want %v", tc.key, tc.fallback, got, tc.want)
}
})
}
}
func TestLoadConfigSeriesAndSeason(t *testing.T) {
t.Setenv("ACB_SERIES_SCHED_INTERVAL", "180")
t.Setenv("ACB_SEASON_RESET_INTERVAL", "600")
t.Setenv("ACB_SEASON_DECAY_FACTOR", "0.8")
cfg := loadConfig()
if cfg.SeriesSchedSecs != 180 {
t.Errorf("SeriesSchedSecs: got %d, want 180", cfg.SeriesSchedSecs)
}
if cfg.SeasonResetSecs != 600 {
t.Errorf("SeasonResetSecs: got %d, want 600", cfg.SeasonResetSecs)
}
if cfg.SeasonDecayFactor != 0.8 {
t.Errorf("SeasonDecayFactor: got %f, want 0.8", cfg.SeasonDecayFactor)
}
}
func TestLoadConfigSeriesAndSeasonDefaults(t *testing.T) {
cfg := loadConfig()
if cfg.SeriesSchedSecs != 120 {
t.Errorf("SeriesSchedSecs default: got %d, want 120", cfg.SeriesSchedSecs)
}
if cfg.SeasonResetSecs != 300 {
t.Errorf("SeasonResetSecs default: got %d, want 300", cfg.SeasonResetSecs)
}
if cfg.SeasonDecayFactor != 0.7 {
t.Errorf("SeasonDecayFactor default: got %f, want 0.7", cfg.SeasonDecayFactor)
}
}
func TestDecayFormula(t *testing.T) {
// Validate the decay formula: new_mu = default + (current_mu - default) * factor
// With default=1500 and factor=0.7:
// mu=2000 → 1500 + 500*0.7 = 1850
// mu=1000 → 1500 + (-500)*0.7 = 1150
// mu=1500 → 1500 + 0*0.7 = 1500
defaultMu := 1500.0
factor := 0.7
tests := []struct {
current float64
want float64
}{
{2000, 1850},
{1000, 1150},
{1500, 1500},
{1800, 1710},
{1200, 1290},
{3000, 2550}, // extreme high
{500, 800}, // extreme low
}
for _, tc := range tests {
result := defaultMu + (tc.current-defaultMu)*factor
if result != tc.want {
t.Errorf("decay(%v) = %v, want %v", tc.current, result, tc.want)
}
}
}
func TestDecayPreservesRankOrder(t *testing.T) {
// Decay should never change relative ordering
defaultMu := 1500.0
factor := 0.7
ratings := []float64{2200, 2000, 1800, 1600, 1500, 1400, 1200, 1000, 800}
decayed := make([]float64, len(ratings))
for i, r := range ratings {
decayed[i] = defaultMu + (r-defaultMu)*factor
}
for i := 1; i < len(decayed); i++ {
if decayed[i] >= decayed[i-1] {
t.Errorf("rank order violated after decay: %.1f (from %.1f) >= %.1f (from %.1f)",
decayed[i], ratings[i], decayed[i-1], ratings[i-1])
}
}
}
func TestDecayDifferentFactors(t *testing.T) {
defaultMu := 1500.0
// Factor=0.5 means ratings are pulled halfway to the default
tests := []struct {
factor float64
current float64
want float64
}{
{0.0, 2000, 1500}, // full reset
{0.5, 2000, 1750}, // half decay
{1.0, 2000, 2000}, // no decay
{0.3, 1000, 1350}, // heavy decay toward center
{0.9, 1000, 1050}, // light decay
}
for _, tc := range tests {
result := defaultMu + (tc.current-defaultMu)*tc.factor
if result != tc.want {
t.Errorf("decay(%v, factor=%v) = %v, want %v", tc.current, tc.factor, result, tc.want)
}
}
}
func TestSeriesWinsNeeded(t *testing.T) {
// ceil(format/2) gives wins needed for each format
tests := []struct {
format int
want int
}{
{3, 2},
{5, 3},
{7, 4},
{1, 1},
{9, 5},
}
for _, tc := range tests {
got := (tc.format + 1) / 2
if got != tc.want {
t.Errorf("winsNeeded(%d) = %d, want %d", tc.format, got, tc.want)
}
}
}
func TestSeriesFormatSelection(t *testing.T) {
// Validate the rating-gap-based format selection logic from autoCreateSeries
tests := []struct {
gap float64
format int
}{
{0, 7}, // identical ratings → bo7
{25, 7}, // small gap → bo7
{49, 7}, // just under threshold → bo7
{50, 5}, // at threshold → bo5
{100, 5}, // moderate gap → bo5
{199, 5}, // just under threshold → bo5
{200, 3}, // at threshold → bo3
{500, 3}, // large gap → bo3
}
for _, tc := range tests {
format := 5
if tc.gap < 50 {
format = 7
} else if tc.gap >= 200 {
format = 3
}
if format != tc.format {
t.Errorf("formatSelection(gap=%.0f) = %d, want %d", tc.gap, format, tc.format)
}
}
}
func TestGenerateIDFormat(t *testing.T) {
id, err := generateID("m_", 8)
if err != nil {
t.Fatalf("generateID error: %v", err)
}
if len(id) != 18 { // "m_" + 16 hex chars
t.Errorf("id length: got %d, want 18", len(id))
}
if id[:2] != "m_" {
t.Errorf("id prefix: got %q, want %q", id[:2], "m_")
}
id2, err := generateID("j_", 8)
if err != nil {
t.Fatalf("generateID error: %v", err)
}
if id2[:2] != "j_" {
t.Errorf("id prefix: got %q, want %q", id2[:2], "j_")
}
}
func TestGenerateIDUniqueness(t *testing.T) {
ids := make(map[string]bool)
for i := 0; i < 1000; i++ {
id, err := generateID("t_", 8)
if err != nil {
t.Fatalf("generateID error: %v", err)
}
if ids[id] {
t.Fatalf("duplicate ID generated: %s", id)
}
ids[id] = true
}
}
func TestSeasonAutoStartNaming(t *testing.T) {
// Validate season naming convention: "Season N" where N = max_id + 1
tests := []struct {
maxID int
name string
}{
{0, "Season 1"},
{1, "Season 2"},
{5, "Season 6"},
}
for _, tc := range tests {
nextNum := tc.maxID + 1
expectedName := "Season " + itoa(nextNum)
if expectedName != tc.name {
t.Errorf("seasonName(maxID=%d) = %q, want %q", tc.maxID, expectedName, tc.name)
}
}
}
func TestSeasonThemeCycling(t *testing.T) {
themes := []string{"The Labyrinth", "Energy Rush", "Fog of War", "The Colosseum", "Shifting Sands"}
tests := []struct {
seasonNum int
want string
}{
{1, "The Labyrinth"},
{2, "Energy Rush"},
{3, "Fog of War"},
{4, "The Colosseum"},
{5, "Shifting Sands"},
{6, "The Labyrinth"}, // cycles
{10, "Shifting Sands"},
}
for _, tc := range tests {
theme := themes[(tc.seasonNum-1)%len(themes)]
if theme != tc.want {
t.Errorf("theme(season=%d) = %q, want %q", tc.seasonNum, theme, tc.want)
}
}
}
func TestSeriesFinalizationThresholds(t *testing.T) {
// Verify that series are finalized at exactly the right win count
tests := []struct {
format int
aWins int
bWins int
finished bool
winner string // "a" or "b"
}{
{3, 2, 0, true, "a"},
{3, 0, 2, true, "b"},
{3, 1, 1, false, ""},
{3, 2, 1, true, "a"},
{5, 3, 0, true, "a"},
{5, 2, 2, false, ""},
{5, 2, 3, true, "b"},
{7, 4, 0, true, "a"},
{7, 3, 3, false, ""},
{7, 3, 4, true, "b"},
}
for _, tc := range tests {
winsNeeded := (tc.format + 1) / 2
aDone := tc.aWins >= winsNeeded
bDone := tc.bWins >= winsNeeded
finished := aDone || bDone
if finished != tc.finished {
t.Errorf("format=%d a=%d b=%d: finished=%v, want %v", tc.format, tc.aWins, tc.bWins, finished, tc.finished)
continue
}
if finished {
winner := ""
if aDone {
winner = "a"
} else {
winner = "b"
}
if winner != tc.winner {
t.Errorf("format=%d a=%d b=%d: winner=%s, want %s", tc.format, tc.aWins, tc.bWins, winner, tc.winner)
}
}
}
}
// itoa is a simple int-to-string helper for tests.
func itoa(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
neg := false
if n < 0 {
neg = true
n = -n
}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
if neg {
digits = append([]byte{'-'}, digits...)
}
return string(digits)
}

View file

@ -388,6 +388,11 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result *
log.Printf("failed to resolve predictions for match %s: %v", matchID, err)
}
// Update series tables if this match is part of a series
if err := updateSeriesResult(ctx, tx, matchID, result.WinnerID); err != nil {
log.Printf("failed to update series result for match %s: %v", matchID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
@ -396,83 +401,71 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result *
}
// resolvePredictions marks open predictions as correct/incorrect and updates predictor_stats.
// Uses RETURNING to only process predictions that were just resolved, preventing double-counting.
func resolvePredictions(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error {
var rows *sql.Rows
var err error
if winnerBotID == "" {
// Draw or no winner — mark all open predictions as incorrect
_, err := tx.ExecContext(ctx, `
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = false, resolved_at = NOW()
WHERE match_id = $1 AND correct IS NULL
RETURNING predictor_id, correct
`, matchID)
if err != nil {
return fmt.Errorf("failed to resolve predictions (draw): %w", err)
}
} else {
// Mark predictions correct where predicted_bot matches winner, incorrect otherwise
_, err := tx.ExecContext(ctx, `
rows, err = tx.QueryContext(ctx, `
UPDATE predictions
SET correct = (predicted_bot = $1), resolved_at = NOW()
WHERE match_id = $2 AND correct IS NULL
RETURNING predictor_id, correct
`, winnerBotID, matchID)
if err != nil {
return fmt.Errorf("failed to resolve predictions: %w", err)
}
}
// Update predictor_stats for each predictor who had a prediction on this match
rows, err := tx.QueryContext(ctx, `
SELECT predictor_id, correct
FROM predictions
WHERE match_id = $1 AND resolved_at IS NOT NULL
`, matchID)
if err != nil {
return fmt.Errorf("failed to fetch resolved predictions: %w", err)
return fmt.Errorf("failed to resolve predictions: %w", err)
}
defer rows.Close()
type predResult struct {
PredictorID string
Correct bool
}
var results []predResult
for rows.Next() {
var r predResult
if err := rows.Scan(&r.PredictorID, &r.Correct); err != nil {
return fmt.Errorf("failed to scan prediction: %w", err)
var predictorID string
var correct bool
if err := rows.Scan(&predictorID, &correct); err != nil {
return fmt.Errorf("failed to scan resolved prediction: %w", err)
}
results = append(results, r)
}
// Upsert predictor_stats for each predictor
for _, r := range results {
if r.Correct {
_, err = tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak, updated_at)
VALUES ($1, 1, 1, 1, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
correct = predictor_stats.correct + 1,
streak = predictor_stats.streak + 1,
best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
updated_at = NOW()
`, r.PredictorID)
} else {
_, err = tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, incorrect, streak, best_streak, updated_at)
VALUES ($1, 1, 0, 0, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
incorrect = predictor_stats.incorrect + 1,
streak = 0,
updated_at = NOW()
`, r.PredictorID)
}
if err != nil {
return fmt.Errorf("failed to update predictor_stats for %s: %w", r.PredictorID, err)
if err := upsertPredictorStats(ctx, tx, predictorID, correct); err != nil {
return fmt.Errorf("failed to update predictor_stats for %s: %w", predictorID, err)
}
}
return nil
}
// upsertPredictorStats updates the predictor_stats row for a single resolution.
func upsertPredictorStats(ctx context.Context, tx *sql.Tx, predictorID string, correct bool) error {
if correct {
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak, updated_at)
VALUES ($1, 1, 1, 1, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
correct = predictor_stats.correct + 1,
streak = predictor_stats.streak + 1,
best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1),
updated_at = NOW()
`, predictorID)
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO predictor_stats (predictor_id, incorrect, streak, best_streak, updated_at)
VALUES ($1, 1, 0, 0, NOW())
ON CONFLICT (predictor_id) DO UPDATE SET
incorrect = predictor_stats.incorrect + 1,
streak = 0,
updated_at = NOW()
`, predictorID)
return err
}
// FailJob marks a job as failed.
func (c *DBClient) FailJob(ctx context.Context, jobID string, workerID string, errorMessage string) error {
result, err := c.db.ExecContext(ctx, `
@ -540,3 +533,57 @@ func (c *DBClient) GetBotRatings(ctx context.Context, botIDs []string) (map[stri
return ratings, nil
}
// updateSeriesResult updates series_games.winner_id and series.a_wins/b_wins
// when a match that belongs to a series completes.
func updateSeriesResult(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error {
// Find the series_game for this match
var seriesID int64
var gameNum int
err := tx.QueryRowContext(ctx, `
SELECT series_id, game_num FROM series_games WHERE match_id = $1
`, matchID).Scan(&seriesID, &gameNum)
if err == sql.ErrNoRows {
return nil // not a series game
}
if err != nil {
return fmt.Errorf("find series game: %w", err)
}
// Update the series_games row with the winner
if _, err := tx.ExecContext(ctx, `
UPDATE series_games SET winner_id = $1 WHERE match_id = $2
`, winnerBotID, matchID); err != nil {
return fmt.Errorf("update series game winner: %w", err)
}
// Increment a_wins or b_wins on the series
if winnerBotID == "" {
return nil // draw — no increment
}
// Determine if the winner is bot_a or bot_b
var botAID string
err = tx.QueryRowContext(ctx, `
SELECT bot_a_id FROM series WHERE id = $1
`, seriesID).Scan(&botAID)
if err != nil {
return fmt.Errorf("find series bot_a: %w", err)
}
if winnerBotID == botAID {
_, err = tx.ExecContext(ctx, `
UPDATE series SET a_wins = a_wins + 1, updated_at = NOW() WHERE id = $1
`, seriesID)
} else {
_, err = tx.ExecContext(ctx, `
UPDATE series SET b_wins = b_wins + 1, updated_at = NOW() WHERE id = $1
`, seriesID)
}
if err != nil {
return fmt.Errorf("increment series wins: %w", err)
}
log.Printf("series: game %d result recorded — series %d, winner=%s", gameNum, seriesID, winnerBotID)
return nil
}

View file

@ -451,6 +451,27 @@ export interface PredictionData {
resolved_at?: string;
}
export interface PredictionHistoryEntry {
id: number;
match_id: string;
predicted_bot: string;
predicted_name: string;
correct: boolean | null;
confidence?: number;
created_at: string;
resolved_at?: string;
match_status: string;
winner_name?: string;
}
export async function fetchPredictionHistory(predictorId: string, limit?: number): Promise<{ predictions: PredictionHistoryEntry[] }> {
const params = new URLSearchParams({ predictor_id: predictorId });
if (limit) params.set('limit', String(limit));
const response = await fetch(`/api/predictions/history?${params}`);
if (!response.ok) throw new Error(`Failed to fetch prediction history: ${response.status}`);
return response.json();
}
export interface PredictorStats {
predictor_id: string;
correct: number;

View file

@ -1,16 +1,18 @@
// Predictions Page - Prediction leaderboard, open matches, and submission
import type { BotProfile, PredictorStats, OpenMatch } from '../api-types';
import type { BotProfile, PredictorStats, OpenMatch, PredictionHistoryEntry } from '../api-types';
import {
fetchPredictionsLeaderboard,
fetchOpenPredictions,
submitPrediction,
getOrCreatePredictorId,
fetchPredictionHistory,
} from '../api-types';
const PAGES_BASE = '';
const API_BASE = '';
let openMatches: OpenMatch[] = [];
let pollTimer: ReturnType<typeof setInterval> | null = null;
let predictorId = '';
export async function renderPredictionsPage(): Promise<void> {
@ -59,6 +61,13 @@ export async function renderPredictionsPage(): Promise<void> {
</div>
</div>
<div class="history-section">
<h2>Your Predictions</h2>
<div id="history-container">
<div class="loading">Loading your predictions...</div>
</div>
</div>
<div class="leaderboard-section">
<h2>Top Predictors</h2>
<div id="leaderboard-container">
@ -172,6 +181,82 @@ export async function renderPredictionsPage(): Promise<void> {
margin-bottom: 16px;
}
.history-section {
margin-bottom: 32px;
}
.history-section h2 {
margin-bottom: 16px;
}
.history-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 14px 20px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 14px;
}
.history-card .result-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 700;
flex-shrink: 0;
}
.history-card .result-icon.correct {
background-color: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.history-card .result-icon.incorrect {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.history-card .result-icon.pending {
background-color: rgba(107, 114, 128, 0.2);
color: #94a3b8;
}
.history-card .history-details {
flex: 1;
min-width: 0;
}
.history-card .history-match {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-card .history-meta {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2px;
}
.history-card .history-status {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 4px;
flex-shrink: 0;
}
.history-card .history-status.correct { background: rgba(34,197,94,0.15); color: #22c55e; }
.history-card .history-status.incorrect { background: rgba(239,68,68,0.15); color: #ef4444; }
.history-card .history-status.pending { background: rgba(107,114,128,0.15); color: #94a3b8; }
.open-match-card {
background-color: var(--bg-secondary);
border-radius: 8px;
@ -418,8 +503,21 @@ export async function renderPredictionsPage(): Promise<void> {
</style>
`;
// Load open matches and leaderboard in parallel
await Promise.all([loadOpenMatches(), loadLeaderboard()]);
// Load open matches, leaderboard, and history in parallel
await Promise.all([loadOpenMatches(), loadLeaderboard(), loadHistory()]);
// Poll for resolved predictions every 15 seconds
pollTimer = setInterval(async () => {
await Promise.all([loadOpenMatches(), loadHistory()]);
}, 15000);
}
// Cleanup polling when navigating away (called by SPA router)
export function cleanupPredictionsPage(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function loadOpenMatches(): Promise<void> {
@ -506,6 +604,9 @@ async function handlePick(e: Event): Promise<void> {
card.querySelectorAll('.pick-btn:not(.picked)').forEach(b => {
(b as HTMLButtonElement).textContent = 'Not picked';
});
// Refresh history to show the new prediction
loadHistory();
} catch (err) {
console.error('Failed to submit prediction:', err);
btn.textContent = 'Error';
@ -519,6 +620,68 @@ async function handlePick(e: Event): Promise<void> {
}
}
async function loadHistory(): Promise<void> {
const container = document.getElementById('history-container');
if (!container) return;
try {
const data = await fetchPredictionHistory(predictorId, 20);
const predictions = data.predictions || [];
if (predictions.length === 0) {
container.innerHTML = '<div class="empty-message">You haven\'t made any predictions yet. Pick a bot above!</div>';
return;
}
container.innerHTML = predictions.map(p => {
let icon: string, iconClass: string, statusText: string, statusClass: string;
if (p.correct === true) {
icon = '✓';
iconClass = 'correct';
statusText = 'Correct!';
statusClass = 'correct';
} else if (p.correct === false) {
icon = '✗';
iconClass = 'incorrect';
statusText = p.winner_name ? `Wrong — ${p.winner_name} won` : 'Wrong';
statusClass = 'incorrect';
} else {
icon = '…';
iconClass = 'pending';
statusText = 'Pending';
statusClass = 'pending';
}
return `
<div class="history-card">
<div class="result-icon ${iconClass}">${icon}</div>
<div class="history-details">
<div class="history-match">Picked ${escapeHtml(p.predicted_name || p.predicted_bot)}</div>
<div class="history-meta">${formatTimeAgo(p.created_at)}</div>
</div>
<span class="history-status ${statusClass}">${statusText}</span>
</div>
`;
}).join('');
} catch (err) {
console.error('Failed to load prediction history:', err);
container.innerHTML = '<div class="empty-message">Failed to load prediction history</div>';
}
}
function formatTimeAgo(isoString: string): string {
const date = new Date(isoString);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
async function loadLeaderboard(): Promise<void> {
const container = document.getElementById('leaderboard-container');
if (!container) return;