ai-code-battle/cmd/acb-api/seasons.go
jedarden f5d7553f98 Add Phase 7-9 features: evolution dashboard, WASM sandbox, enhanced replay
Phase 7 Evolution:
- Add live-export subcommand to acb-evolver for dashboard JSON generation
- Export programs, stats, and generation log to live.json

Phase 8 Enhanced Features:
- Add WASM game engine build (cmd/acb-wasm/) with JS bindings
- Add in-browser sandbox page with Monaco editor (web/src/pages/sandbox.ts)
- Add win probability computation (web/src/win-probability.ts)
- Add replay commentary generator (web/src/commentary.ts)
- Add clip maker for GIF/MP4 export (web/src/pages/clip-maker.ts)
- Add rivalry detection and pages (web/src/pages/rivalries.ts)
- Add replay feedback system (web/src/pages/feedback.ts)
- Add evolution dashboard page (web/src/pages/evolution.ts)

Phase 9 Platform Depth:
- Add predictions API (cmd/acb-api/predictions.go)
- Add series management API (cmd/acb-api/series.go)
- Add seasons API (cmd/acb-api/seasons.go)
- Add narrative generator for rivalries (cmd/acb-indexer/src/narrative.ts)

Engine Updates:
- Add debug field to move response schema
- Add match event timeline extraction
- Add replay enrichment fields

Web Updates:
- Update app.html navigation for new pages
- Add API client methods for predictions, series, seasons
- Export engine types for browser use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 01:13:23 -04:00

248 lines
7 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
)
// handleListSeasons handles GET /api/seasons
func (s *Server) handleListSeasons(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at, created_at
FROM seasons
ORDER BY created_at DESC
LIMIT 20
`)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type seasonEntry struct {
ID int64 `json:"id"`
Name string `json:"name"`
Theme *string `json:"theme"`
RulesVersion string `json:"rules_version"`
Status string `json:"status"`
ChampionID *string `json:"champion_id"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
CreatedAt time.Time `json:"created_at"`
}
seasons := make([]seasonEntry, 0)
for rows.Next() {
var se seasonEntry
var theme, championID sql.NullString
var endsAt sql.NullTime
if err := rows.Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status,
&championID, &se.StartsAt, &endsAt, &se.CreatedAt); err != nil {
continue
}
if theme.Valid {
se.Theme = &theme.String
}
if championID.Valid {
se.ChampionID = &championID.String
}
if endsAt.Valid {
se.EndsAt = &endsAt.Time
}
seasons = append(seasons, se)
}
writeJSON(w, http.StatusOK, map[string]any{"seasons": seasons})
}
// handleCreateSeason handles POST /api/seasons
func (s *Server) handleCreateSeason(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Theme string `json:"theme"`
RulesVersion string `json:"rules_version"`
EndsAt string `json:"ends_at"` // RFC3339
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.RulesVersion == "" {
req.RulesVersion = "1.0"
}
ctx := r.Context()
var endsAt sql.NullTime
if req.EndsAt != "" {
t, err := time.Parse(time.RFC3339, req.EndsAt)
if err == nil {
endsAt = sql.NullTime{Time: t, Valid: true}
}
}
var id int64
err := s.db.QueryRowContext(ctx, `
INSERT INTO seasons (name, theme, rules_version, ends_at)
VALUES ($1, $2, $3, $4)
RETURNING id
`, req.Name, req.Theme, req.RulesVersion, endsAt).Scan(&id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create season")
return
}
writeJSON(w, http.StatusOK, map[string]any{"season_id": id, "ok": true})
}
// handleGetSeason handles GET /api/seasons/{id}
func (s *Server) handleGetSeason(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx := r.Context()
var se struct {
ID int64 `json:"id"`
Name string `json:"name"`
Theme *string `json:"theme"`
RulesVersion string `json:"rules_version"`
Status string `json:"status"`
ChampionID *string `json:"champion_id"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
}
var theme, championID sql.NullString
var endsAt sql.NullTime
err := s.db.QueryRowContext(ctx, `
SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at
FROM seasons WHERE id = $1
`, id).Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status,
&championID, &se.StartsAt, &endsAt)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "season not found")
return
} else if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
if theme.Valid {
se.Theme = &theme.String
}
if championID.Valid {
se.ChampionID = &championID.String
}
if endsAt.Valid {
se.EndsAt = &endsAt.Time
}
// Get leaderboard snapshot for this season
rows, err := s.db.QueryContext(ctx, `
SELECT ss.bot_id, b.name, ss.rank, ss.rating, ss.wins, ss.losses, ss.recorded_at
FROM season_snapshots ss
JOIN bots b ON ss.bot_id = b.bot_id
WHERE ss.season_id = $1
ORDER BY ss.rank
LIMIT 50
`, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
type snap struct {
BotID string `json:"bot_id"`
BotName string `json:"bot_name"`
Rank int `json:"rank"`
Rating float64 `json:"rating"`
Wins int `json:"wins"`
Losses int `json:"losses"`
RecordedAt time.Time `json:"recorded_at"`
}
snapshots := make([]snap, 0)
for rows.Next() {
var sn snap
if err := rows.Scan(&sn.BotID, &sn.BotName, &sn.Rank, &sn.Rating, &sn.Wins, &sn.Losses, &sn.RecordedAt); err != nil {
continue
}
snapshots = append(snapshots, sn)
}
writeJSON(w, http.StatusOK, map[string]any{
"season": se,
"standings": snapshots,
})
}
// handleSnapshotSeason handles POST /api/seasons/{id}/snapshot
// Takes a snapshot of the current leaderboard for the season archive.
func (s *Server) handleSnapshotSeason(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx := r.Context()
// Check season exists
var seasonName string
err := s.db.QueryRowContext(ctx, `SELECT name FROM seasons WHERE id = $1`, id).Scan(&seasonName)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "season not found")
return
}
// Take snapshot of current leaderboard
_, err = s.db.ExecContext(ctx, `
INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses)
SELECT $1, bot_id,
ROW_NUMBER() OVER (ORDER BY rating_mu DESC),
rating_mu,
(SELECT COUNT(*) FROM match_participants mp2
JOIN matches m2 ON mp2.match_id = m2.match_id
WHERE mp2.bot_id = b.bot_id AND m2.status = 'completed'
AND m2.winner = mp2.player_slot),
(SELECT COUNT(*) FROM match_participants mp3
JOIN matches m3 ON mp3.match_id = m3.match_id
WHERE mp3.bot_id = b.bot_id AND m3.status = 'completed'
AND m3.winner != mp3.player_slot AND m3.winner >= 0)
FROM bots b
WHERE status = 'active'
ORDER BY rating_mu DESC
LIMIT 100
`, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to snapshot season")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handleCloseSeason handles POST /api/seasons/{id}/close
func (s *Server) handleCloseSeason(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx := r.Context()
// Find current leader
var championID sql.NullString
_ = s.db.QueryRowContext(ctx, `
SELECT bot_id FROM season_snapshots
WHERE season_id = $1
ORDER BY rank ASC LIMIT 1
`, id).Scan(&championID)
_, err := s.db.ExecContext(ctx, `
UPDATE seasons SET status = 'archived', champion_id = $1, ends_at = NOW()
WHERE id = $2
`, championID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to close season")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}