feat(web,cmd): enhance evolution dashboard, series/seasons pages, and matchmaker

- Evolution page: live polling (10s), activity feed, candidate tracking,
  statistics section, island overview with live.json schema
- Series page: detailed series view with game-by-game results
- Seasons page: season list with status and champion display
- Predictions page: enhanced prediction UI with open matches
- API types: add CycleInfo, Candidate, ActivityEntry, Totals for live.json
- Embed: improved embeddable replay widget
- Mobile CSS: responsive breakpoints and bottom tab bar
- Exporter: enhanced live.json generation with full cycle/candidate data
- Matchmaker: series scheduling support with config
- Worker: additional database queries for series/season data

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 13:42:20 -04:00
parent c5a83cbe32
commit 91d807cec2
14 changed files with 3139 additions and 251 deletions

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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),
}
}

View file

@ -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)
}

View file

@ -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)) {

View file

@ -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, `

View file

@ -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<string, number>;
island_best_fitness: Record<string, number>;
}
// 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<string, IslandStat>;
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<T> { data: T; ts: number }
const swrCache = new Map<string, CacheEntry<unknown>>();
const SWR_MAX_AGE = 5 * 60 * 1000; // 5 min — data is served from cache without re-fetch
function swr<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = swrCache.get(key) as CacheEntry<T> | 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<LeaderboardIndex> {
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<BotDirectory> {
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<BotProfile> {
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<MatchIndex> {
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<RegisterResponse> {
@ -213,22 +342,26 @@ export async function registerBot(request: RegisterRequest): Promise<RegisterRes
const R2_BASE_URL = 'https://r2.aicodebattle.com';
export async function fetchEvolutionData(): Promise<EvolutionLiveData> {
// 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<BlogIndex> {
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<BlogPost> {
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<RegisterResponse> {
@ -291,15 +424,19 @@ export interface PlaylistIndex {
}
export async function fetchPlaylistIndex(): Promise<PlaylistIndex> {
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<Playlist> {
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<PredictionsLeaderboard> {
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<OpenPredictionsResponse> {
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<EvolutionMeta> {
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<SeasonIndex> {
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();
});
}

View file

@ -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);

View file

@ -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<string, string> = {
alpha: '#ef4444', // red - core-rushing
@ -16,6 +16,8 @@ const ISLAND_LABELS: Record<string, string> = {
delta: 'Delta (Experimental)',
};
let pollingInterval: number | null = null;
export async function renderEvolutionPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
@ -30,6 +32,21 @@ export async function renderEvolutionPage(): Promise<void> {
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<void> {
try {
const data = await fetchEvolutionData();
renderDashboard(content, data);
@ -44,16 +61,39 @@ export async function renderEvolutionPage(): Promise<void> {
}
}
// 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 = `
<p class="updated-at">Last updated: ${formatTimestamp(data.updated_at)} &nbsp;·&nbsp;
${data.total_programs} programs &nbsp;·&nbsp; ${data.promoted_count} promoted</p>
${data.total_programs || 0} programs &nbsp;·&nbsp; ${data.promoted_count || 0} promoted</p>
<section class="evo-section">
<h2 class="evo-section-title">Island Status</h2>
<h2 class="evo-section-title">Live Status</h2>
<div id="live-status"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Island Overview</h2>
<div class="island-grid" id="island-grid"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Statistics</h2>
<div id="statistics"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Recent Activity</h2>
<div id="activity-feed"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Meta Tracker <span class="evo-subtitle">Best fitness per island over generations</span></h2>
<div class="chart-container" id="meta-chart"></div>
@ -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;
}
}
</style>
`;
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<string, Island
if (!stat) return '';
const color = ISLAND_COLORS[island] ?? '#94a3b8';
const label = ISLAND_LABELS[island] ?? island;
const diversityPct = Math.round(stat.diversity * 100);
return `
<div class="island-card" style="border-left-color: ${color}">
<div class="island-card-name" style="color: ${color}">${escapeHtml(label)}</div>
<div class="island-stat-row">
<span class="island-stat-label">Population</span>
<span class="island-stat-value">${stat.count}</span>
<span class="island-stat-value">${stat.population}</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Best Fitness</span>
<span class="island-stat-value">${(stat.best_fitness * 100).toFixed(1)}%</span>
<span class="island-stat-label">Best Rating</span>
<span class="island-stat-value">${stat.best_rating}</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Avg Fitness</span>
<span class="island-stat-value">${(stat.avg_fitness * 100).toFixed(1)}%</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Promoted</span>
<span class="island-stat-value">${stat.promoted_count}</span>
</div>
<div class="island-diversity-bar" title="Language diversity ${diversityPct}%">
<div class="island-diversity-fill" style="width: ${diversityPct}%; background-color: ${color}"></div>
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">
Diversity: ${diversityPct}%
<span class="island-stat-label">Best Bot</span>
<span class="island-stat-value" style="font-family: monospace; font-size: 0.8rem;">${escapeHtml(stat.best_bot || '—')}</span>
</div>
</div>
`;
@ -294,6 +544,162 @@ function renderIslandGrid(container: HTMLElement, islands: Record<string, Island
container.innerHTML = cards.join('');
}
// ── Live Status ─────────────────────────────────────────────────────────────────
function renderLiveStatus(container: HTMLElement, cycle: CycleInfo | undefined): void {
if (!cycle) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.875rem;">No active cycle. Evolution is idle.</p>';
return;
}
const phaseColors: Record<string, string> = {
idle: '#94a3b8',
generating: '#f59e0b',
validating: '#3b82f6',
evaluating: '#8b5cf6',
promoting: '#22c55e',
};
const phaseLabel = cycle.phase.charAt(0).toUpperCase() + cycle.phase.slice(1);
container.innerHTML = `
<div class="live-status-container">
<div class="live-status-main">
<div class="live-status-item">
<span class="live-status-label">Generation</span>
<span class="live-status-value">#${cycle.generation}</span>
</div>
<div class="live-status-item">
<span class="live-status-label">Phase</span>
<span class="live-status-phase" style="background-color: ${phaseColors[cycle.phase] || '#94a3b8'}">${phaseLabel}</span>
</div>
<div class="live-status-item">
<span class="live-status-label">Started</span>
<span class="live-status-value">${formatTimestamp(cycle.started_at)}</span>
</div>
</div>
${cycle.candidate ? renderCandidateInfo(cycle.candidate) : ''}
</div>
`;
}
function renderCandidateInfo(candidate: Candidate): string {
let statusHTML = '';
if (candidate.validation) {
const v = candidate.validation;
statusHTML += `
<div class="candidate-validation">
<div class="validation-stage ${v.syntax?.passed ? 'passed' : 'pending'}">Syntax ${v.syntax?.passed ? '✓' : '⋯'}</div>
<div class="validation-stage ${v.schema?.passed ? 'passed' : 'pending'}">Schema ${v.schema?.passed ? '✓' : '⋯'}</div>
<div class="validation-stage ${v.smoke?.passed ? 'passed' : 'pending'}">Smoke ${v.smoke?.passed ? '✓' : '⋯'}</div>
</div>
`;
}
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 += `
<div class="candidate-evaluation">
<div class="evaluation-progress">
<div class="evaluation-bar" style="width: ${pct}%"></div>
</div>
<span class="evaluation-text">Evaluating: ${played}/${total} matches</span>
</div>
`;
}
return `
<div class="candidate-info">
<div class="candidate-header">
<span class="candidate-id">${escapeHtml(candidate.id)}</span>
<span class="candidate-island" style="color: ${ISLAND_COLORS[candidate.island] || '#94a3b8'}">${escapeHtml(candidate.island)}</span>
</div>
<div class="candidate-parents">
Parents: ${candidate.parents.map(p => `<span class="parent-tag">${escapeHtml(p.id)} (${p.rating})</span>`).join('')}
</div>
${statusHTML}
</div>
`;
}
// ── Statistics ─────────────────────────────────────────────────────────────────
function renderStatistics(container: HTMLElement, totals: Totals): void {
container.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Generations</div>
<div class="stat-value">${totals.generations_total}</div>
</div>
<div class="stat-card">
<div class="stat-label">Candidates Today</div>
<div class="stat-value">${totals.candidates_today}</div>
</div>
<div class="stat-card">
<div class="stat-label">Promoted Today</div>
<div class="stat-value">${totals.promoted_today}</div>
</div>
<div class="stat-card">
<div class="stat-label">Promotion Rate (7d)</div>
<div class="stat-value">${(totals.promotion_rate_7d * 100).toFixed(1)}%</div>
</div>
<div class="stat-card">
<div class="stat-label">Highest Evolved Rating</div>
<div class="stat-value">${totals.highest_evolved_rating}</div>
</div>
<div class="stat-card">
<div class="stat-label">Evolved in Top 10</div>
<div class="stat-value">${totals.evolved_in_top_10}</div>
</div>
</div>
`;
}
// ── Activity Feed ───────────────────────────────────────────────────────────────
function renderActivityFeed(container: HTMLElement, activities: ActivityEntry[]): void {
if (!activities || activities.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.875rem;">No recent activity.</p>';
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 `
<div class="activity-entry">
<span class="activity-time">${formatTimeAgo(a.time)}</span>
<span class="activity-result ${resultClass}">${resultIcon} ${escapeHtml(a.result)}</span>
<span class="activity-candidate">${escapeHtml(a.candidate)}</span>
<span class="activity-island" style="color: ${color}">${escapeHtml(a.island)}</span>
<span class="activity-reason">${escapeHtml(a.reason)}</span>
</div>
`;
}).join('');
container.innerHTML = `<div class="activity-feed">${rows}</div>`;
}
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 {

View file

@ -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<void> {
const app = document.getElementById('app');
if (!app) return;
predictorId = getOrCreatePredictorId();
app.innerHTML = `
<div class="predictions-page">
<h1 class="page-title">Prediction Leaderboard</h1>
<p class="page-subtitle">Top predictors and their accuracy stats</p>
<h1 class="page-title">Predictions</h1>
<p class="page-subtitle">Predict match outcomes and climb the leaderboard</p>
<div class="how-it-works">
<h2>How It Works</h2>
@ -41,6 +52,13 @@ export async function renderPredictionsPage(): Promise<void> {
</div>
</div>
<div class="open-section">
<h2>Open Matches</h2>
<div id="open-matches-container">
<div class="loading">Loading open matches...</div>
</div>
</div>
<div class="leaderboard-section">
<h2>Top Predictors</h2>
<div id="leaderboard-container">
@ -71,8 +89,7 @@ export async function renderPredictionsPage(): Promise<void> {
</div>
</div>
<div id="stats-login-prompt">
<p>Log in to track your predictions</p>
<button class="btn primary" id="login-btn">Connect</button>
<p>Make your first prediction above to start tracking stats</p>
</div>
</div>
</div>
@ -147,6 +164,84 @@ export async function renderPredictionsPage(): Promise<void> {
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<void> {
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;
}
}
</style>
`;
// Load leaderboard
await loadLeaderboard();
// Load open matches and leaderboard in parallel
await Promise.all([loadOpenMatches(), loadLeaderboard()]);
}
async function loadOpenMatches(): Promise<void> {
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 = '<div class="empty-message">No open matches available for prediction right now. Check back soon!</div>';
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 `
<div class="open-match-card" data-match-id="${m.match_id}">
<div class="bot-option">
<div class="bot-info">
<div class="bot-name">${escapeHtml(botA.name)}</div>
<div class="bot-rating">Rating: ${Math.round(botA.rating)}</div>
</div>
<button class="pick-btn ${pickedA ? 'picked' : ''}"
data-match="${m.match_id}" data-bot="${botA.bot_id}"
${pickedA ? 'disabled' : ''}>
${pickedA ? 'Picked' : 'Pick'}
</button>
</div>
<span class="vs">vs</span>
<div class="bot-option">
<div class="bot-info">
<div class="bot-name">${escapeHtml(botB.name)}</div>
<div class="bot-rating">Rating: ${Math.round(botB.rating)}</div>
</div>
<button class="pick-btn ${pickedB ? 'picked' : ''}"
data-match="${m.match_id}" data-bot="${botB.bot_id}"
${pickedB ? 'disabled' : ''}>
${pickedB ? 'Picked' : 'Pick'}
</button>
</div>
</div>
`;
}).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 = '<div class="empty-message">Failed to load open matches</div>';
}
}
async function handlePick(e: Event): Promise<void> {
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<void> {
const container = document.getElementById('leaderboard-container');
if (!container) return;
@ -321,6 +531,23 @@ async function loadLeaderboard(): Promise<void> {
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<void> {
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 `
<tr class="rank-${idx + 1}">
<td class="rank">#${idx + 1}</td>
<td class="predictor-name">${botName}</td>
<td class="predictor-name">${botName}${isYou ? ' (you)' : ''}</td>
<td>${entry.correct}</td>
<td>${entry.incorrect}</td>
<td>
@ -374,7 +602,13 @@ async function loadLeaderboard(): Promise<void> {
}
}
async function fetchBotNames(botIds: string[]): Promise<Map<string, string>> {
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function fetchBotNames(botIds: string[]): Promise<Map<string, string>> {
const names = new Map<string, string>();
const uniqueIds = [...new Set(botIds)];

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
.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;
}
</style>
`;
// Load seasons data
await loadSeasons();
}
@ -273,13 +377,11 @@ async function loadSeasons(): Promise<void> {
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 = '<div class="empty-message">No seasons available yet</div>';
return;
@ -287,11 +389,11 @@ async function loadSeasons(): Promise<void> {
list.innerHTML = index.seasons.map((s: Season) => `
<div class="season-card" data-season-id="${s.id}">
<h3>${s.name}</h3>
<h3>${escapeHtml(s.name)}</h3>
${s.champion_name ? `
<div class="champion">
<span class="champion-crown">👑</span>
<span class="champion-name">${s.champion_name}</span>
<span class="champion-crown">&#x1F451;</span>
<span class="champion-name">${escapeHtml(s.champion_name)}</span>
</div>
` : ''}
<div class="meta">
@ -301,7 +403,6 @@ async function loadSeasons(): Promise<void> {
</div>
`).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<void> {
}
}
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 = `
<div class="mini-leaderboard">
<h4>Top Bots</h4>
${top5.map((entry: SeasonSnapshot) => `
<div class="mini-leaderboard-row">
<span class="rank rank-${entry.rank}">#${entry.rank}</span>
<span class="bot-name">${escapeHtml(entry.bot_name)}</span>
<span class="bot-rating">${Math.round(entry.rating)}</span>
<span class="bot-record">${entry.wins}W ${entry.losses}L</span>
</div>
`).join('')}
</div>
`;
}
return `
<div class="season-card" data-season-id="${season.id}">
<div class="season-header">
<div class="season-info">
<h3>${season.name}</h3>
<p class="season-theme">${season.theme}</p>
<h3>${escapeHtml(season.name)}</h3>
<p class="season-theme">${escapeHtml(season.theme)}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
@ -349,6 +475,7 @@ function renderActiveSeason(season: Season): string {
<span>${Math.round(progressPercent)}% complete</span>
</div>
</div>
${miniLeaderboard}
</div>
`;
}
@ -367,11 +494,54 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
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
? `
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>Record</th>
<th>Win Rate</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="rank-${entry.rank}">
<td class="rank">#${entry.rank}</td>
<td>${escapeHtml(entry.bot_name)}</td>
<td style="font-family: monospace">${Math.round(entry.rating)}</td>
<td>${entry.wins}W / ${entry.losses}L</td>
<td>
<div style="display: flex; gap: 2px; align-items: center;">
<div class="win-bar" style="width: ${winWidth}px;"></div>
<div class="loss-bar" style="width: ${lossWidth}px;"></div>
<span style="margin-left: 6px; font-size: 0.75rem; color: var(--text-muted)">${winRate}%</span>
</div>
</td>
</tr>
`}).join('')}
</tbody>
</table>
`
: '<p style="color: var(--text-muted); text-align: center; padding: 24px;">No leaderboard data available yet.</p>';
detailContent.innerHTML = `
<div class="season-header" style="margin-bottom: 24px;">
<div class="season-info">
<h2>${season.name}</h2>
<p class="season-theme">${season.theme}</p>
<h2>${escapeHtml(season.name)}</h2>
<p class="season-theme">${escapeHtml(season.theme)}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
@ -382,40 +552,38 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
${season.champion_name ? `
<div class="champion" style="justify-content: center; padding: 20px; margin-bottom: 24px;">
<span class="champion-crown" style="font-size: 2rem;">👑</span>
<span class="champion-crown" style="font-size: 2rem;">&#x1F451;</span>
<div>
<div style="color: var(--text-muted); font-size: 0.75rem;">CHAMPION</div>
<span class="champion-name" style="font-size: 1.25rem;">${season.champion_name}</span>
<span class="champion-name" style="font-size: 1.25rem;">${escapeHtml(season.champion_name)}</span>
</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
<h3>Final Leaderboard</h3>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>W/L</th>
</tr>
</thead>
<tbody>
${season.final_snapshot.map((entry: SeasonSnapshot) => `
<tr>
<td class="rank-${entry.rank}">#${entry.rank}</td>
<td>${entry.bot_name}</td>
<td>${Math.round(entry.rating)}</td>
<td>${entry.wins}/${entry.losses}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<p>No leaderboard data available yet.</p>'}
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${season.total_matches}</div>
<div class="stat-label">Matches Played</div>
</div>
${season.final_snapshot ? `
<div class="stat-card">
<div class="stat-value">${season.final_snapshot.length}</div>
<div class="stat-label">Ranked Bots</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
<div class="stat-card">
<div class="stat-value">${Math.round(season.final_snapshot[0].rating)}</div>
<div class="stat-label">Highest Rating</div>
</div>
` : ''}
</div>
<h3 style="margin-bottom: 16px;">Season Leaderboard</h3>
${leaderboardHtml}
<div class="season-rules">
<h4>Rules Version: ${season.rules_version}</h4>
<h4>Rules Version: ${escapeHtml(season.rules_version)}</h4>
<ul>
<li>Standard 60x60 toroidal grid</li>
<li>500 turn limit</li>

View file

@ -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<void> {
</select>
</div>
<div class="spoiler-toggle">
<input type="checkbox" id="spoiler-toggle">
<label for="spoiler-toggle">Hide spoilers (scores/results)</label>
</div>
<div class="series-list" id="series-list">
<div class="loading">Loading series...</div>
</div>
@ -65,6 +70,25 @@ export async function renderSeriesPage(): Promise<void> {
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<void> {
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<void> {
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<void> {
.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<void> {
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<void> {
await loadSeries();
// Setup spoiler toggle
const spoilerToggle = document.createElement('div');
spoilerToggle.className = 'spoiler-toggle';
spoilerToggle.innerHTML = `
<input type="checkbox" id="spoiler-toggle">
<label for="spoiler-toggle">Hide spoilers (scores/results)</label>
`;
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<void> {
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<void> {
}
}
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(`<div class="bracket-dot pending">${i + 1}</div>`);
} else if (game.winner_slot === 0) {
dots.push(`<div class="bracket-dot win-a">${i + 1}</div>`);
} else if (game.winner_slot === 1) {
dots.push(`<div class="bracket-dot win-b">${i + 1}</div>`);
} else {
dots.push(`<div class="bracket-dot draw">${i + 1}</div>`);
}
}
return `
<div class="bracket-container">
<div class="bracket-labels">
<span class="bracket-label bot-a">${bot1Name}</span>
<span class="bracket-label bot-b">${bot2Name}</span>
</div>
<div class="bracket-track">
${dots.join('<div class="bracket-connector"></div>')}
</div>
</div>
`;
}
function renderSeriesList(series: Series[], container: HTMLElement, _botNames: Map<string, string>): void {
container.innerHTML = series.map(s => `
<div class="series-card" data-series-id="${s.id}">
@ -347,15 +475,11 @@ function renderSeriesList(series: Series[], container: HTMLElement, _botNames: M
<span class="series-bot-name">${s.bot2_name}</span>
</div>
</div>
<div class="series-score">
<span class="${s.bot1_wins > s.bot2_wins ? 'score-winner' : 'score-loser'}">${s.bot1_wins}</span>
<span>-</span>
<span class="${s.bot2_wins > s.bot1_wins ? 'score-winner' : 'score-loser'}">${s.bot2_wins}</span>
</div>
</div>
${renderBracketProgress(s.bot1_name, s.bot2_name, s.games || [], s.best_of)}
<div class="series-meta">
<span class="status-badge ${s.status}">${s.status}</span>
<span>Best of ${s.best_of}</span>
<span>Best of ${s.best_of} &middot; ${s.bot1_wins}-${s.bot2_wins}</span>
<span>${s.completed_at ? new Date(s.completed_at).toLocaleDateString() : 'In progress'}</span>
</div>
</div>
@ -375,6 +499,8 @@ async function showSeriesDetail(seriesId: string): Promise<void> {
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<void> {
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 `
<div class="game-row ${winnerClass}">
<span class="game-number">Game ${g.game_number}</span>
<span class="game-badge ${badgeClass}"></span>
<span class="game-result-text ${resultClass}">
${resultText}
${g.turns ? ` (${g.turns} turns)` : ''}
</span>
${g.match_id ? `<button class="watch-btn" data-match-id="${g.match_id}">Watch</button>` : ''}
</div>
`;
}).join('');
detailContent.innerHTML = `
<div class="series-header" style="margin-bottom: 24px;">
<div class="detail-header">
<h2>${series.bot1_name} vs ${series.bot2_name}</h2>
<span class="status-badge ${series.status}">${series.status}</span>
</div>
<div class="series-score" style="justify-content: center; margin-bottom: 24px; font-size: 2rem;">
<span class="${series.bot1_wins > series.bot2_wins ? 'score-winner' : 'score-loser'}">${series.bot1_wins}</span>
<span>-</span>
<span class="${series.bot2_wins > series.bot1_wins ? 'score-winner' : 'score-loser'}">${series.bot2_wins}</span>
<div class="detail-score">
<span class="score-a">${series.bot1_wins}</span>
<span class="score-dash">-</span>
<span class="score-b">${series.bot2_wins}</span>
</div>
<h3>Games</h3>
<div class="series-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 `
<div class="game-row">
<span class="game-number">Game ${g.game_number}</span>
<span class="game-result ${winnerClass}">
${g.completed_at ? (g.winner_id ? `Winner: ${winnerName}` : 'Draw') : 'Not played'}
${g.turns ? `(${g.turns} turns)` : ''}
</span>
${g.match_id ? `<button class="watch-btn" data-match-id="${g.match_id}">Watch</button>` : ''}
</div>
`;
}).join('')}
${renderBracketProgress(series.bot1_name, series.bot2_name, series.games || [], series.best_of)}
<div class="detail-bracket">
<h3 style="margin-bottom: 12px; font-size: 0.875rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;">Game Results</h3>
${gamesHtml}
</div>
`;
@ -425,12 +565,27 @@ async function showSeriesDetail(seriesId: string): Promise<void> {
});
});
// 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) {

684
web/src/styles/mobile.css Normal file
View file

@ -0,0 +1,684 @@
/* ──────────────────────────────────────────────────────────────────────────────── */
/* Mobile Responsive Styles */
/* ──────────────────────────────────────────────────────────────────────────────── */
/* Mobile-first responsive breakpoints:
- <640px: phone single column, bottom tab bar, touch-optimized
- 6401024px: 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 (6401024px) ───────────────────────────────────────────────────────── */
@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 */
}