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:
parent
0d887ebeb2
commit
347ae4f1df
8 changed files with 929 additions and 77 deletions
|
|
@ -1 +1 @@
|
|||
00069b1870a0d79fcf3af56bf8885fd75b2e4258
|
||||
0d887ebeb2f2e3db51f92adc2225646f2b451fe2
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
329
cmd/acb-matchmaker/series_season_test.go
Normal file
329
cmd/acb-matchmaker/series_season_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue