diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index 85cd9aa..7c60bae 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -45,6 +45,10 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { // UI feedback (Agentation overlay) mux.HandleFunc("POST /api/ui-feedback", s.handleUIFeedback) + + // Predictions + mux.HandleFunc("POST /api/predict", s.handlePredict) + mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions) } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -736,6 +740,162 @@ 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. +// Rejects if the match has already started (status != 'pending'). +func (s *Server) handlePredict(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + MatchID string `json:"match_id"` + BotID string `json:"bot_id"` + Predictor string `json:"predictor_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.MatchID == "" || req.BotID == "" || req.Predictor == "" { + writeError(w, http.StatusBadRequest, "match_id, bot_id, and predictor_id are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Verify match exists and hasn't started + var matchStatus string + err := s.db.QueryRowContext(ctx, ` + SELECT status FROM matches WHERE match_id = $1 + `, req.MatchID).Scan(&matchStatus) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "match not found") + return + } else if err != nil { + log.Printf("database error checking match: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + if matchStatus != "pending" { + writeError(w, http.StatusConflict, "match has already started") + return + } + + // Verify bot is a participant in this match + var participantExists bool + err = s.db.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM match_participants + WHERE match_id = $1 AND bot_id = $2 + ) + `, req.MatchID, req.BotID).Scan(&participantExists) + if err != nil { + log.Printf("database error checking participant: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + if !participantExists { + writeError(w, http.StatusBadRequest, "bot is not a participant in this match") + return + } + + // 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 + RETURNING id + `, req.MatchID, req.Predictor, req.BotID).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{}{ + "id": predictionID, + "match_id": req.MatchID, + "predicted": req.BotID, + "predictor": req.Predictor, + }) +} + +// handleOpenPredictions handles GET /api/predictions/open +// Returns pending matches that are open for predictions, along with +// any predictions the requesting predictor has made. +func (s *Server) handleOpenPredictions(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") + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Get pending matches with their participants + rows, err := s.db.QueryContext(ctx, ` + SELECT m.match_id, m.created_at, + COALESCE(json_agg(json_build_object('bot_id', mp.bot_id, 'name', b.name, 'rating', b.rating_mu - 2*b.rating_phi)) FILTER (WHERE mp.bot_id IS NOT NULL), '[]') + FROM matches m + JOIN match_participants mp ON m.match_id = mp.match_id + JOIN bots b ON mp.bot_id = b.bot_id + WHERE m.status = 'pending' + GROUP BY m.match_id, m.created_at + ORDER BY m.created_at ASC + LIMIT 20 + `) + if err != nil { + log.Printf("database error fetching open matches: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type MatchPrediction struct { + MatchID string `json:"match_id"` + CreatedAt string `json:"created_at"` + Participants []map[string]interface{} `json:"participants"` + YourPick *string `json:"your_pick,omitempty"` + } + + var matches []MatchPrediction + for rows.Next() { + var m MatchPrediction + var participantsJSON string + if err := rows.Scan(&m.MatchID, &m.CreatedAt, &participantsJSON); err != nil { + log.Printf("error scanning match: %v", err) + continue + } + json.Unmarshal([]byte(participantsJSON), &m.Participants) + + // If predictor_id given, look up their existing prediction + if predictorID != "" { + var predictedBot string + err := s.db.QueryRowContext(ctx, ` + SELECT predicted_bot FROM predictions + WHERE match_id = $1 AND predictor_id = $2 + `, m.MatchID, predictorID).Scan(&predictedBot) + if err == nil { + m.YourPick = &predictedBot + } + } + + matches = append(matches, m) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "matches": matches, + }) +} + // 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-evolver/internal/live/exporter.go b/cmd/acb-evolver/internal/live/exporter.go index b8f1469..0c71c22 100644 --- a/cmd/acb-evolver/internal/live/exporter.go +++ b/cmd/acb-evolver/internal/live/exporter.go @@ -59,15 +59,94 @@ type MetaSnapshot struct { IslandBestFitness map[string]float64 `json:"island_best_fitness"` } -// LiveData is the full evolution dashboard payload written to live.json. +// CycleInfo represents the current evolution cycle status. +type CycleInfo struct { + Generation int `json:"generation"` + StartedAt string `json:"started_at"` + Phase string `json:"phase"` // generating, validating, evaluating, promoting, idle + Candidate *Candidate `json:"candidate,omitempty"` +} + +// Candidate represents the current candidate being evaluated. +type Candidate struct { + ID string `json:"id"` // e.g., "go-847-3" + Island string `json:"island"` + Language string `json:"language"` + Parents []ParentInfo `json:"parents"` + Validation *ValidationStatus `json:"validation,omitempty"` + Evaluation *EvaluationStatus `json:"evaluation,omitempty"` +} + +// ParentInfo holds parent bot information. +type ParentInfo struct { + ID string `json:"id"` // e.g., "go-831-1" + Rating int `json:"rating"` +} + +// ValidationStatus holds validation stage results. +type ValidationStatus struct { + Syntax *StageResult `json:"syntax,omitempty"` + Schema *StageResult `json:"schema,omitempty"` + Smoke *StageResult `json:"smoke,omitempty"` +} + +// StageResult holds result for a single validation stage. +type StageResult struct { + Passed bool `json:"passed"` + TimeMs int `json:"time_ms"` + Error string `json:"error,omitempty"` +} + +// EvaluationStatus holds arena evaluation results. +type EvaluationStatus struct { + MatchesTotal int `json:"matches_total"` + MatchesPlayed int `json:"matches_played"` + Results []MatchResult `json:"results"` +} + +// MatchResult is a single evaluation match result. +type MatchResult struct { + Opponent string `json:"opponent"` // opponent bot name + Won bool `json:"won"` + Score string `json:"score"` // e.g., "5-1" +} + +// ActivityEntry is a single event in the recent activity feed. +type ActivityEntry struct { + Time string `json:"time"` + Generation int `json:"generation"` + Candidate string `json:"candidate"` + Island string `json:"island"` + Result string `json:"result"` // promoted, rejected + Reason string `json:"reason"` + Stage string `json:"stage"` // validation, promotion, deployment + BotID string `json:"bot_id,omitempty"` + InitialRating int `json:"initial_rating,omitempty"` +} + +// Totals holds overall evolution statistics. +type Totals struct { + GenerationsTotal int `json:"generations_total"` + CandidatesToday int `json:"candidates_today"` + PromotedToday int `json:"promoted_today"` + PromotionRate7d float64 `json:"promotion_rate_7d"` + HighestEvolvedRating int `json:"highest_evolved_rating"` + EvolvedInTop10 int `json:"evolved_in_top_10"` +} + +// LiveData is the full evolution dashboard payload written to live.json (plan §14 format). type LiveData struct { - UpdatedAt string `json:"updated_at"` - TotalPrograms int `json:"total_programs"` - PromotedCount int `json:"promoted_count"` - Islands map[string]IslandStat `json:"islands"` - GenerationLog []GenerationEntry `json:"generation_log"` - Lineage []LineageNode `json:"lineage"` - MetaSnapshots []MetaSnapshot `json:"meta_snapshots"` + UpdatedAt string `json:"updated_at"` + Cycle *CycleInfo `json:"cycle,omitempty"` + RecentActivity []ActivityEntry `json:"recent_activity,omitempty"` + Islands map[string]IslandStat `json:"islands"` + Totals Totals `json:"totals"` + // Legacy fields for backward compatibility + TotalPrograms int `json:"total_programs,omitempty"` + PromotedCount int `json:"promoted_count,omitempty"` + GenerationLog []GenerationEntry `json:"generation_log,omitempty"` + Lineage []LineageNode `json:"lineage,omitempty"` + MetaSnapshots []MetaSnapshot `json:"meta_snapshots,omitempty"` } // Export queries the programs database and builds the current evolution state. @@ -75,11 +154,19 @@ func Export(ctx context.Context, db *sql.DB) (*LiveData, error) { data := &LiveData{ UpdatedAt: time.Now().UTC().Format(time.RFC3339), Islands: make(map[string]IslandStat), + Totals: Totals{}, } if err := fillIslandStats(ctx, db, data); err != nil { return nil, err } + if err := fillTotals(ctx, db, data); err != nil { + return nil, err + } + if err := fillRecentActivity(ctx, db, data); err != nil { + return nil, err + } + // Legacy fields for backward compatibility if err := fillGenerationLog(ctx, db, data); err != nil { return nil, err } @@ -94,50 +181,168 @@ func Export(ctx context.Context, db *sql.DB) (*LiveData, error) { } func fillIslandStats(ctx context.Context, db *sql.DB, data *LiveData) error { + // Query island stats with bot ratings rows, err := db.QueryContext(ctx, ` - SELECT island, - COUNT(*) AS cnt, - COALESCE(AVG(fitness), 0) AS avg_fit, - COALESCE(MAX(fitness), 0) AS max_fit, - COUNT(DISTINCT language) AS lang_diversity, - SUM(CASE WHEN promoted AND bot_id IS NOT NULL THEN 1 ELSE 0 END) AS promoted_cnt - FROM programs - GROUP BY island`) + SELECT p.island, + COUNT(*) AS population, + COALESCE(MAX(b.rating_mu - 2*b.rating_phi), 0) AS best_rating, + COALESCE(b.bot_id, '') AS best_bot_id + FROM programs p + LEFT JOIN bots b ON p.bot_id = b.bot_id + GROUP BY p.island`) if err != nil { return fmt.Errorf("island stats: %w", err) } defer rows.Close() total := 0 - promoted := 0 for rows.Next() { var island string - var cnt, langDiv, promotedCnt int - var avgFit, maxFit float64 - if err := rows.Scan(&island, &cnt, &avgFit, &maxFit, &langDiv, &promotedCnt); err != nil { + var population, bestRating int + var bestBotID string + if err := rows.Scan(&island, &population, &bestRating, &bestBotID); err != nil { return fmt.Errorf("scan island stats: %w", err) } - // Diversity: language diversity normalized to [0,1], up to 6 languages - const maxLangs = 6.0 - diversity := float64(langDiv) / maxLangs - if diversity > 1.0 { - diversity = 1.0 - } + data.Islands[island] = IslandStat{ - Count: cnt, - BestFitness: round3(maxFit), - AvgFitness: round3(avgFit), - Diversity: round3(diversity), - PromotedCount: promotedCnt, + Population: population, + BestRating: bestRating, + BestBot: bestBotID, } - total += cnt - promoted += promotedCnt + total += population } if err := rows.Err(); err != nil { return err } data.TotalPrograms = total - data.PromotedCount = promoted + return nil +} + +func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error { + // Get max generation + var maxGen int + err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(generation), 0) FROM programs`).Scan(&maxGen) + if err != nil { + return fmt.Errorf("max generation: %w", err) + } + + // Count candidates created today + var candidatesToday int + today := time.Now().UTC().Format("2006-01-02") + err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs WHERE created_at >= $1::date`, today).Scan(&candidatesToday) + if err != nil { + return fmt.Errorf("candidates today: %w", err) + } + + // Count promoted today + var promotedToday int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM programs p + JOIN bots b ON p.bot_id = b.bot_id + WHERE p.promoted = TRUE AND b.created_at >= $1::date`, today).Scan(&promotedToday) + if err != nil { + return fmt.Errorf("promoted today: %w", err) + } + + // Calculate 7-day promotion rate + var promoted7d, total7d int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM programs p + JOIN bots b ON p.bot_id = b.bot_id + WHERE b.created_at >= NOW() - INTERVAL '7 days'`).Scan(&promoted7d) + if err != nil { + promoted7d = 0 + } + err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs WHERE created_at >= NOW() - INTERVAL '7 days'`).Scan(&total7d) + if err != nil { + total7d = 0 + } + var rate7d float64 + if total7d > 0 { + rate7d = round3(float64(promoted7d) / float64(total7d)) + } + + // Highest evolved rating + var highestRating int + err = db.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(b.rating_mu - 2*b.rating_phi), 0) + FROM bots b + WHERE b.owner = 'acb-evolver'`).Scan(&highestRating) + if err != nil { + highestRating = 0 + } + + // Count evolved in top 10 + var top10Count int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM ( + SELECT b.bot_id, b.rating_mu - 2*b.rating_phi AS display_rating + FROM bots b + WHERE b.status = 'active' + ORDER BY display_rating DESC + LIMIT 10 + ) top10 + JOIN bots b ON top10.bot_id = b.bot_id + WHERE b.owner = 'acb-evolver'`).Scan(&top10Count) + if err != nil { + top10Count = 0 + } + + data.Totals = Totals{ + GenerationsTotal: maxGen, + CandidatesToday: candidatesToday, + PromotedToday: promotedToday, + PromotionRate7d: rate7d, + HighestEvolvedRating: highestRating, + EvolvedInTop10: top10Count, + } + + return nil +} + +func fillRecentActivity(ctx context.Context, db *sql.DB, data *LiveData) error { + // Get recent promoted bots from bots table (with timestamps) + rows, err := db.QueryContext(ctx, ` + SELECT + p.bot_id, + p.bot_name, + p.island, + p.generation, + p.language, + b.created_at + FROM programs p + JOIN bots b ON p.bot_id = b.bot_id + WHERE p.promoted = TRUE AND b.owner = 'acb-evolver' + ORDER BY b.created_at DESC + LIMIT 10`) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("recent activity: %w", err) + } + defer rows.Close() + + activities := []ActivityEntry{} + for rows.Next() { + var botID, botName, island, language string + var generation int + var createdAt time.Time + if err := rows.Scan(&botID, &botName, &island, &generation, &language, &createdAt); err != nil { + continue + } + activities = append(activities, ActivityEntry{ + Time: createdAt.UTC().Format(time.RFC3339), + Generation: generation, + Candidate: botName, + Island: island, + Result: "promoted", + Reason: "Passed promotion gate", + Stage: "deployment", + BotID: botID, + }) + } + data.RecentActivity = activities + data.PromotedCount = len(activities) + return nil } diff --git a/cmd/acb-matchmaker/config.go b/cmd/acb-matchmaker/config.go index 32c3e4f..b35deca 100644 --- a/cmd/acb-matchmaker/config.go +++ b/cmd/acb-matchmaker/config.go @@ -23,3 +23,15 @@ func envInt(key string, fallback int) int { } return n } + +func envFloat(key string, fallback float64) float64 { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.ParseFloat(v, 64) + if err != nil { + return fallback + } + return n +} diff --git a/cmd/acb-matchmaker/main.go b/cmd/acb-matchmaker/main.go index 79f2571..2833c06 100644 --- a/cmd/acb-matchmaker/main.go +++ b/cmd/acb-matchmaker/main.go @@ -24,12 +24,15 @@ type Config struct { EncryptionKey string // AES-256-GCM key for shared secret decryption DiscordWebhook string SlackWebhook string - MatchmakerSecs int - HealthCheckSecs int - ReaperSecs int - BotTimeoutSecs int - StaleJobMinutes int - MaxConsecFails int + MatchmakerSecs int + HealthCheckSecs int + ReaperSecs int + SeriesSchedSecs int + SeasonResetSecs int + BotTimeoutSecs int + StaleJobMinutes int + MaxConsecFails int + SeasonDecayFactor float64 } type Matchmaker struct { @@ -47,12 +50,15 @@ func loadConfig() Config { EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"), DiscordWebhook: os.Getenv("ACB_DISCORD_WEBHOOK"), SlackWebhook: os.Getenv("ACB_SLACK_WEBHOOK"), - MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60), - HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900), - ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300), - BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5), - StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15), - MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3), + MatchmakerSecs: envInt("ACB_MATCHMAKER_INTERVAL", 60), + HealthCheckSecs: envInt("ACB_HEALTHCHECK_INTERVAL", 900), + ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300), + SeriesSchedSecs: envInt("ACB_SERIES_SCHED_INTERVAL", 120), + SeasonResetSecs: envInt("ACB_SEASON_RESET_INTERVAL", 300), + BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5), + StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15), + MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3), + SeasonDecayFactor: envFloat("ACB_SEASON_DECAY_FACTOR", 0.7), } } diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go new file mode 100644 index 0000000..6e459b0 --- /dev/null +++ b/cmd/acb-matchmaker/series_season.go @@ -0,0 +1,580 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "math/rand" + "time" +) + +// tickSeriesScheduler schedules remaining games for active series. +// For each active series with unplayed games, it schedules the next game +// in round-robin order, feeding the match into the job queue. +// It also marks series as completed when a bot reaches the winning threshold. +func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) { + // 1. Finalize any completed series (check if winner reached threshold) + if err := m.finalizeCompletedSeries(ctx); err != nil { + log.Printf("series-scheduler: finalize error: %v", err) + } + + // 2. Schedule next game for active series that need one + if err := m.scheduleNextSeriesGames(ctx); err != nil { + log.Printf("series-scheduler: schedule error: %v", err) + } + + // 3. Auto-create series for top bots (one per bot per day, best-of-5) + if err := m.autoCreateSeries(ctx); err != nil { + log.Printf("series-scheduler: auto-create error: %v", err) + } +} + +// finalizeCompletedSeries checks active series where one bot has already won enough games. +func (m *Matchmaker) finalizeCompletedSeries(ctx context.Context) error { + // Find active series where a_wins or b_wins >= ceil(format/2) + rows, err := m.db.QueryContext(ctx, ` + SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins + FROM series + WHERE status = 'active' + AND (a_wins >= ((format + 1) / 2) OR b_wins >= ((format + 1) / 2)) + `) + if err != nil { + return fmt.Errorf("query completed series: %w", err) + } + defer rows.Close() + + type completedSeries struct { + ID int64 + BotAID string + BotBID string + Format int + AWins int + BWins int + } + var completed []completedSeries + + for rows.Next() { + var s completedSeries + if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins); err != nil { + return fmt.Errorf("scan series: %w", err) + } + completed = append(completed, s) + } + + for _, s := range completed { + winsNeeded := (s.Format + 1) / 2 + var winnerID string + if s.AWins >= winsNeeded { + winnerID = s.BotAID + } else { + winnerID = s.BotBID + } + + _, err := m.db.ExecContext(ctx, ` + UPDATE series + SET status = 'completed', winner_id = $1, updated_at = NOW() + WHERE id = $2 AND status = 'active' + `, winnerID, s.ID) + if err != nil { + log.Printf("series-scheduler: failed to finalize series %d: %v", s.ID, err) + continue + } + log.Printf("series-scheduler: finalized series %d, winner=%s (%d-%d)", s.ID, winnerID, s.AWins, s.BWins) + } + + return nil +} + +// scheduleNextSeriesGames finds active series with unplayed games and schedules the next one. +func (m *Matchmaker) scheduleNextSeriesGames(ctx context.Context) error { + // Find active series where the next sequential game has no match_id yet + rows, err := m.db.QueryContext(ctx, ` + SELECT s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins, + COALESCE(MAX(sg.game_num), 0) AS last_game_num + FROM series s + LEFT JOIN series_games sg ON s.id = sg.series_id AND sg.match_id IS NOT NULL + WHERE s.status = 'active' + GROUP BY s.id, s.bot_a_id, s.bot_b_id, s.format, s.a_wins, s.b_wins + HAVING COUNT(sg.id) < s.format + `) + if err != nil { + return fmt.Errorf("query pending series: %w", err) + } + defer rows.Close() + + type pendingSeries struct { + ID int64 + BotAID string + BotBID string + Format int + AWins int + BWins int + LastGameNum int + } + var pending []pendingSeries + + for rows.Next() { + var s pendingSeries + if err := rows.Scan(&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins, &s.LastGameNum); err != nil { + return fmt.Errorf("scan pending series: %w", err) + } + + // Skip if series is already decided + winsNeeded := (s.Format + 1) / 2 + if s.AWins >= winsNeeded || s.BWins >= winsNeeded { + continue + } + + // Check that both bots are active + var aActive, bActive bool + err := m.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotAID).Scan(&aActive) + if err != nil { + continue + } + err = m.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotBID).Scan(&bActive) + if err != nil { + continue + } + if !aActive || !bActive { + continue + } + + pending = append(pending, s) + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + for _, s := range pending { + nextGameNum := s.LastGameNum + 1 + + // Check if this game already has a pending match (not yet completed) + var existingMatch int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM series_games + WHERE series_id = $1 AND game_num = $2 AND match_id IS NOT NULL + `, s.ID, nextGameNum).Scan(&existingMatch) + if err != nil || existingMatch > 0 { + continue // already scheduled or played + } + + // Check if there's already a pending/running job for this series + var pendingJobs int + err = m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM series_games sg + JOIN matches m ON sg.match_id = m.match_id + JOIN jobs j ON j.match_id = m.match_id + WHERE sg.series_id = $1 AND j.status IN ('pending', 'running') + `, s.ID).Scan(&pendingJobs) + if err != nil || pendingJobs > 0 { + continue // a game is already in progress for this series + } + + if err := m.scheduleSeriesGame(ctx, s.ID, s.BotAID, s.BotBID, nextGameNum, rng); err != nil { + log.Printf("series-scheduler: failed to schedule game %d for series %d: %v", nextGameNum, s.ID, err) + continue + } + log.Printf("series-scheduler: scheduled game %d for series %d (%s vs %s)", nextGameNum, s.ID, s.BotAID, s.BotBID) + } + + return nil +} + +// scheduleSeriesGame creates a match and job for one game in a series. +func (m *Matchmaker) scheduleSeriesGame(ctx context.Context, seriesID int64, botAID, botBID string, gameNum int, rng *rand.Rand) error { + // Fetch bot endpoints and secrets + var endpointA, secretA, endpointB, secretB string + err := m.db.QueryRowContext(ctx, + `SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botAID).Scan(&endpointA, &secretA) + if err != nil { + return fmt.Errorf("fetch bot %s: %w", botAID, err) + } + err = m.db.QueryRowContext(ctx, + `SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1`, botBID).Scan(&endpointB, &secretB) + if err != nil { + return fmt.Errorf("fetch bot %s: %w", botBID, err) + } + + // Decrypt secrets + if m.cfg.EncryptionKey != "" { + if dec, err := decryptSecret(secretA, m.cfg.EncryptionKey); err == nil { + secretA = dec + } + if dec, err := decryptSecret(secretB, m.cfg.EncryptionKey); err == nil { + secretB = dec + } + } + + matchID, err := generateID("m_", 8) + if err != nil { + return err + } + jobID, err := generateID("j_", 8) + if err != nil { + return err + } + + mapSeed := rng.Int63() + mapID := fmt.Sprintf("map_%d", mapSeed%100000) + + type botConfig struct { + BotID string `json:"bot_id"` + Endpoint string `json:"endpoint"` + Secret string `json:"secret"` + Slot int `json:"slot"` + } + type jobConfig struct { + MatchID string `json:"match_id"` + SeriesID int64 `json:"series_id,omitempty"` + GameNum int `json:"game_num,omitempty"` + MapSeed int64 `json:"map_seed"` + MaxTurns int `json:"max_turns"` + Rows int `json:"rows"` + Cols int `json:"cols"` + Bots []botConfig `json:"bots"` + } + + config := jobConfig{ + MatchID: matchID, + SeriesID: seriesID, + GameNum: gameNum, + MapSeed: mapSeed, + MaxTurns: 500, + Rows: 60, + Cols: 60, + Bots: []botConfig{ + {BotID: botAID, Endpoint: endpointA, Secret: secretA, Slot: 0}, + {BotID: botBID, Endpoint: endpointB, Secret: secretB, Slot: 1}, + }, + } + configJSON, _ := json.Marshal(config) + + tx, err := m.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, + `INSERT INTO matches (match_id, map_id, map_seed, status) VALUES ($1, $2, $3, 'pending')`, + matchID, mapID, mapSeed) + if err != nil { + return fmt.Errorf("insert match: %w", err) + } + + _, err = tx.ExecContext(ctx, + `INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES ($1, $2, 0), ($1, $3, 1)`, + matchID, botAID, botBID) + if err != nil { + return fmt.Errorf("insert participants: %w", err) + } + + _, err = tx.ExecContext(ctx, + `INSERT INTO jobs (job_id, match_id, status, config_json) VALUES ($1, $2, 'pending', $3)`, + jobID, matchID, configJSON) + if err != nil { + return fmt.Errorf("insert job: %w", err) + } + + // Create the series_games row + _, err = tx.ExecContext(ctx, ` + INSERT INTO series_games (series_id, match_id, game_num, winner_id) + VALUES ($1, $2, $3, NULL) + `, seriesID, matchID, gameNum) + if err != nil { + return fmt.Errorf("insert series_game: %w", err) + } + + if err := tx.Commit(); err != nil { + return err + } + + // Enqueue in Valkey + if err := m.rdb.LPush(ctx, valkeyJobQueue, jobID).Err(); err != nil { + return fmt.Errorf("valkey push: %w", err) + } + + return nil +} + +// autoCreateSeries creates best-of-5 series between top-20 active bots, +// one per bot per day. +func (m *Matchmaker) autoCreateSeries(ctx context.Context) error { + // Find top-20 active bots by rating + rows, err := m.db.QueryContext(ctx, ` + SELECT bot_id FROM bots + WHERE status = 'active' AND evolved = false + ORDER BY rating_mu DESC + LIMIT 20 + `) + if err != nil { + return fmt.Errorf("query top bots: %w", err) + } + defer rows.Close() + + var topBots []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + topBots = append(topBots, id) + } + + if len(topBots) < 2 { + return nil + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + for _, botID := range topBots { + // Check if this bot already has an active or pending series created today + var todaySeries int + err := m.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM series + WHERE (bot_a_id = $1 OR bot_b_id = $1) + AND created_at >= NOW() - INTERVAL '24 hours' + AND status IN ('active', 'pending') + `, botID).Scan(&todaySeries) + if err != nil || todaySeries > 0 { + continue + } + + // Pick an opponent — closest rating that isn't this bot and doesn't have an active series + var opponentID string + err = m.db.QueryRowContext(ctx, ` + SELECT b.bot_id FROM bots b + WHERE b.bot_id != $1 + AND b.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM series s + WHERE ((s.bot_a_id = $1 AND s.bot_b_id = b.bot_id) + OR (s.bot_a_id = b.bot_id AND s.bot_b_id = $1)) + AND s.status IN ('active', 'pending') + ) + ORDER BY ABS(b.rating_mu - (SELECT rating_mu FROM bots WHERE bot_id = $1)) ASC + LIMIT 1 + `, botID).Scan(&opponentID) + if err != nil { + if err == sql.ErrNoRows { + continue + } + return fmt.Errorf("find opponent for %s: %w", botID, err) + } + + // Determine format based on ratings — closer ratings get longer series + var botRating, oppRating float64 + err = m.db.QueryRowContext(ctx, + `SELECT rating_mu FROM bots WHERE bot_id = $1`, botID).Scan(&botRating) + if err != nil { + continue + } + err = m.db.QueryRowContext(ctx, + `SELECT rating_mu FROM bots WHERE bot_id = $1`, opponentID).Scan(&oppRating) + if err != nil { + continue + } + + format := 5 // default best-of-5 + ratingGap := botRating - oppRating + if ratingGap < 0 { + ratingGap = -ratingGap + } + if ratingGap < 50 { + format = 7 // close ratings → best-of-7 + } else if ratingGap > 200 { + format = 3 // large gap → best-of-3 + } + + // Randomize who is bot_a vs bot_b + botAID, botBID := botID, opponentID + if rng.Intn(2) == 0 { + botAID, botBID = botBID, botAID + } + + // Get the active season ID (if any) + var seasonID sql.NullInt64 + m.db.QueryRowContext(ctx, + `SELECT id FROM seasons WHERE status = 'active' ORDER BY starts_at DESC LIMIT 1`).Scan(&seasonID) + + _, err = m.db.ExecContext(ctx, ` + INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, updated_at) + VALUES ($1, $2, $3, 'active', 0, 0, NOW()) + `, botAID, botBID, format) + 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) + } + + return nil +} + +// tickSeasonReset checks for seasons that have ended and performs: +// 1. Snapshot current ELO ratings into season_snapshots +// 2. Apply decay formula to all bot ratings +// 3. Close the old season and start a new one +func (m *Matchmaker) tickSeasonReset(ctx context.Context) { + // Find active seasons that have passed their ends_at + rows, err := m.db.QueryContext(ctx, ` + SELECT id, name, theme, rules_version FROM seasons + WHERE status = 'active' AND ends_at IS NOT NULL AND ends_at <= NOW() + `) + if err != nil { + log.Printf("season-reset: query error: %v", err) + return + } + defer rows.Close() + + type endingSeason struct { + ID int64 + Name string + Theme string + RulesVersion string + } + var ending []endingSeason + + for rows.Next() { + var s endingSeason + var theme sql.NullString + if err := rows.Scan(&s.ID, &s.Name, &theme, &s.RulesVersion); err != nil { + log.Printf("season-reset: scan error: %v", err) + return + } + if theme.Valid { + s.Theme = theme.String + } + ending = append(ending, s) + } + + for _, s := range ending { + if err := m.processSeasonEnd(ctx, s.ID, s.Name); err != nil { + log.Printf("season-reset: failed to process season %d (%s): %v", s.ID, s.Name, err) + continue + } + log.Printf("season-reset: processed season %d (%s) — snapshot + decay complete", s.ID, s.Name) + } + + // Check if there's no active season and auto-start one + m.autoStartSeason(ctx) +} + +// processSeasonEnd handles the end-of-season workflow for one season. +func (m *Matchmaker) processSeasonEnd(ctx context.Context, seasonID int64, seasonName string) error { + tx, err := m.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // 1. Snapshot current ratings into season_snapshots + _, err = tx.ExecContext(ctx, ` + INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses) + SELECT $1, b.bot_id, + ROW_NUMBER() OVER (ORDER BY b.rating_mu DESC)::int, + b.rating_mu, + COALESCE(mp.wins, 0), + COALESCE(mp.losses, 0) + FROM bots b + LEFT JOIN ( + SELECT bot_id, + COUNT(*) FILTER (WHERE player_slot = m.winner) AS wins, + COUNT(*) FILTER (WHERE player_slot != m.winner) AS losses + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE m.status = 'completed' + GROUP BY bot_id + ) mp ON mp.bot_id = b.bot_id + WHERE b.status != 'retired' + ORDER BY b.rating_mu DESC + `, seasonID) + if err != nil { + return fmt.Errorf("snapshot ratings: %w", err) + } + + // 2. Determine champion (rank 1) + var championID string + err = tx.QueryRowContext(ctx, ` + SELECT bot_id FROM season_snapshots + WHERE season_id = $1 AND rank = 1 + `, seasonID).Scan(&championID) + if err != nil { + log.Printf("season-reset: could not determine champion for season %d: %v", seasonID, err) + } + + // 3. Mark season as completed + _, err = tx.ExecContext(ctx, ` + UPDATE seasons SET status = 'completed', champion_id = $1 WHERE id = $2 + `, championID, seasonID) + if err != nil { + return fmt.Errorf("complete season: %w", err) + } + + // 4. Apply decay to all non-retired bots + // Formula: new_mu = default + (current_mu - default) * decay_factor + // This pulls ratings toward 1500 but preserves relative ordering + decayFactor := m.cfg.SeasonDecayFactor + defaultMu := 1500.0 + defaultPhi := 350.0 + defaultSigma := 0.06 + + _, err = tx.ExecContext(ctx, ` + UPDATE bots SET + rating_mu = $1 + (rating_mu - $1) * $2, + rating_phi = $3, + rating_sigma = $4 + WHERE status != 'retired' + `, defaultMu, decayFactor, defaultPhi, defaultSigma) + if err != nil { + return fmt.Errorf("apply decay: %w", err) + } + + if err := tx.Commit(); err != nil { + return err + } + + log.Printf("season-reset: season %d (%s) complete — champion=%s, decay=%.0f%%", + seasonID, seasonName, championID, decayFactor*100) + + return nil +} + +// autoStartSeason creates a new season if no active season exists. +func (m *Matchmaker) autoStartSeason(ctx context.Context) { + var activeCount int + err := m.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM seasons WHERE status = 'active'`).Scan(&activeCount) + if err != nil || activeCount > 0 { + return + } + + // Determine next season number + var maxNum int + err = m.db.QueryRowContext(ctx, + `SELECT COALESCE(MAX(id), 0) FROM seasons`).Scan(&maxNum) + if err != nil { + return + } + + nextNum := maxNum + 1 + seasonName := fmt.Sprintf("Season %d", nextNum) + themes := []string{"The Labyrinth", "Energy Rush", "Fog of War", "The Colosseum", "Shifting Sands"} + theme := themes[(nextNum-1)%len(themes)] + rulesVersion := fmt.Sprintf("%d.0", nextNum) + + _, err = m.db.ExecContext(ctx, ` + INSERT INTO seasons (name, theme, rules_version, status, starts_at, ends_at) + VALUES ($1, $2, $3, 'active', NOW(), NOW() + INTERVAL '28 days') + `, seasonName, theme, rulesVersion) + if err != nil { + log.Printf("season-reset: failed to create new season: %v", err) + return + } + + log.Printf("season-reset: auto-started %s (%s) — ends in 28 days", seasonName, theme) +} diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index 0d2d497..c595632 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -17,6 +17,8 @@ func (m *Matchmaker) StartTickers(ctx context.Context) { go m.runTicker(ctx, "matchmaker", time.Duration(m.cfg.MatchmakerSecs)*time.Second, m.tickMatchmaker) go m.runTicker(ctx, "health-checker", time.Duration(m.cfg.HealthCheckSecs)*time.Second, m.tickHealthChecker) go m.runTicker(ctx, "stale-reaper", time.Duration(m.cfg.ReaperSecs)*time.Second, m.tickStaleReaper) + go m.runTicker(ctx, "series-scheduler", time.Duration(m.cfg.SeriesSchedSecs)*time.Second, m.tickSeriesScheduler) + go m.runTicker(ctx, "season-reset", time.Duration(m.cfg.SeasonResetSecs)*time.Second, m.tickSeasonReset) } func (m *Matchmaker) runTicker(ctx context.Context, name string, interval time.Duration, fn func(context.Context)) { diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go index 7cd06b9..e24c1b4 100644 --- a/cmd/acb-worker/db.go +++ b/cmd/acb-worker/db.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "time" _ "github.com/lib/pq" @@ -381,6 +382,12 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result * } } + // Resolve predictions for this match + if err := resolvePredictions(ctx, tx, matchID, result.WinnerID); err != nil { + // Log but don't fail the match result — predictions are non-critical + log.Printf("failed to resolve predictions for match %s: %v", matchID, err) + } + if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } @@ -388,6 +395,84 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result * return nil } +// resolvePredictions marks open predictions as correct/incorrect and updates predictor_stats. +func resolvePredictions(ctx context.Context, tx *sql.Tx, matchID string, winnerBotID string) error { + if winnerBotID == "" { + // Draw or no winner — mark all open predictions as incorrect + _, err := tx.ExecContext(ctx, ` + UPDATE predictions + SET correct = false, resolved_at = NOW() + WHERE match_id = $1 AND correct IS NULL + `, 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, ` + UPDATE predictions + SET correct = (predicted_bot = $1), resolved_at = NOW() + WHERE match_id = $2 AND correct IS NULL + `, 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) + } + 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) + } + 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) + } + } + + return nil +} + // 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, ` diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 25038cf..e701aa7 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -99,7 +99,17 @@ export interface RegisterResponse { } // Evolution dashboard types + +// Dashboard island stat (live.json format) export interface IslandStat { + population: number; + best_rating: number; + best_bot: string; + language_div?: string; +} + +// Full island stat (legacy format) +export interface IslandStatFull { count: number; best_fitness: number; avg_fitness: number; @@ -107,6 +117,82 @@ export interface IslandStat { promoted_count: number; } +// Parent info for candidate +export interface ParentInfo { + id: string; + rating: number; +} + +// Validation stage result +export interface StageResult { + passed: boolean; + time_ms: number; + error?: string; +} + +// Validation status +export interface ValidationStatus { + syntax?: StageResult; + schema?: StageResult; + smoke?: StageResult; +} + +// Match result in evaluation +export interface MatchResult { + opponent: string; + won: boolean; + score: string; +} + +// Evaluation status +export interface EvaluationStatus { + matches_total: number; + matches_played: number; + results: MatchResult[]; +} + +// Current candidate being evaluated +export interface Candidate { + id: string; + island: string; + language: string; + parents: ParentInfo[]; + validation?: ValidationStatus; + evaluation?: EvaluationStatus; +} + +// Current cycle info +export interface CycleInfo { + generation: number; + started_at: string; + phase: string; // generating, validating, evaluating, promoting, idle + candidate?: Candidate; +} + +// Activity entry in recent activity feed +export interface ActivityEntry { + time: string; + generation: number; + candidate: string; + island: string; + result: string; // promoted, rejected + reason: string; + stage: string; // validation, promotion, deployment + bot_id?: string; + initial_rating?: number; +} + +// Overall evolution statistics +export interface Totals { + generations_total: number; + candidates_today: number; + promoted_today: number; + promotion_rate_7d: number; + highest_evolved_rating: number; + evolved_in_top_10: number; +} + +// Legacy generation entry export interface GenerationEntry { generation: number; island: string; @@ -117,6 +203,7 @@ export interface GenerationEntry { avg_fitness: number; } +// Legacy lineage node export interface LineageNode { id: number; parent_ids: number[]; @@ -128,20 +215,26 @@ export interface LineageNode { created_at: string; } +// Legacy meta snapshot export interface MetaSnapshot { generation: number; island_counts: Record; island_best_fitness: Record; } +// Evolution live data (plan §14 format) export interface EvolutionLiveData { updated_at: string; - total_programs: number; - promoted_count: number; + cycle?: CycleInfo; + recent_activity?: ActivityEntry[]; islands: Record; - generation_log: GenerationEntry[]; - lineage: LineageNode[]; - meta_snapshots: MetaSnapshot[]; + totals: Totals; + // Legacy fields for backward compatibility + total_programs?: number; + promoted_count?: number; + generation_log?: GenerationEntry[]; + lineage?: LineageNode[]; + meta_snapshots?: MetaSnapshot[]; } // Blog / Narrative Engine types @@ -174,29 +267,65 @@ export interface BlogIndex { // API configuration export const API_BASE = '/api'; +// ─── Stale-while-revalidate cache ───────────────────────────────────────────────── +// Returns cached data instantly (if available) while fetching fresh data in the +// background. On the next call the fresh data is already in cache. This gives +// sub-ms render times for repeat visits while keeping data reasonably current. + +interface CacheEntry { data: T; ts: number } + +const swrCache = new Map>(); +const SWR_MAX_AGE = 5 * 60 * 1000; // 5 min — data is served from cache without re-fetch + +function swr(key: string, fetcher: () => Promise): Promise { + const cached = swrCache.get(key) as CacheEntry | undefined; + + if (cached) { + // Serve stale immediately, revalidate in background if older than max-age + if (Date.now() - cached.ts > SWR_MAX_AGE) { + fetcher().then(data => swrCache.set(key, { data, ts: Date.now() })).catch(() => {}); + } + return Promise.resolve(cached.data); + } + + // No cache — fetch and cache + return fetcher().then(data => { + swrCache.set(key, { data, ts: Date.now() }); + return data; + }); +} + // API client functions export async function fetchLeaderboard(): Promise { - const response = await fetch('/data/leaderboard.json'); - if (!response.ok) throw new Error(`Failed to fetch leaderboard: ${response.status}`); - return response.json(); + return swr('leaderboard', async () => { + const response = await fetch('/data/leaderboard.json'); + if (!response.ok) throw new Error(`Failed to fetch leaderboard: ${response.status}`); + return response.json(); + }); } export async function fetchBotDirectory(): Promise { - const response = await fetch('/data/bots/index.json'); - if (!response.ok) throw new Error(`Failed to fetch bot directory: ${response.status}`); - return response.json(); + return swr('bot-directory', async () => { + const response = await fetch('/data/bots/index.json'); + if (!response.ok) throw new Error(`Failed to fetch bot directory: ${response.status}`); + return response.json(); + }); } export async function fetchBotProfile(botId: string): Promise { - const response = await fetch(`/data/bots/${botId}.json`); - if (!response.ok) throw new Error(`Failed to fetch bot profile: ${response.status}`); - return response.json(); + return swr(`bot-${botId}`, async () => { + const response = await fetch(`/data/bots/${botId}.json`); + if (!response.ok) throw new Error(`Failed to fetch bot profile: ${response.status}`); + return response.json(); + }); } export async function fetchMatchIndex(): Promise { - const response = await fetch('/data/matches/index.json'); - if (!response.ok) throw new Error(`Failed to fetch match index: ${response.status}`); - return response.json(); + return swr('match-index', async () => { + const response = await fetch('/data/matches/index.json'); + if (!response.ok) throw new Error(`Failed to fetch match index: ${response.status}`); + return response.json(); + }); } export async function registerBot(request: RegisterRequest): Promise { @@ -213,22 +342,26 @@ export async function registerBot(request: RegisterRequest): Promise { - // Fetch from R2 for real-time updates (not from static Pages) + // Evolution data changes every ~10s — bypass SWR, always fetch fresh const response = await fetch(`${R2_BASE_URL}/evolution/live.json`); if (!response.ok) throw new Error(`Failed to fetch evolution data: ${response.status}`); return response.json(); } export async function fetchBlogIndex(): Promise { - const response = await fetch('/data/blog/index.json'); - if (!response.ok) throw new Error(`Failed to fetch blog index: ${response.status}`); - return response.json(); + return swr('blog-index', async () => { + const response = await fetch('/data/blog/index.json'); + if (!response.ok) throw new Error(`Failed to fetch blog index: ${response.status}`); + return response.json(); + }); } export async function fetchBlogPost(slug: string): Promise { - const response = await fetch(`/data/blog/${slug}.json`); - if (!response.ok) throw new Error(`Failed to fetch blog post: ${response.status}`); - return response.json(); + return swr(`blog-${slug}`, async () => { + const response = await fetch(`/data/blog/${slug}.json`); + if (!response.ok) throw new Error(`Failed to fetch blog post: ${response.status}`); + return response.json(); + }); } export async function rotateApiKey(botId: string, currentKey: string): Promise { @@ -291,15 +424,19 @@ export interface PlaylistIndex { } export async function fetchPlaylistIndex(): Promise { - const response = await fetch('/data/playlists/index.json'); - if (!response.ok) throw new Error(`Failed to fetch playlist index: ${response.status}`); - return response.json(); + return swr('playlist-index', async () => { + const response = await fetch('/data/playlists/index.json'); + if (!response.ok) throw new Error(`Failed to fetch playlist index: ${response.status}`); + return response.json(); + }); } export async function fetchPlaylist(slug: string): Promise { - const response = await fetch(`/data/playlists/${slug}.json`); - if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`); - return response.json(); + return swr(`playlist-${slug}`, async () => { + const response = await fetch(`/data/playlists/${slug}.json`); + if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`); + return response.json(); + }); } // Prediction types @@ -328,11 +465,53 @@ export interface PredictionsLeaderboard { } export async function fetchPredictionsLeaderboard(): Promise { - const response = await fetch('/data/predictions/leaderboard.json'); - if (!response.ok) throw new Error(`Failed to fetch predictions leaderboard: ${response.status}`); + return swr('predictions-leaderboard', async () => { + const response = await fetch('/data/predictions/leaderboard.json'); + if (!response.ok) throw new Error(`Failed to fetch predictions leaderboard: ${response.status}`); + return response.json(); + }); +} + +export interface OpenMatch { + match_id: string; + created_at: string; + participants: { bot_id: string; name: string; rating: number }[]; + your_pick?: string; +} + +export interface OpenPredictionsResponse { + matches: OpenMatch[]; +} + +export async function fetchOpenPredictions(predictorId?: string): Promise { + const params = predictorId ? `?predictor_id=${encodeURIComponent(predictorId)}` : ''; + const response = await fetch(`/api/predictions/open${params}`); + if (!response.ok) throw new Error(`Failed to fetch open predictions: ${response.status}`); return response.json(); } +export async function submitPrediction(matchId: string, botId: string, predictorId: string): Promise<{ id: number }> { + const response = await fetch('/api/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ match_id: matchId, bot_id: botId, predictor_id: predictorId }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error || `Failed to submit prediction: ${response.status}`); + } + return response.json(); +} + +export function getOrCreatePredictorId(): string { + let id = localStorage.getItem('acb_predictor_id'); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem('acb_predictor_id', id); + } + return id; +} + // Evolution meta types for homepage export interface EvolutionMeta { generation: number; @@ -342,12 +521,13 @@ export interface EvolutionMeta { } export async function fetchEvolutionMeta(): Promise { - const response = await fetch('/data/evolution/meta.json'); - if (!response.ok) { - // Return default values if file doesn't exist yet - return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' }; - } - return response.json(); + return swr('evolution-meta', async () => { + const response = await fetch('/data/evolution/meta.json'); + if (!response.ok) { + return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' }; + } + return response.json(); + }); } // Season types (re-export from types.ts for convenience) @@ -355,10 +535,11 @@ import type { SeasonIndex } from './types'; export type { Season, SeasonIndex } from './types'; export async function fetchSeasonIndex(): Promise { - const response = await fetch('/data/seasons/index.json'); - if (!response.ok) { - // Return empty index if file doesn't exist - return { updated_at: '', active_season: null, seasons: [] }; - } - return response.json(); + return swr('season-index', async () => { + const response = await fetch('/data/seasons/index.json'); + if (!response.ok) { + return { updated_at: '', active_season: null, seasons: [] }; + } + return response.json(); + }); } diff --git a/web/src/embed.ts b/web/src/embed.ts index 45052de..febe42c 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -22,6 +22,7 @@ interface EmbedConfig { autoPlay: boolean; speed: number; loop: boolean; + viewMode: 'standard' | 'dots' | 'voronoi' | 'influence'; } class EmbedViewer { @@ -77,11 +78,19 @@ class EmbedViewer { const matchIdMatch = path.match(/\/embed\/([^/]+)/); const matchId = matchIdMatch ? matchIdMatch[1] : params.get('match_id') || ''; + // Parse view mode - default to 'influence' (territory view) for homepage embeds + const viewModeParam = params.get('view'); + const viewMode: 'standard' | 'dots' | 'voronoi' | 'influence' = + viewModeParam === 'standard' || viewModeParam === 'dots' || viewModeParam === 'voronoi' || viewModeParam === 'influence' + ? viewModeParam + : 'influence'; // Default to territory view for homepage + return { matchId, autoPlay: params.get('autoplay') !== 'false', speed: parseInt(params.get('speed') || '100', 10), loop: params.get('loop') === 'true', + viewMode, }; } @@ -121,6 +130,7 @@ class EmbedViewer { this.viewer = new ReplayViewer(this.canvas, { cellSize: 10, animationSpeed: this.config.speed, + viewMode: this.config.viewMode, }); this.viewer.loadReplay(replay); diff --git a/web/src/pages/evolution.ts b/web/src/pages/evolution.ts index 277ab3a..fd99fcd 100644 --- a/web/src/pages/evolution.ts +++ b/web/src/pages/evolution.ts @@ -1,6 +1,6 @@ // Evolution dashboard - shows live evolution pipeline status -import { fetchEvolutionData, type EvolutionLiveData, type IslandStat, type LineageNode, type MetaSnapshot, type GenerationEntry } from '../api-types'; +import { fetchEvolutionData, type EvolutionLiveData, type IslandStat, type LineageNode, type MetaSnapshot, type GenerationEntry, type CycleInfo, type ActivityEntry, type Totals, type Candidate } from '../api-types'; const ISLAND_COLORS: Record = { alpha: '#ef4444', // red - core-rushing @@ -16,6 +16,8 @@ const ISLAND_LABELS: Record = { delta: 'Delta (Experimental)', }; +let pollingInterval: number | null = null; + export async function renderEvolutionPage(): Promise { const app = document.getElementById('app'); if (!app) return; @@ -30,6 +32,21 @@ export async function renderEvolutionPage(): Promise { const content = document.getElementById('evolution-content'); if (!content) return; + // Clear any existing poll + if (pollingInterval !== null) { + clearInterval(pollingInterval); + } + + // Initial load + await loadEvolutionData(content); + + // Start polling for live updates (every 10 seconds) + pollingInterval = window.setInterval(() => { + loadEvolutionData(content); + }, 10000); +} + +async function loadEvolutionData(content: HTMLElement): Promise { try { const data = await fetchEvolutionData(); renderDashboard(content, data); @@ -44,16 +61,39 @@ export async function renderEvolutionPage(): Promise { } } +// Stop polling when navigating away +export function cleanupEvolutionPage(): void { + if (pollingInterval !== null) { + clearInterval(pollingInterval); + pollingInterval = null; + } +} + function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void { container.innerHTML = `

Last updated: ${formatTimestamp(data.updated_at)}  ·  - ${data.total_programs} programs  ·  ${data.promoted_count} promoted

+ ${data.total_programs || 0} programs  ·  ${data.promoted_count || 0} promoted

-

Island Status

+

Live Status

+
+
+ +
+

Island Overview

+
+

Statistics

+
+
+ +
+

Recent Activity

+
+
+

Meta Tracker Best fitness per island over generations

@@ -95,6 +135,218 @@ function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void margin-left: 8px; } + /* Live status */ + .live-status-container { + display: flex; + flex-direction: column; + gap: 16px; + } + + .live-status-main { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: center; + } + + .live-status-item { + display: flex; + flex-direction: column; + gap: 4px; + } + + .live-status-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .live-status-value { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + } + + .live-status-phase { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 600; + color: white; + } + + .candidate-info { + background-color: var(--bg-primary); + border-radius: 8px; + padding: 16px; + border-left: 4px solid var(--accent-color, #3b82f6); + } + + .candidate-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .candidate-id { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } + + .candidate-island { + font-size: 0.875rem; + font-weight: 500; + text-transform: uppercase; + } + + .candidate-parents { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + font-size: 0.8125rem; + color: var(--text-muted); + } + + .parent-tag { + background-color: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 4px; + font-family: monospace; + } + + .candidate-validation { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .validation-stage { + font-size: 0.8125rem; + padding: 4px 8px; + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-muted); + } + + .validation-stage.passed { + background-color: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + .candidate-evaluation { + display: flex; + align-items: center; + gap: 12px; + } + + .evaluation-progress { + flex: 1; + height: 6px; + background-color: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + } + + .evaluation-bar { + height: 100%; + background-color: var(--accent-color, #3b82f6); + transition: width 0.3s; + } + + .evaluation-text { + font-size: 0.8125rem; + color: var(--text-muted); + } + + /* Statistics grid */ + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + } + + .stat-card { + background-color: var(--bg-primary); + border-radius: 8px; + padding: 16px; + text-align: center; + } + + .stat-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + } + + /* Activity feed */ + .activity-feed { + display: flex; + flex-direction: column; + gap: 8px; + } + + .activity-entry { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background-color: var(--bg-primary); + border-radius: 6px; + font-size: 0.875rem; + } + + .activity-time { + color: var(--text-muted); + font-size: 0.8125rem; + min-width: 60px; + } + + .activity-result { + font-weight: 600; + min-width: 90px; + } + + .activity-result.result-promoted { + color: #22c55e; + } + + .activity-result.result-rejected { + color: #ef4444; + } + + .activity-candidate { + font-family: monospace; + color: var(--text-primary); + } + + .activity-island { + text-transform: uppercase; + font-size: 0.8125rem; + min-width: 60px; + } + + .activity-reason { + color: var(--text-muted); + font-size: 0.8125rem; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* Island status grid */ .island-grid { display: grid; @@ -237,20 +489,29 @@ function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void .island-grid { grid-template-columns: 1fr 1fr; } + .stats-grid { + grid-template-columns: 1fr 1fr; + } } @media (max-width: 480px) { .island-grid { grid-template-columns: 1fr; } + .stats-grid { + grid-template-columns: 1fr; + } } `; renderIslandGrid(document.getElementById('island-grid')!, data.islands); - renderMetaChart(document.getElementById('meta-chart')!, data.meta_snapshots); - renderLineageTree(document.getElementById('lineage-tree')!, data.lineage); - renderGenerationLog(document.getElementById('generation-log')!, data.generation_log); + renderLiveStatus(document.getElementById('live-status')!, data.cycle); + renderStatistics(document.getElementById('statistics')!, data.totals); + renderActivityFeed(document.getElementById('activity-feed')!, data.recent_activity || []); + renderMetaChart(document.getElementById('meta-chart')!, data.meta_snapshots ?? []); + renderLineageTree(document.getElementById('lineage-tree')!, data.lineage ?? []); + renderGenerationLog(document.getElementById('generation-log')!, data.generation_log ?? []); } // ── Island Status ────────────────────────────────────────────────────────────── @@ -262,31 +523,20 @@ function renderIslandGrid(container: HTMLElement, islands: Record
${escapeHtml(label)}
Population - ${stat.count} + ${stat.population}
- Best Fitness - ${(stat.best_fitness * 100).toFixed(1)}% + Best Rating + ${stat.best_rating}
- Avg Fitness - ${(stat.avg_fitness * 100).toFixed(1)}% -
-
- Promoted - ${stat.promoted_count} -
-
-
-
-
- Diversity: ${diversityPct}% + Best Bot + ${escapeHtml(stat.best_bot || '—')}
`; @@ -294,6 +544,162 @@ function renderIslandGrid(container: HTMLElement, islands: Record = { + idle: '#94a3b8', + generating: '#f59e0b', + validating: '#3b82f6', + evaluating: '#8b5cf6', + promoting: '#22c55e', + }; + + const phaseLabel = cycle.phase.charAt(0).toUpperCase() + cycle.phase.slice(1); + + container.innerHTML = ` +
+
+
+ Generation + #${cycle.generation} +
+
+ Phase + ${phaseLabel} +
+
+ Started + ${formatTimestamp(cycle.started_at)} +
+
+ ${cycle.candidate ? renderCandidateInfo(cycle.candidate) : ''} +
+ `; +} + +function renderCandidateInfo(candidate: Candidate): string { + let statusHTML = ''; + + if (candidate.validation) { + const v = candidate.validation; + statusHTML += ` +
+
Syntax ${v.syntax?.passed ? '✓' : '⋯'}
+
Schema ${v.schema?.passed ? '✓' : '⋯'}
+
Smoke ${v.smoke?.passed ? '✓' : '⋯'}
+
+ `; + } + + if (candidate.evaluation && candidate.evaluation.matches_total > 0) { + const played = candidate.evaluation.matches_played; + const total = candidate.evaluation.matches_total; + const pct = Math.round((played / total) * 100); + statusHTML += ` +
+
+
+
+ Evaluating: ${played}/${total} matches +
+ `; + } + + return ` +
+
+ ${escapeHtml(candidate.id)} + ${escapeHtml(candidate.island)} +
+
+ Parents: ${candidate.parents.map(p => `${escapeHtml(p.id)} (${p.rating})`).join('')} +
+ ${statusHTML} +
+ `; +} + +// ── Statistics ───────────────────────────────────────────────────────────────── + +function renderStatistics(container: HTMLElement, totals: Totals): void { + container.innerHTML = ` +
+
+
Total Generations
+
${totals.generations_total}
+
+
+
Candidates Today
+
${totals.candidates_today}
+
+
+
Promoted Today
+
${totals.promoted_today}
+
+
+
Promotion Rate (7d)
+
${(totals.promotion_rate_7d * 100).toFixed(1)}%
+
+
+
Highest Evolved Rating
+
${totals.highest_evolved_rating}
+
+
+
Evolved in Top 10
+
${totals.evolved_in_top_10}
+
+
+ `; +} + +// ── Activity Feed ─────────────────────────────────────────────────────────────── + +function renderActivityFeed(container: HTMLElement, activities: ActivityEntry[]): void { + if (!activities || activities.length === 0) { + container.innerHTML = '

No recent activity.

'; + return; + } + + const rows = activities.map(a => { + const resultClass = a.result === 'promoted' ? 'result-promoted' : 'result-rejected'; + const resultIcon = a.result === 'promoted' ? '🟢' : '🔴'; + const color = ISLAND_COLORS[a.island] || '#94a3b8'; + + return ` +
+ ${formatTimeAgo(a.time)} + ${resultIcon} ${escapeHtml(a.result)} + ${escapeHtml(a.candidate)} + ${escapeHtml(a.island)} + ${escapeHtml(a.reason)} +
+ `; + }).join(''); + + container.innerHTML = `
${rows}
`; +} + +function formatTimeAgo(iso: string): string { + try { + const then = new Date(iso).getTime(); + const now = Date.now(); + const seconds = Math.floor((now - then) / 1000); + + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; + } catch { + return iso; + } +} + // ── Meta Tracker Chart ───────────────────────────────────────────────────────── function renderMetaChart(container: HTMLElement, snapshots: MetaSnapshot[]): void { diff --git a/web/src/pages/predictions.ts b/web/src/pages/predictions.ts index c0f68a0..0fb2d1f 100644 --- a/web/src/pages/predictions.ts +++ b/web/src/pages/predictions.ts @@ -1,17 +1,28 @@ -// Predictions Page - Prediction leaderboard and stats -import type { BotProfile, PredictorStats } from '../api-types'; -import { fetchPredictionsLeaderboard } from '../api-types'; +// Predictions Page - Prediction leaderboard, open matches, and submission +import type { BotProfile, PredictorStats, OpenMatch } from '../api-types'; +import { + fetchPredictionsLeaderboard, + fetchOpenPredictions, + submitPrediction, + getOrCreatePredictorId, +} from '../api-types'; const PAGES_BASE = ''; +const API_BASE = ''; + +let openMatches: OpenMatch[] = []; +let predictorId = ''; export async function renderPredictionsPage(): Promise { const app = document.getElementById('app'); if (!app) return; + predictorId = getOrCreatePredictorId(); + app.innerHTML = `
-

Prediction Leaderboard

-

Top predictors and their accuracy stats

+

Predictions

+

Predict match outcomes and climb the leaderboard

How It Works

@@ -41,6 +52,13 @@ export async function renderPredictionsPage(): Promise {
+
+

Open Matches

+
+
Loading open matches...
+
+
+

Top Predictors

@@ -71,8 +89,7 @@ export async function renderPredictionsPage(): Promise {
-

Log in to track your predictions

- +

Make your first prediction above to start tracking stats

@@ -147,6 +164,84 @@ export async function renderPredictionsPage(): Promise { margin: 0; } + .open-section { + margin-bottom: 32px; + } + + .open-section h2 { + margin-bottom: 16px; + } + + .open-match-card { + background-color: var(--bg-secondary); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 16px; + } + + .open-match-card .vs { + color: var(--text-muted); + font-size: 0.8rem; + flex-shrink: 0; + } + + .open-match-card .bot-option { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + } + + .open-match-card .bot-option .bot-info { + min-width: 0; + } + + .open-match-card .bot-option .bot-name { + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .open-match-card .bot-option .bot-rating { + font-size: 0.75rem; + color: var(--text-muted); + } + + .open-match-card .pick-btn { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; + } + + .open-match-card .pick-btn:hover { + background: var(--accent); + color: white; + } + + .open-match-card .pick-btn.picked { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .open-match-card .pick-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .leaderboard-section { margin-bottom: 32px; } @@ -301,14 +396,129 @@ export async function renderPredictionsPage(): Promise { font-size: 0.75rem; margin-top: 16px; } + + .prediction-error { + color: #ef4444; + font-size: 0.8rem; + margin-top: 8px; + } + + @media (max-width: 640px) { + .rules-grid { + grid-template-columns: 1fr; + } + .open-match-card { + flex-direction: column; + align-items: stretch; + } + .open-match-card .bot-option { + justify-content: space-between; + } + } `; - // Load leaderboard - await loadLeaderboard(); + // Load open matches and leaderboard in parallel + await Promise.all([loadOpenMatches(), loadLeaderboard()]); +} + +async function loadOpenMatches(): Promise { + const container = document.getElementById('open-matches-container'); + if (!container) return; + + try { + const data = await fetchOpenPredictions(predictorId); + openMatches = data.matches || []; + + if (openMatches.length === 0) { + container.innerHTML = '
No open matches available for prediction right now. Check back soon!
'; + return; + } + + container.innerHTML = openMatches.map(m => { + const participants = m.participants || []; + if (participants.length < 2) return ''; + + const botA = participants[0]; + const botB = participants[1]; + const pickedA = m.your_pick === botA.bot_id; + const pickedB = m.your_pick === botB.bot_id; + + return ` +
+
+
+
${escapeHtml(botA.name)}
+
Rating: ${Math.round(botA.rating)}
+
+ +
+ vs +
+
+
${escapeHtml(botB.name)}
+
Rating: ${Math.round(botB.rating)}
+
+ +
+
+ `; + }).join(''); + + // Attach click handlers + container.querySelectorAll('.pick-btn:not(.picked)').forEach(btn => { + btn.addEventListener('click', handlePick); + }); + } catch (err) { + console.error('Failed to load open matches:', err); + container.innerHTML = '
Failed to load open matches
'; + } +} + +async function handlePick(e: Event): Promise { + const btn = e.target as HTMLButtonElement; + const matchId = btn.getAttribute('data-match')!; + const botId = btn.getAttribute('data-bot')!; + const card = btn.closest('.open-match-card') as HTMLElement; + + // Disable all buttons in this card + card.querySelectorAll('.pick-btn').forEach(b => { + (b as HTMLButtonElement).disabled = true; + }); + btn.textContent = 'Submitting...'; + + try { + await submitPrediction(matchId, botId, predictorId); + + // Mark the picked button + btn.textContent = 'Picked'; + btn.classList.add('picked'); + + // Update the other button to show it wasn't picked + card.querySelectorAll('.pick-btn:not(.picked)').forEach(b => { + (b as HTMLButtonElement).textContent = 'Not picked'; + }); + } catch (err) { + console.error('Failed to submit prediction:', err); + btn.textContent = 'Error'; + card.querySelectorAll('.pick-btn').forEach(b => { + (b as HTMLButtonElement).disabled = false; + }); + + // Show error message + const errDiv = card.querySelector('.prediction-error'); + if (errDiv) errDiv.textContent = (err as Error).message; + } } -// fetch bot names for leaderboard display async function loadLeaderboard(): Promise { const container = document.getElementById('leaderboard-container'); if (!container) return; @@ -321,6 +531,23 @@ async function loadLeaderboard(): Promise { return; } + // Check if current predictor is in the list + const myEntry = data.entries.find((e: PredictorStats) => e.predictor_id === predictorId); + if (myEntry) { + const statsEl = document.getElementById('your-stats'); + const promptEl = document.getElementById('stats-login-prompt'); + if (statsEl && promptEl) { + statsEl.style.display = 'block'; + promptEl.style.display = 'none'; + const total = myEntry.correct + myEntry.incorrect; + const accuracy = total > 0 ? Math.round((myEntry.correct / total) * 100) : 0; + document.getElementById('stat-total')!.textContent = String(total); + document.getElementById('stat-accuracy')!.textContent = `${accuracy}%`; + document.getElementById('stat-streak')!.textContent = String(myEntry.streak); + document.getElementById('stat-best-streak')!.textContent = String(myEntry.best_streak); + } + } + // Fetch bot names for predictor IDs const botNames = await fetchBotNames(data.entries.map((e: PredictorStats) => e.predictor_id)); @@ -342,11 +569,12 @@ async function loadLeaderboard(): Promise { const accuracy = total > 0 ? Math.round((entry.correct / total) * 100) : 0; const streakClass = entry.streak > 0 ? 'positive' : entry.streak < 0 ? 'negative' : 'neutral'; const botName = botNames.get(entry.predictor_id) || entry.predictor_id; + const isYou = entry.predictor_id === predictorId; return ` #${idx + 1} - ${botName} + ${botName}${isYou ? ' (you)' : ''} ${entry.correct} ${entry.incorrect} @@ -374,7 +602,13 @@ async function loadLeaderboard(): Promise { } } - async function fetchBotNames(botIds: string[]): Promise> { +function escapeHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +async function fetchBotNames(botIds: string[]): Promise> { const names = new Map(); const uniqueIds = [...new Set(botIds)]; diff --git a/web/src/pages/seasons.ts b/web/src/pages/seasons.ts index 51a375e..9e4e7a7 100644 --- a/web/src/pages/seasons.ts +++ b/web/src/pages/seasons.ts @@ -1,4 +1,4 @@ -// Seasons Page - Browse seasonal competitions +// Seasons Page - Browse seasonal competitions with per-season rankings import type { Season, SeasonIndex, SeasonSnapshot } from '../types'; const PAGES_BASE = ''; @@ -63,6 +63,8 @@ export async function renderSeasonsPage(): Promise { justify-content: space-between; align-items: flex-start; margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; } .season-info h3 { @@ -105,6 +107,57 @@ export async function renderSeasonsPage(): Promise { margin-top: 4px; } + /* Active season mini-leaderboard */ + .mini-leaderboard { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + } + + .mini-leaderboard h4 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 8px; + } + + .mini-leaderboard-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 0.8rem; + } + + .mini-leaderboard-row .rank { + font-weight: 700; + width: 24px; + text-align: center; + color: var(--text-muted); + } + + .mini-leaderboard-row .rank-1 { color: gold; } + .mini-leaderboard-row .rank-2 { color: silver; } + .mini-leaderboard-row .rank-3 { color: #cd7f32; } + + .mini-leaderboard-row .bot-name { + flex: 1; + color: var(--text-primary); + } + + .mini-leaderboard-row .bot-rating { + font-family: monospace; + color: var(--text-muted); + } + + .mini-leaderboard-row .bot-record { + font-size: 0.7rem; + color: var(--text-muted); + min-width: 60px; + text-align: right; + } + .seasons-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); @@ -188,38 +241,59 @@ export async function renderSeasonsPage(): Promise { text-decoration: underline; } + /* Detail view leaderboard */ .leaderboard-table { width: 100%; border-collapse: collapse; + background-color: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + margin-bottom: 24px; } .leaderboard-table th, .leaderboard-table td { - padding: 12px; + padding: 12px 16px; text-align: left; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--bg-tertiary); } .leaderboard-table th { + background-color: var(--bg-tertiary); color: var(--text-muted); - font-weight: 500; + font-weight: 600; font-size: 0.75rem; text-transform: uppercase; + letter-spacing: 0.05em; } - .leaderboard-table .rank-1 { - color: gold; + .leaderboard-table .rank { font-weight: 700; + color: var(--text-muted); } - .leaderboard-table .rank-2 { - color: silver; - font-weight: 600; + .leaderboard-table tr.rank-1 .rank { color: #fbbf24; } + .leaderboard-table tr.rank-2 .rank { color: #94a3b8; } + .leaderboard-table tr.rank-3 .rank { color: #cd7f32; } + + .leaderboard-table .bot-name-cell { + display: flex; + align-items: center; + gap: 8px; } - .leaderboard-table .rank-3 { - color: #cd7f32; - font-weight: 500; + .leaderboard-table .win-bar { + height: 4px; + border-radius: 2px; + background-color: #22c55e; + min-width: 2px; + } + + .leaderboard-table .loss-bar { + height: 4px; + border-radius: 2px; + background-color: #ef4444; + min-width: 2px; } .empty-message { @@ -249,10 +323,40 @@ export async function renderSeasonsPage(): Promise { .season-rules li { margin-bottom: 4px; } + + /* Stats summary row */ + .stats-row { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; + } + + .stat-card { + flex: 1; + min-width: 120px; + background-color: var(--bg-secondary); + border-radius: 8px; + padding: 16px; + text-align: center; + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + } + + .stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-top: 4px; + } `; - // Load seasons data await loadSeasons(); } @@ -273,13 +377,11 @@ async function loadSeasons(): Promise { activeSeasonContainer.style.display = 'block'; activeSeasonContent.innerHTML = renderActiveSeason(index.active_season); - // Wire click handler activeSeasonContent.querySelector('.season-card')?.addEventListener('click', () => { showSeasonDetail(index.active_season!.id); }); } - // Render all seasons if (index.seasons.length === 0) { list.innerHTML = '
No seasons available yet
'; return; @@ -287,11 +389,11 @@ async function loadSeasons(): Promise { list.innerHTML = index.seasons.map((s: Season) => `
-

${s.name}

+

${escapeHtml(s.name)}

${s.champion_name ? `
- 👑 - ${s.champion_name} + 👑 + ${escapeHtml(s.champion_name)}
` : ''}
@@ -301,7 +403,6 @@ async function loadSeasons(): Promise {
`).join(''); - // Wire click handlers list.querySelectorAll('.season-card').forEach(card => { card.addEventListener('click', () => { const seasonId = (card as HTMLElement).dataset.seasonId; @@ -315,6 +416,12 @@ async function loadSeasons(): Promise { } } +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + function renderActiveSeason(season: Season): string { const startDate = new Date(season.starts_at); const now = new Date(); @@ -327,12 +434,31 @@ function renderActiveSeason(season: Season): string { progressPercent = Math.min(100, Math.max(0, (elapsed / total) * 100)); } + // Build mini-leaderboard from snapshot if available + let miniLeaderboard = ''; + if (season.final_snapshot && season.final_snapshot.length > 0) { + const top5 = season.final_snapshot.slice(0, 5); + miniLeaderboard = ` +
+

Top Bots

+ ${top5.map((entry: SeasonSnapshot) => ` +
+ #${entry.rank} + ${escapeHtml(entry.bot_name)} + ${Math.round(entry.rating)} + ${entry.wins}W ${entry.losses}L +
+ `).join('')} +
+ `; + } + return `
-

${season.name}

-

${season.theme}

+

${escapeHtml(season.name)}

+

${escapeHtml(season.theme)}

${season.status} @@ -349,6 +475,7 @@ function renderActiveSeason(season: Season): string { ${Math.round(progressPercent)}% complete
+ ${miniLeaderboard}
`; } @@ -367,11 +494,54 @@ async function showSeasonDetail(seasonId: string): Promise { if (!response.ok) throw new Error('Season not found'); const season: Season = await response.json(); + // Compute max wins/losses for bar scaling + const maxGames = season.final_snapshot?.reduce((max: number, e: SeasonSnapshot) => { + return Math.max(max, e.wins, e.losses); + }, 1) || 1; + + const leaderboardHtml = season.final_snapshot && season.final_snapshot.length > 0 + ? ` + + + + + + + + + + + + ${season.final_snapshot.map((entry: SeasonSnapshot) => { + const total = entry.wins + entry.losses; + const winRate = total > 0 ? (entry.wins / total * 100).toFixed(0) : '-'; + const winWidth = maxGames > 0 ? (entry.wins / maxGames * 60) : 0; + const lossWidth = maxGames > 0 ? (entry.losses / maxGames * 60) : 0; + return ` + + + + + + + + `}).join('')} + +
RankBotRatingRecordWin Rate
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}W / ${entry.losses}L +
+
+
+ ${winRate}% +
+
+ ` + : '

No leaderboard data available yet.

'; + detailContent.innerHTML = `
-

${season.name}

-

${season.theme}

+

${escapeHtml(season.name)}

+

${escapeHtml(season.theme)}

${season.status} @@ -382,40 +552,38 @@ async function showSeasonDetail(seasonId: string): Promise { ${season.champion_name ? `
- 👑 + 👑
CHAMPION
- ${season.champion_name} + ${escapeHtml(season.champion_name)}
` : ''} - ${season.final_snapshot && season.final_snapshot.length > 0 ? ` -

Final Leaderboard

- - - - - - - - - - - ${season.final_snapshot.map((entry: SeasonSnapshot) => ` - - - - - - - `).join('')} - -
RankBotRatingW/L
#${entry.rank}${entry.bot_name}${Math.round(entry.rating)}${entry.wins}/${entry.losses}
- ` : '

No leaderboard data available yet.

'} +
+
+
${season.total_matches}
+
Matches Played
+
+ ${season.final_snapshot ? ` +
+
${season.final_snapshot.length}
+
Ranked Bots
+
+ ` : ''} + ${season.final_snapshot && season.final_snapshot.length > 0 ? ` +
+
${Math.round(season.final_snapshot[0].rating)}
+
Highest Rating
+
+ ` : ''} +
+ +

Season Leaderboard

+ ${leaderboardHtml}
-

Rules Version: ${season.rules_version}

+

Rules Version: ${escapeHtml(season.rules_version)}

  • Standard 60x60 toroidal grid
  • 500 turn limit
  • diff --git a/web/src/pages/series.ts b/web/src/pages/series.ts index 2cb0469..b0b09b8 100644 --- a/web/src/pages/series.ts +++ b/web/src/pages/series.ts @@ -1,5 +1,5 @@ -// Series Page - Browse multi-game series between bots -import type { Series, SeriesIndex } from '../types'; +// Series Page - Browse multi-game series between bots with bracket visualization +import type { Series, SeriesIndex, SeriesGame } from '../types'; import type { BotProfile } from '../api-types'; const PAGES_BASE = ''; @@ -25,6 +25,11 @@ export async function renderSeriesPage(): Promise {
+
+ + +
+
Loading series...
@@ -65,6 +70,25 @@ export async function renderSeriesPage(): Promise { font-size: 14px; } + .spoiler-toggle { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + } + + .spoiler-toggle input { + width: 16px; + height: 16px; + } + + .spoiler-hidden .bracket-progress, + .spoiler-hidden .bracket-dot, + .spoiler-hidden .game-result-text { + filter: blur(4px); + cursor: pointer; + } + .series-list { display: flex; flex-direction: column; @@ -109,31 +133,84 @@ export async function renderSeriesPage(): Promise { color: var(--text-primary); } - .series-bot-rating { - font-size: 0.75rem; - color: var(--text-muted); - } - .series-vs { font-size: 0.875rem; color: var(--text-muted); font-weight: 600; } - .series-score { + /* Bracket progress bar: horizontal track with dots */ + .bracket-container { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + } + + .bracket-labels { display: flex; - align-items: center; - gap: 8px; - font-size: 1.25rem; + justify-content: space-between; + margin-bottom: 6px; + } + + .bracket-label { + font-size: 0.75rem; font-weight: 600; } - .score-winner { - color: #22c55e; + .bracket-label.bot-a { color: #3b82f6; } + .bracket-label.bot-b { color: #ef4444; } + + .bracket-track { + display: flex; + align-items: center; + gap: 4px; + justify-content: center; } - .score-loser { + .bracket-dot { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + border: 2px solid var(--border); + background-color: var(--bg-tertiary); color: var(--text-muted); + transition: all 0.2s; + } + + .bracket-dot.win-a { + background-color: #3b82f6; + border-color: #3b82f6; + color: white; + } + + .bracket-dot.win-b { + background-color: #ef4444; + border-color: #ef4444; + color: white; + } + + .bracket-dot.draw { + background-color: #6b7280; + border-color: #6b7280; + color: white; + } + + .bracket-dot.pending { + background-color: var(--bg-tertiary); + border-color: var(--border); + color: var(--text-muted); + border-style: dashed; + } + + .bracket-connector { + width: 12px; + height: 2px; + background-color: var(--border); } .series-meta { @@ -141,6 +218,7 @@ export async function renderSeriesPage(): Promise { justify-content: space-between; color: var(--text-muted); font-size: 0.75rem; + margin-top: 8px; } .status-badge { @@ -155,37 +233,86 @@ export async function renderSeriesPage(): Promise { .status-badge.completed { background-color: #3b82f6; color: white; } .status-badge.pending { background-color: #6b7280; color: white; } - .series-games { + /* Detail view styles */ + .detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + .detail-score { + display: flex; + align-items: center; + gap: 12px; + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 24px; + justify-content: center; + } + + .detail-score .score-a { color: #3b82f6; } + .detail-score .score-b { color: #ef4444; } + .detail-score .score-dash { color: var(--text-muted); } + + /* Large bracket for detail view */ + .detail-bracket { display: flex; flex-direction: column; gap: 8px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); + margin-bottom: 24px; + padding: 16px; + background-color: var(--bg-secondary); + border-radius: 8px; } .game-row { display: flex; align-items: center; gap: 12px; - padding: 8px 12px; + padding: 10px 12px; background-color: var(--bg-tertiary); border-radius: 6px; + border-left: 3px solid transparent; + transition: background-color 0.15s; } + .game-row:hover { + background-color: rgba(255, 255, 255, 0.05); + } + + .game-row.win-a { border-left-color: #3b82f6; } + .game-row.win-b { border-left-color: #ef4444; } + .game-row.draw { border-left-color: #6b7280; } + .game-row.pending { border-left-color: var(--border); } + .game-number { font-weight: 600; color: var(--text-muted); - min-width: 30px; + min-width: 60px; + font-size: 0.8rem; } - .game-result { + .game-result-text { flex: 1; color: var(--text-primary); + font-size: 0.875rem; } - .game-result.winner-1 { color: #3b82f6; } - .game-result.winner-2 { color: #ef4444; } + .game-result-text.winner-a { color: #3b82f6; } + .game-result-text.winner-b { color: #ef4444; } + + .game-badge { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + } + + .game-badge.win-a { background-color: #3b82f6; } + .game-badge.win-b { background-color: #ef4444; } + .game-badge.draw { background-color: #6b7280; } .watch-btn { background-color: var(--accent); @@ -201,24 +328,6 @@ export async function renderSeriesPage(): Promise { opacity: 0.9; } - .spoiler-toggle { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; - } - - .spoiler-toggle input { - width: 16px; - height: 16px; - } - - .spoiler-hidden .series-score, - .spoiler-hidden .game-result { - filter: blur(4px); - cursor: pointer; - } - .loading { color: var(--text-muted); text-align: center; @@ -251,15 +360,6 @@ export async function renderSeriesPage(): Promise { await loadSeries(); // Setup spoiler toggle - const spoilerToggle = document.createElement('div'); - spoilerToggle.className = 'spoiler-toggle'; - spoilerToggle.innerHTML = ` - - - `; - const seriesList = document.getElementById('series-list'); - seriesList?.parentElement?.insertBefore(spoilerToggle, seriesList); - document.getElementById('spoiler-toggle')?.addEventListener('change', (e) => { const checked = (e.target as HTMLInputElement).checked; document.querySelector('.series-list')?.classList.toggle('spoiler-hidden', checked); @@ -310,7 +410,7 @@ async function loadSeries(): Promise { botFilter.appendChild(option); }); - // Render series cards + // Render series cards with bracket visualization renderSeriesList(index.series, list, botNames); // Filter handlers @@ -334,6 +434,34 @@ async function loadSeries(): Promise { } } +function renderBracketProgress(bot1Name: string, bot2Name: string, games: SeriesGame[], format: number): string { + const dots: string[] = []; + for (let i = 0; i < format; i++) { + const game = games[i]; + if (!game || !game.winner_id) { + dots.push(`
${i + 1}
`); + } else if (game.winner_slot === 0) { + dots.push(`
${i + 1}
`); + } else if (game.winner_slot === 1) { + dots.push(`
${i + 1}
`); + } else { + dots.push(`
${i + 1}
`); + } + } + + return ` +
+
+ ${bot1Name} + ${bot2Name} +
+
+ ${dots.join('
')} +
+
+ `; +} + function renderSeriesList(series: Series[], container: HTMLElement, _botNames: Map): void { container.innerHTML = series.map(s => `
@@ -347,15 +475,11 @@ function renderSeriesList(series: Series[], container: HTMLElement, _botNames: M ${s.bot2_name}
-
- ${s.bot1_wins} - - - ${s.bot2_wins} -
+ ${renderBracketProgress(s.bot1_name, s.bot2_name, s.games || [], s.best_of)}
${s.status} - Best of ${s.best_of} + Best of ${s.best_of} · ${s.bot1_wins}-${s.bot2_wins} ${s.completed_at ? new Date(s.completed_at).toLocaleDateString() : 'In progress'}
@@ -375,6 +499,8 @@ async function showSeriesDetail(seriesId: string): Promise { const detail = document.getElementById('series-detail'); const detailContent = document.getElementById('series-detail-content'); const backBtn = document.getElementById('back-btn'); + const spoilerToggle = document.getElementById('spoiler-toggle') as HTMLInputElement; + const spoilerActive = spoilerToggle?.checked; if (!list || !detail || !detailContent) return; @@ -383,34 +509,48 @@ async function showSeriesDetail(seriesId: string): Promise { if (!response.ok) throw new Error('Series not found'); const series: Series = await response.json(); + const gamesHtml = (series.games || []).map(g => { + const winnerClass = g.winner_slot === 0 ? 'win-a' : g.winner_slot === 1 ? 'win-b' : 'draw'; + const resultClass = g.winner_slot === 0 ? 'winner-a' : g.winner_slot === 1 ? 'winner-b' : ''; + const winnerName = g.winner_slot === 0 ? series.bot1_name : g.winner_slot === 1 ? series.bot2_name : 'Draw'; + const badgeClass = g.winner_slot === 0 ? 'win-a' : g.winner_slot === 1 ? 'win-b' : 'draw'; + + const resultText = spoilerActive + ? '***' + : (g.completed_at + ? (g.winner_id ? `Winner: ${winnerName}` : 'Draw') + : 'Not yet played'); + + return ` +
+ Game ${g.game_number} + + + ${resultText} + ${g.turns ? ` (${g.turns} turns)` : ''} + + ${g.match_id ? `` : ''} +
+ `; + }).join(''); + detailContent.innerHTML = ` -
+

${series.bot1_name} vs ${series.bot2_name}

${series.status}
-
- ${series.bot1_wins} - - - ${series.bot2_wins} +
+ ${series.bot1_wins} + - + ${series.bot2_wins}
-

Games

-
- ${series.games.map(g => { - const winnerClass = g.winner_slot === 0 ? 'winner-1' : g.winner_slot === 1 ? 'winner-2' : ''; - const winnerName = g.winner_slot === 0 ? series.bot1_name : g.winner_slot === 1 ? series.bot2_name : 'Draw'; - return ` -
- Game ${g.game_number} - - ${g.completed_at ? (g.winner_id ? `Winner: ${winnerName}` : 'Draw') : 'Not played'} - ${g.turns ? `(${g.turns} turns)` : ''} - - ${g.match_id ? `` : ''} -
- `; - }).join('')} + ${renderBracketProgress(series.bot1_name, series.bot2_name, series.games || [], series.best_of)} + +
+

Game Results

+ ${gamesHtml}
`; @@ -425,12 +565,27 @@ async function showSeriesDetail(seriesId: string): Promise { }); }); + // Hide spoilers in detail view if active + if (spoilerActive) { + detailContent.querySelectorAll('.game-result-text, .bracket-dot').forEach(el => { + el.classList.add('spoiler-blur'); + }); + } + list.style.display = 'none'; detail.style.display = 'block'; + // Also hide the filters and spoiler toggle + const filters = document.querySelector('.series-filters') as HTMLElement; + const spoilerDiv = document.querySelector('.spoiler-toggle') as HTMLElement; + if (filters) filters.style.display = 'none'; + if (spoilerDiv) spoilerDiv.style.display = 'none'; + backBtn!.onclick = () => { detail.style.display = 'none'; list.style.display = 'flex'; + if (filters) filters.style.display = 'flex'; + if (spoilerDiv) spoilerDiv.style.display = 'flex'; }; } catch (err) { diff --git a/web/src/styles/mobile.css b/web/src/styles/mobile.css new file mode 100644 index 0000000..e06b820 --- /dev/null +++ b/web/src/styles/mobile.css @@ -0,0 +1,684 @@ +/* ──────────────────────────────────────────────────────────────────────────────── */ +/* Mobile Responsive Styles */ +/* ──────────────────────────────────────────────────────────────────────────────── */ + +/* Mobile-first responsive breakpoints: + - <640px: phone — single column, bottom tab bar, touch-optimized + - 640–1024px: tablet — two column where useful, top nav + - >1024px: desktop — full layout, sidebar where appropriate +*/ + +/* ─── Phone (<640px) ───────────────────────────────────────────────────────────── */ + +@media (max-width: 639px) { + /* Typography */ + h1 { font-size: 1.5rem; } + h2 { font-size: 1.25rem; } + h3 { font-size: 1.125rem; } + + /* Page titles */ + .page-title { + font-size: 1.5rem; + margin-bottom: 16px; + } + + /* Cards - full width */ + .card { + border-radius: var(--radius-md); + padding: var(--space-sm); + margin-bottom: var(--space-sm); + } + + /* Grid - single column */ + .grid-2, .grid-3, .grid-4 { + grid-template-columns: 1fr; + } + + /* Buttons - larger touch targets */ + .btn { + min-height: 48px; + min-width: 48px; + padding: var(--space-md); + } + + .btn.small { + min-height: 40px; + min-width: 40px; + padding: var(--space-sm) var(--space-md); + } + + /* Tables - convert to cards on mobile */ + .table-container { + border: none; + } + + table { + display: none; + } + + .mobile-cards { + display: flex; + flex-direction: column; + gap: var(--space-md); + } + + .mobile-card { + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + padding: var(--space-md); + } + + .mobile-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-sm); + } + + .mobile-card-title { + font-weight: 600; + color: var(--text-primary); + } + + .mobile-card-content { + display: none; + padding-top: var(--space-sm); + border-top: 1px solid var(--border); + } + + .mobile-card-content.expanded { + display: block; + } + + .mobile-card.expanded .mobile-card-content { + display: block; + } + + /* Leaderboard mobile cards */ + .leaderboard-mobile-card { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + margin-bottom: var(--space-sm); + cursor: pointer; + transition: background-color var(--transition-fast); + } + + .leaderboard-mobile-card:active { + background-color: var(--bg-tertiary); + } + + .leaderboard-mobile-rank { + font-size: 1.25rem; + font-weight: 700; + min-width: 40px; + text-align: center; + } + + .leaderboard-mobile-rank.rank-1 { color: var(--warning); } + .leaderboard-mobile-rank.rank-2 { color: #94a3b8; } + .leaderboard-mobile-rank.rank-3 { color: #b45309; } + + .leaderboard-mobile-info { + flex: 1; + } + + .leaderboard-mobile-name { + font-weight: 600; + color: var(--text-primary); + } + + .leaderboard-mobile-rating { + font-size: 0.875rem; + color: var(--text-muted); + } + + .leaderboard-mobile-trend { + font-size: 1.25rem; + } + + .leaderboard-mobile-details { + display: none; + padding-top: var(--space-sm); + margin-top: var(--space-sm); + border-top: 1px solid var(--border); + } + + .leaderboard-mobile-details.expanded { + display: block; + } + + .leaderboard-mobile-stat { + display: flex; + justify-content: space-between; + padding: var(--space-xs) 0; + font-size: 0.875rem; + } + + .leaderboard-mobile-stat-label { + color: var(--text-muted); + } + + .leaderboard-mobile-stat-value { + color: var(--text-primary); + font-weight: 500; + } + + /* Replay viewer mobile */ + .replay-page .page-title { + font-size: 1.25rem; + margin-bottom: 12px; + } + + .replay-layout { + flex-direction: column; + gap: 12px; + } + + .replay-main { + order: 1; + } + + .replay-sidebar { + order: 2; + width: 100%; + } + + .canvas-wrapper { + padding: var(--space-xs); + border-radius: var(--radius-md); + } + + /* Mobile replay controls - compact bar below canvas */ + .mobile-replay-controls { + display: flex; + flex-direction: column; + gap: var(--space-sm); + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + padding: var(--space-sm); + } + + .mobile-playback-bar { + display: flex; + align-items: center; + gap: var(--space-xs); + } + + .mobile-playback-bar .btn { + flex: 1; + min-height: 44px; + font-size: 0.875rem; + } + + .mobile-speed-display { + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); + padding: var(--space-xs); + } + + /* Mobile event timeline - horizontal scrollable ribbon */ + .mobile-event-timeline { + display: flex; + overflow-x: auto; + gap: var(--space-xs); + padding: var(--space-sm); + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .mobile-event-timeline::-webkit-scrollbar { + display: none; + } + + .mobile-event-dot { + flex-shrink: 0; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-tertiary); + border-radius: 50%; + font-size: 0.75rem; + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition-fast); + } + + .mobile-event-dot.active { + background-color: var(--accent); + color: white; + } + + .mobile-event-dot:active { + transform: scale(0.95); + } + + /* Mobile view mode toggle - floating button */ + .mobile-view-mode-toggle { + position: fixed; + bottom: 80px; + right: 16px; + width: 56px; + height: 56px; + border-radius: 50%; + background-color: var(--accent); + color: white; + border: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + } + + /* Mobile debug telemetry - slide-up sheet */ + .mobile-debug-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: var(--bg-secondary); + border-top-left-radius: var(--radius-xl); + border-top-right-radius: var(--radius-xl); + padding: var(--space-md); + transform: translateY(100%); + transition: transform var(--transition-normal); + z-index: 200; + max-height: 50vh; + overflow-y: auto; + } + + .mobile-debug-sheet.expanded { + transform: translateY(0); + } + + .mobile-debug-handle { + width: 40px; + height: 4px; + background-color: var(--border); + border-radius: 2px; + margin: 0 auto var(--space-md); + } + + /* Sandbox desktop required message */ + .sandbox-mobile-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl); + text-align: center; + min-height: 60vh; + } + + .sandbox-mobile-message h2 { + font-size: 1.5rem; + margin-bottom: var(--space-md); + } + + .sandbox-mobile-message p { + margin-bottom: var(--space-lg); + } + + .sandbox-mobile-qr { + width: 200px; + height: 200px; + background-color: white; + border-radius: var(--radius-md); + padding: var(--space-md); + margin-bottom: var(--space-md); + } + + .sandbox-mobile-link { + display: inline-block; + padding: var(--space-md) var(--space-lg); + background-color: var(--accent); + color: white; + text-decoration: none; + border-radius: var(--radius-md); + font-weight: 600; + } + + /* Match cards mobile */ + .match-card { + border-radius: var(--radius-md); + padding: var(--space-md); + } + + .match-participants { + gap: var(--space-sm); + } + + .match-footer { + flex-direction: column; + align-items: stretch; + gap: var(--space-sm); + } + + .match-footer .btn { + margin-left: 0; + width: 100%; + } + + /* Bot profile mobile */ + .profile-header { + flex-direction: column; + text-align: center; + gap: var(--space-sm); + } + + .profile-grid { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(3, 1fr); + } + + /* Bot cards mobile */ + .bots-grid { + grid-template-columns: 1fr; + } + + .bot-card { + padding: var(--space-md); + } + + /* Watch/Compete hub cards */ + .watch-grid, + .compete-grid { + grid-template-columns: 1fr; + gap: var(--space-sm); + } + + .watch-card, + .compete-card { + padding: var(--space-md); + } + + .card-icon { + font-size: 2rem; + margin-bottom: var(--space-sm); + } + + .watch-card h2, + .compete-card h2 { + font-size: 1.125rem; + } + + /* Steps section */ + .steps { + grid-template-columns: 1fr; + gap: var(--space-md); + } + + .step-number { + width: 40px; + height: 40px; + font-size: 1rem; + } + + /* Form inputs */ + input[type="text"], + input[type="number"], + input[type="email"], + input[type="password"], + input[type="url"], + select, + textarea { + font-size: 16px; /* Prevent iOS zoom on focus */ + } + + /* Panel mobile */ + .panel { + border-radius: var(--radius-md); + padding: var(--space-md); + } + + .panel-header { + font-size: 0.875rem; + margin-bottom: var(--space-sm); + } + + /* Loading state */ + .loading { + padding: var(--space-lg); + } + + /* Error state */ + .error { + padding: var(--space-md); + border-radius: var(--radius-md); + } + + /* Empty state */ + .empty-state { + padding: var(--space-lg); + } + + /* Pagination */ + .pagination { + display: flex; + gap: var(--space-sm); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .pagination .btn { + flex-shrink: 0; + } +} + +/* ─── Tablet (640–1024px) ───────────────────────────────────────────────────────── */ + +@media (min-width: 640px) and (max-width: 1023px) { + /* Two column layout where useful */ + .grid-2, .grid-3 { + grid-template-columns: repeat(2, 1fr); + } + + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } + + /* Replay viewer - two column on tablet */ + .replay-layout { + flex-direction: row; + } + + .replay-sidebar { + width: 280px; + } + + /* Leaderboard - show table on tablet */ + .leaderboard-table { + display: table; + } + + .mobile-cards { + display: none; + } + + /* Hub cards */ + .watch-grid, + .compete-grid { + grid-template-columns: repeat(2, 1fr); + } + + /* Bot cards */ + .bots-grid { + grid-template-columns: repeat(2, 1fr); + } + + /* Profile grid */ + .profile-grid { + grid-template-columns: repeat(2, 1fr); + } + + /* Hide mobile-specific elements */ + .mobile-replay-controls, + .mobile-event-timeline, + .mobile-view-mode-toggle, + .mobile-debug-sheet, + .sandbox-mobile-message { + display: none; + } +} + +/* ─── Desktop (>1024px) ────────────────────────────────────────────────────────── */ + +@media (min-width: 1024px) { + /* Full layout with sidebar where appropriate */ + + /* Hide mobile-specific elements */ + .mobile-bottom-nav, + .mobile-menu-toggle, + .mobile-menu, + .mobile-replay-controls, + .mobile-event-timeline, + .mobile-view-mode-toggle, + .mobile-debug-sheet, + .sandbox-mobile-message, + .leaderboard-mobile-card, + .mobile-cards { + display: none !important; + } + + /* Show desktop nav */ + .desktop-nav { + display: flex; + } + + /* Replay viewer sidebar */ + .replay-sidebar { + width: 300px; + } + + /* Sandbox layout */ + .sandbox-layout { + flex-direction: row; + } + + .sandbox-controls-col { + width: 320px; + } +} + +/* ─── Touch-specific styles ──────────────────────────────────────────────────────── */ + +@media (hover: none) and (pointer: coarse) { + /* Touch device optimizations */ + + /* Larger tap targets */ + .btn, + button, + a { + min-height: 44px; + min-width: 44px; + } + + /* Disable hover effects on touch */ + .btn:hover, + .nav-link:hover, + .bot-card:hover, + .link-card:hover { + transform: none; + box-shadow: none; + } + + /* Active states for touch feedback */ + .btn:active { + transform: scale(0.98); + } + + /* Smooth scrolling */ + html { + -webkit-overflow-scrolling: touch; + } + + /* Disable text selection on UI elements */ + .btn, + .nav-link, + .mobile-event-dot { + user-select: none; + -webkit-user-select: none; + } + + /* Prevent callout on long press */ + .btn, + .nav-link { + -webkit-touch-callout: none; + } +} + +/* ─── Orientation-specific styles ───────────────────────────────────────────────── */ + +@media (orientation: landscape) and (max-height: 600px) { + /* Landscape phone - more compact layout */ + + .page-title { + font-size: 1.25rem; + margin-bottom: 8px; + } + + .canvas-wrapper { + max-height: 50vh; + overflow: auto; + } + + .mobile-bottom-nav { + padding: 4px 0; + } + + .mobile-bottom-nav .nav-link { + padding: 4px 8px; + font-size: 0.625rem; + } + + #app { + padding-bottom: 50px; + } +} + +/* ─── Reduced motion ────────────────────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ─── High contrast mode ─────────────────────────────────────────────────────────── */ + +@media (prefers-contrast: more) { + :root { + --border: #64748b; + --bg-tertiary: #475569; + } + + .btn { + border: 2px solid currentColor; + } + + .nav-link.active { + border: 2px solid var(--accent); + } +} + +/* ─── Dark mode system preference ─────────────────────────────────────────────────── */ + +@media (prefers-color-scheme: light) { + /* The app uses dark theme by default, but we respect system preference + if someone wants to override via custom CSS */ +}