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