From 347ae4f1df591e948957c463c93806575cdd22e3 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 14:08:15 -0400 Subject: [PATCH] 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 --- .needle-predispatch-sha | 2 +- cmd/acb-api/db.go | 5 +- cmd/acb-api/server.go | 190 ++++++++++++- cmd/acb-matchmaker/series_season.go | 139 +++++++++- cmd/acb-matchmaker/series_season_test.go | 329 +++++++++++++++++++++++ cmd/acb-worker/db.go | 151 +++++++---- web/src/api-types.ts | 21 ++ web/src/pages/predictions.ts | 169 +++++++++++- 8 files changed, 929 insertions(+), 77 deletions(-) create mode 100644 cmd/acb-matchmaker/series_season_test.go diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index a8a8578..a6b208d 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -00069b1870a0d79fcf3af56bf8885fd75b2e4258 +0d887ebeb2f2e3db51f92adc2225646f2b451fe2 diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index 4dc9e4c..8d73364 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -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() diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index 7c60bae..96b13f3 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -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. diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index 6e459b0..8c27f65 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -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 +} diff --git a/cmd/acb-matchmaker/series_season_test.go b/cmd/acb-matchmaker/series_season_test.go new file mode 100644 index 0000000..04a5c1d --- /dev/null +++ b/cmd/acb-matchmaker/series_season_test.go @@ -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) +} diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go index e24c1b4..2136660 100644 --- a/cmd/acb-worker/db.go +++ b/cmd/acb-worker/db.go @@ -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 +} diff --git a/web/src/api-types.ts b/web/src/api-types.ts index e701aa7..65f45d6 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -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; diff --git a/web/src/pages/predictions.ts b/web/src/pages/predictions.ts index 0fb2d1f..19fc0c4 100644 --- a/web/src/pages/predictions.ts +++ b/web/src/pages/predictions.ts @@ -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 | null = null; let predictorId = ''; export async function renderPredictionsPage(): Promise { @@ -59,6 +61,13 @@ export async function renderPredictionsPage(): Promise { +
+

Your Predictions

+
+
Loading your predictions...
+
+
+

Top Predictors

@@ -172,6 +181,82 @@ export async function renderPredictionsPage(): Promise { 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 { `; - // 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 { @@ -506,6 +604,9 @@ async function handlePick(e: Event): Promise { 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 { } } +async function loadHistory(): Promise { + 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 = '
You haven\'t made any predictions yet. Pick a bot above!
'; + 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 ` +
+
${icon}
+
+
Picked ${escapeHtml(p.predicted_name || p.predicted_bot)}
+
${formatTimeAgo(p.created_at)}
+
+ ${statusText} +
+ `; + }).join(''); + } catch (err) { + console.error('Failed to load prediction history:', err); + container.innerHTML = '
Failed to load prediction history
'; + } +} + +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 { const container = document.getElementById('leaderboard-container'); if (!container) return;