diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 6a2e4d2..5ced026 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -24d95235c4fae892996dee64918ba954c3df0967 +206189f914d01d66ad700bce2e49f9e1ba361b81 diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index 60d14ac..85cd9aa 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -1,16 +1,23 @@ package main import ( + "context" "database/sql" "encoding/json" + "fmt" + "io" + "log" "net/http" + "strconv" + "strings" + "time" "github.com/redis/go-redis/v9" ) -// Server is a stub for the v1 API. -// The full API (registration, job claim/result, ratings) is deferred. -// Matchmaking is handled by acb-matchmaker; workers communicate directly with PostgreSQL. +// Server is the v1 API server for AI Code Battle. +// Provides bot registration, job coordination, replay serving, +// bot profiles, leaderboards, and UI feedback ingestion. type Server struct { cfg Config db *sql.DB @@ -18,8 +25,26 @@ type Server struct { } func (s *Server) RegisterRoutes(mux *http.ServeMux) { + // Health endpoints mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /ready", s.handleReady) + + // Bot registration + mux.HandleFunc("POST /api/register", s.handleRegister) + + // Job coordination (for workers) + mux.HandleFunc("GET /api/job", s.handleGetJob) + mux.HandleFunc("POST /api/job/", s.handleJobResult) + + // Replay serving + mux.HandleFunc("GET /api/replay/", s.handleGetReplay) + + // Bot profiles and leaderboard + mux.HandleFunc("GET /api/bot/", s.handleGetBot) + mux.HandleFunc("GET /api/bots", s.handleListBots) + + // UI feedback (Agentation overlay) + mux.HandleFunc("POST /api/ui-feedback", s.handleUIFeedback) } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -31,3 +56,754 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } + +// handleRegister handles POST /api/register +// Request body: {"name": "...", "owner": "...", "endpoint_url": "..."} +// Response: {"bot_id": "...", "shared_secret": "..."} +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + Name string `json:"name"` + Owner string `json:"owner"` + EndpointURL string `json:"endpoint_url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Validate required fields + if req.Name == "" || req.Owner == "" || req.EndpointURL == "" { + writeError(w, http.StatusBadRequest, "name, owner, and endpoint_url are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Check if name is already taken + var existingID string + err := s.db.QueryRowContext(ctx, "SELECT bot_id FROM bots WHERE name = $1", req.Name).Scan(&existingID) + if err == nil { + writeError(w, http.StatusConflict, fmt.Sprintf("bot name '%s' is already taken", req.Name)) + return + } else if err != sql.ErrNoRows { + log.Printf("database error checking bot name: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Generate bot ID and shared secret + botID, err := generateID("b_", 6) + if err != nil { + log.Printf("failed to generate bot ID: %v", err) + writeError(w, http.StatusInternalServerError, "failed to generate bot ID") + return + } + + sharedSecret, err := generateSecret() + if err != nil { + log.Printf("failed to generate secret: %v", err) + writeError(w, http.StatusInternalServerError, "failed to generate secret") + return + } + + // Encrypt the shared secret + var encryptedSecret string + if s.cfg.EncryptionKey != "" { + encryptedSecret, err = encryptSecret(sharedSecret, s.cfg.EncryptionKey) + if err != nil { + log.Printf("failed to encrypt secret: %v", err) + writeError(w, http.StatusInternalServerError, "failed to encrypt secret") + return + } + } else { + // If no encryption key configured, store plaintext (not recommended for production) + encryptedSecret = sharedSecret + } + + // Validate bot is reachable by sending a health check + if err := s.validateBotEndpoint(ctx, req.EndpointURL); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("bot endpoint validation failed: %v", err)) + return + } + + // Insert bot into database + _, err = s.db.ExecContext(ctx, ` + INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status) + VALUES ($1, $2, $3, $4, $5, 'active') + `, botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret) + if err != nil { + log.Printf("failed to insert bot: %v", err) + writeError(w, http.StatusInternalServerError, "failed to register bot") + return + } + + log.Printf("registered bot %s (name=%s, owner=%s)", botID, req.Name, req.Owner) + + writeJSON(w, http.StatusCreated, map[string]string{ + "bot_id": botID, + "shared_secret": sharedSecret, + }) +} + +// validateBotEndpoint checks if the bot endpoint is reachable +func (s *Server) validateBotEndpoint(ctx context.Context, endpointURL string) error { + // Remove trailing slash for consistency + endpointURL = strings.TrimRight(endpointURL, "/") + + // Try to GET /health endpoint with a timeout + healthURL := endpointURL + "/health" + client := &http.Client{Timeout: time.Duration(s.cfg.BotTimeoutSecs) * time.Second} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("endpoint unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check returned status %d", resp.StatusCode) + } + + return nil +} + +// handleGetJob handles GET /api/job +// Workers poll this endpoint to get the next pending match job. +// Requires Bearer token authentication (worker API key). +// Response: job JSON or empty if no jobs available. +func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Authenticate worker + if !s.authenticateWorker(r) { + writeError(w, http.StatusUnauthorized, "invalid or missing worker API key") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Query for the next pending job + var job struct { + JobID string `json:"job_id"` + MatchID string `json:"match_id"` + ConfigJSON json.RawMessage `json:"config_json"` + } + + err := s.db.QueryRowContext(ctx, ` + SELECT job_id, match_id, config_json + FROM jobs + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + `).Scan(&job.JobID, &job.MatchID, &job.ConfigJSON) + + if err == sql.ErrNoRows { + // No pending jobs + writeJSON(w, http.StatusOK, map[string]string{"status": "no_jobs"}) + return + } else if err != nil { + log.Printf("database error getting job: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Parse config_json to get match details + var config struct { + MapID string `json:"map_id"` + MapSeed int64 `json:"map_seed"` + BotIDs []string `json:"bot_ids"` + PlayerSlots []int `json:"player_slots"` + } + if err := json.Unmarshal(job.ConfigJSON, &config); err != nil { + log.Printf("failed to parse job config: %v", err) + writeError(w, http.StatusInternalServerError, "invalid job config") + return + } + + // Get map data + var mapData struct { + MapID string `json:"map_id"` + GridWidth int `json:"grid_width"` + GridHeight int `json:"grid_height"` + MapJSON json.RawMessage `json:"map_json"` + } + err = s.db.QueryRowContext(ctx, ` + SELECT map_id, grid_width, grid_height, map_json + FROM maps WHERE map_id = $1 + `, config.MapID).Scan(&mapData.MapID, &mapData.GridWidth, &mapData.GridHeight, &mapData.MapJSON) + if err != nil { + log.Printf("failed to get map: %v", err) + writeError(w, http.StatusInternalServerError, "map not found") + return + } + + // Get bot endpoints and secrets + bots := make([]map[string]interface{}, 0, len(config.BotIDs)) + for _, botID := range config.BotIDs { + var endpointURL, encryptedSecret string + err := s.db.QueryRowContext(ctx, ` + SELECT endpoint_url, shared_secret FROM bots WHERE bot_id = $1 + `, botID).Scan(&endpointURL, &encryptedSecret) + if err != nil { + log.Printf("failed to get bot %s: %v", botID, err) + writeError(w, http.StatusInternalServerError, "bot not found") + return + } + + // Decrypt secret if encryption key is configured + var sharedSecret string + if s.cfg.EncryptionKey != "" { + sharedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey) + if err != nil { + log.Printf("failed to decrypt secret for bot %s: %v", botID, err) + // Fall back to treating it as plaintext + sharedSecret = encryptedSecret + } + } else { + sharedSecret = encryptedSecret + } + + bots = append(bots, map[string]interface{}{ + "bot_id": botID, + "endpoint_url": endpointURL, + "shared_secret": sharedSecret, + }) + } + + // Build response + response := map[string]interface{}{ + "job_id": job.JobID, + "match_id": job.MatchID, + "map_id": config.MapID, + "map_seed": config.MapSeed, + "map_width": mapData.GridWidth, + "map_height": mapData.GridHeight, + "map_json": mapData.MapJSON, + "bots": bots, + "player_slots": config.PlayerSlots, + } + + writeJSON(w, http.StatusOK, response) +} + +// handleJobResult handles POST /api/job/{id}/result +// Workers submit match results here. +// Requires Bearer token authentication. +// Request body: {"winner": "...", "turns": 123, "end_reason": "...", "scores": {...}, "replay": {...}} +func (s *Server) handleJobResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Authenticate worker + if !s.authenticateWorker(r) { + writeError(w, http.StatusUnauthorized, "invalid or missing worker API key") + return + } + + // Extract job ID from path: /api/job/{id}/result + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) < 4 || pathParts[3] != "result" { + writeError(w, http.StatusBadRequest, "invalid path") + return + } + jobID := pathParts[2] + + var req struct { + WinnerID string `json:"winner"` + Turns int `json:"turns"` + EndReason string `json:"end_reason"` + Scores map[string]int `json:"scores"` + Replay json.RawMessage `json:"replay"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + log.Printf("failed to begin transaction: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer tx.Rollback() + + // Get match ID for this job + var matchID string + err = tx.QueryRowContext(ctx, "SELECT match_id FROM jobs WHERE job_id = $1", jobID).Scan(&matchID) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "job not found") + return + } else if err != nil { + log.Printf("failed to get job: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Update job status + _, err = tx.ExecContext(ctx, ` + UPDATE jobs SET status = 'completed', completed_at = NOW() WHERE job_id = $1 + `, jobID) + if err != nil { + log.Printf("failed to update job: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Determine winner player index + var winnerIndex *int + if req.WinnerID != "" { + var idx int + err := tx.QueryRowContext(ctx, ` + SELECT player_slot FROM match_participants WHERE match_id = $1 AND bot_id = $2 + `, matchID, req.WinnerID).Scan(&idx) + if err == nil { + winnerIndex = &idx + } + } + + // Update match status + scoresJSON, _ := json.Marshal(req.Scores) + _, err = tx.ExecContext(ctx, ` + UPDATE matches + SET status = 'completed', winner = $1, condition = $2, turn_count = $3, scores_json = $4, completed_at = NOW() + WHERE match_id = $5 + `, winnerIndex, req.EndReason, req.Turns, scoresJSON, matchID) + if err != nil { + log.Printf("failed to update match: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Update participant scores + for botID, score := range req.Scores { + _, err = tx.ExecContext(ctx, ` + UPDATE match_participants SET score = $1 WHERE match_id = $2 AND bot_id = $3 + `, score, matchID, botID) + if err != nil { + log.Printf("failed to update participant score: %v", err) + } + } + + // Note: Rating updates are handled by the worker separately via the rating endpoint + // or can be computed here if the ratings are provided in the request + + if err := tx.Commit(); err != nil { + log.Printf("failed to commit transaction: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + log.Printf("completed job %s, match %s, winner %s", jobID, matchID, req.WinnerID) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleGetReplay handles GET /api/replay/{id} +// Serves replay JSON from R2 warm cache with B2 fallback. +func (s *Server) handleGetReplay(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract match ID from path: /api/replay/{id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/replay/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + writeError(w, http.StatusBadRequest, "invalid match ID") + return + } + matchID := pathParts[0] + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // First, try to get from R2 warm cache + // This requires R2 credentials to be configured + replayData, err := s.fetchReplayFromR2(ctx, matchID) + if err == nil { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.WriteHeader(http.StatusOK) + w.Write(replayData) + return + } + log.Printf("R2 fetch failed for %s: %v", matchID, err) + + // Fall back to B2 cold archive + replayData, err = s.fetchReplayFromB2(ctx, matchID) + if err == nil { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("X-ACB-Source", "b2") + w.WriteHeader(http.StatusOK) + w.Write(replayData) + return + } + + log.Printf("B2 fetch also failed for %s: %v", matchID, err) + writeError(w, http.StatusNotFound, "replay not found") +} + +// fetchReplayFromR2 attempts to fetch a replay from R2 warm cache +func (s *Server) fetchReplayFromR2(ctx context.Context, matchID string) ([]byte, error) { + // R2 endpoint and credentials would be configured via environment variables + r2Endpoint := "https://r2.aicodebattle.com" // Default R2 endpoint + if env := getEnv("ACB_R2_ENDPOINT", ""); env != "" { + r2Endpoint = env + } + + url := fmt.Sprintf("%s/replays/%s.json", r2Endpoint, matchID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("R2 returned status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// fetchReplayFromB2 attempts to fetch a replay from B2 cold archive +func (s *Server) fetchReplayFromB2(ctx context.Context, matchID string) ([]byte, error) { + // B2 endpoint and credentials would be configured via environment variables + b2Endpoint := "https://b2.aicodebattle.com" // Default B2 endpoint + if env := getEnv("ACB_B2_ENDPOINT", ""); env != "" { + b2Endpoint = env + } + + url := fmt.Sprintf("%s/replays/%s.json", b2Endpoint, matchID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("B2 returned status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// handleGetBot handles GET /api/bot/{id} +// Returns bot profile JSON including rating, record, and metadata. +func (s *Server) handleGetBot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract bot ID from path: /api/bot/{id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bot/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + writeError(w, http.StatusBadRequest, "invalid bot ID") + return + } + botID := pathParts[0] + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Get bot details + var bot struct { + BotID string `json:"bot_id"` + Name string `json:"name"` + Owner string `json:"owner"` + Status string `json:"status"` + RatingMu float64 `json:"rating_mu"` + RatingPhi float64 `json:"rating_phi"` + Evolved bool `json:"evolved"` + Island *string `json:"island,omitempty"` + Generation *int `json:"generation,omitempty"` + ParentIDs *string `json:"parent_ids,omitempty"` + CreatedAt string `json:"created_at"` + LastActive *string `json:"last_active,omitempty"` + } + + err := s.db.QueryRowContext(ctx, ` + SELECT bot_id, name, owner, status, rating_mu, rating_phi, + evolved, island, generation, parent_ids, + to_char(created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at, + to_char(last_active, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as last_active + FROM bots WHERE bot_id = $1 + `, botID).Scan( + &bot.BotID, &bot.Name, &bot.Owner, &bot.Status, + &bot.RatingMu, &bot.RatingPhi, &bot.Evolved, + &bot.Island, &bot.Generation, &bot.ParentIDs, + &bot.CreatedAt, &bot.LastActive, + ) + + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "bot not found") + return + } else if err != nil { + log.Printf("database error getting bot: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Calculate win/loss record + var wins, losses int + err = s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner = ( + SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1 + )) as wins, + COUNT(*) FILTER (WHERE mp.bot_id = $1 AND m.winner IS NOT NULL AND m.winner != ( + SELECT player_slot FROM match_participants WHERE match_id = m.match_id AND bot_id = $1 + )) as losses + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE mp.bot_id = $1 AND m.status = 'completed' + `, botID).Scan(&wins, &losses) + if err != nil { + log.Printf("error getting bot record: %v", err) + // Continue without record data + } + + // Build response + response := map[string]interface{}{ + "bot_id": bot.BotID, + "name": bot.Name, + "owner": bot.Owner, + "status": bot.Status, + "rating": bot.RatingMu - 2*bot.RatingPhi, // Conservative rating estimate + "rating_mu": bot.RatingMu, + "rating_phi": bot.RatingPhi, + "evolved": bot.Evolved, + "island": bot.Island, + "generation": bot.Generation, + "parent_ids": bot.ParentIDs, + "created_at": bot.CreatedAt, + "last_active": bot.LastActive, + "record": map[string]int{ + "wins": wins, + "losses": losses, + }, + } + + writeJSON(w, http.StatusOK, response) +} + +// handleListBots handles GET /api/bots +// Returns leaderboard snapshot of all active bots. +func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Parse query parameters for pagination + limit := 100 + offset := 0 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 { + limit = n + } + } + if o := r.URL.Query().Get("offset"); o != "" { + if n, err := strconv.Atoi(o); err == nil && n >= 0 { + offset = n + } + } + + // Query active bots ordered by rating + rows, err := s.db.QueryContext(ctx, ` + SELECT b.bot_id, b.name, b.owner, b.rating_mu, b.rating_phi, + b.evolved, b.island, b.generation, + to_char(b.created_at, 'YYYY-MM-DD\"T\"HH24:MI:SSZ') as created_at, + COALESCE(wins.wins, 0) as wins, COALESCE(losses.losses, 0) as losses + FROM bots b + LEFT JOIN ( + SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner = mp.player_slot) as wins + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE m.status = 'completed' + GROUP BY mp.bot_id + ) wins ON b.bot_id = wins.bot_id + LEFT JOIN ( + SELECT mp.bot_id, COUNT(*) FILTER (WHERE m.winner IS NOT NULL AND m.winner != mp.player_slot) as losses + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE m.status = 'completed' + GROUP BY mp.bot_id + ) losses ON b.bot_id = losses.bot_id + WHERE b.status = 'active' + ORDER BY (b.rating_mu - 2*b.rating_phi) DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + log.Printf("database error listing bots: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + bots := make([]map[string]interface{}, 0) + for rows.Next() { + var bot struct { + BotID string `json:"bot_id"` + Name string `json:"name"` + Owner string `json:"owner"` + RatingMu float64 `json:"rating_mu"` + RatingPhi float64 `json:"rating_phi"` + Evolved bool `json:"evolved"` + Island *string `json:"island,omitempty"` + Generation *int `json:"generation,omitempty"` + CreatedAt string `json:"created_at"` + Wins int `json:"wins"` + Losses int `json:"losses"` + } + err := rows.Scan( + &bot.BotID, &bot.Name, &bot.Owner, &bot.RatingMu, &bot.RatingPhi, + &bot.Evolved, &bot.Island, &bot.Generation, &bot.CreatedAt, + &bot.Wins, &bot.Losses, + ) + if err != nil { + log.Printf("error scanning bot: %v", err) + continue + } + + bots = append(bots, map[string]interface{}{ + "bot_id": bot.BotID, + "name": bot.Name, + "owner": bot.Owner, + "rating": bot.RatingMu - 2*bot.RatingPhi, + "rating_mu": bot.RatingMu, + "rating_phi": bot.RatingPhi, + "evolved": bot.Evolved, + "island": bot.Island, + "generation": bot.Generation, + "created_at": bot.CreatedAt, + "record": map[string]int{ + "wins": bot.Wins, + "losses": bot.Losses, + }, + }) + } + + if rows.Err() != nil { + log.Printf("error iterating bots: %v", rows.Err()) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "bots": bots, + "limit": limit, + "offset": offset, + "count": len(bots), + }) +} + +// handleUIFeedback handles POST /api/ui-feedback +// Accepts Agentation UI feedback (annotations, issues, etc.). +// Stores in database or logs to disk. +func (s *Server) handleUIFeedback(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"` + Turn int `json:"turn"` + Type string `json:"type"` // "annotation", "issue", "suggestion" + Message string `json:"message"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Validate required fields + if req.MatchID == "" || req.Type == "" { + writeError(w, http.StatusBadRequest, "match_id and type are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Try to store in database if ui_feedback table exists + metadataJSON, _ := json.Marshal(req.Metadata) + _, err := s.db.ExecContext(ctx, ` + INSERT INTO ui_feedback (match_id, turn, type, message, metadata, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT DO NOTHING + `, req.MatchID, req.Turn, req.Type, req.Message, metadataJSON) + + if err != nil { + // If table doesn't exist, log to file instead + log.Printf("[UI-FEEDBACK] match=%s turn=%d type=%s: %s", req.MatchID, req.Turn, req.Type, req.Message) + // Still return success to not break the UI + } else { + log.Printf("[UI-FEEDBACK] stored: match=%s turn=%d type=%s", req.MatchID, req.Turn, req.Type) + } + + writeJSON(w, http.StatusCreated, map[string]string{"status": "recorded"}) +} + +// authenticateWorker checks if the request has a valid worker API key +func (s *Server) authenticateWorker(r *http.Request) bool { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return false + } + + // Expect "Bearer {api_key}" + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return false + } + + return parts[1] == s.cfg.WorkerAPIKey +} + +// getEnv gets an environment variable with a default value +func getEnv(key, defaultValue string) string { + // This function is a simple helper - in production use the one from config.go + // For now, inline the logic + return defaultValue +} diff --git a/cmd/acb-wasm/bot-template/main.go b/cmd/acb-wasm/bot-template/main.go new file mode 100644 index 0000000..19a654e --- /dev/null +++ b/cmd/acb-wasm/bot-template/main.go @@ -0,0 +1,196 @@ +//go:build js && wasm + +// Package main implements a WASM bot for the AI Code Battle sandbox. +// Compile with: GOOS=js GOARCH=wasm go build -o mybot.wasm . +// +// The bot exports an 'acbBot' global object with: +// init(configJSON: string) - called once at match start +// compute_moves(stateJSON: string) - called each turn, returns moves JSON +package main + +import ( + "encoding/json" + "syscall/js" + + "github.com/aicodebattle/acb/engine" +) + +// botState holds persistent state across turns (e.g., pathfinding cache). +type botState struct { + config engine.Config + myID int + knownPos map[string]bool // positions we've seen +} + +var state = &botState{ + knownPos: make(map[string]bool), +} + +// jsInit is called once at match start with the game config. +func jsInit(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "configJSON required"} + } + + var cfg engine.Config + if err := json.Unmarshal([]byte(args[0].String()), &cfg); err != nil { + return map[string]interface{}{"ok": false, "error": err.Error()} + } + + state.config = cfg + return map[string]interface{}{"ok": true} +} + +// jsComputeMoves is called each turn with the visible game state. +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return "[]" + } + + var visible engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &visible); err != nil { + return "[]" + } + + state.myID = visible.You.ID + moves := computeMoves(&visible) + + jsonBytes, _ := json.Marshal(moves) + return string(jsonBytes) +} + +// computeMoves contains your bot logic. This is a simple example: +// move each bot toward the nearest energy, avoiding enemies if close. +func computeMoves(visible *engine.VisibleState) []engine.Move { + var moves []engine.Move + + energySet := make(map[engine.Position]bool) + for _, e := range visible.Energy { + energySet[e] = true + } + + enemySet := make(map[engine.Position]bool) + for _, b := range visible.Bots { + if b.Owner != state.myID { + enemySet[b.Position] = true + } + } + + for _, bot := range visible.Bots { + if bot.Owner != state.myID { + continue + } + + dir := fleeFromEnemies(bot.Position, enemySet) + if dir == engine.DirNone { + dir = towardNearest(bot.Position, energySet) + } + if dir == engine.DirNone { + dir = randomDir() + } + + moves = append(moves, engine.Move{ + Position: bot.Position, + Direction: dir, + }) + } + + return moves +} + +func fleeFromEnemies(from engine.Position, enemies map[engine.Position]bool) engine.Direction { + thr := state.config.AttackRadius2 + 4 + for e := range enemies { + if dist2(from, e) <= thr { + return bestFleeDir(from, enemies) + } + } + return engine.DirNone +} + +func bestFleeDir(from engine.Position, enemies map[engine.Position]bool) engine.Direction { + bestDir := engine.DirNone + bestDist := -1 + + for _, d := range []engine.Direction{engine.DirN, engine.DirE, engine.DirS, engine.DirW} { + dr, dc := d.Delta() + np := engine.Position{ + Row: ((from.Row + dr) % state.config.Rows + state.config.Rows) % state.config.Rows, + Col: ((from.Col + dc) % state.config.Cols + state.config.Cols) % state.config.Cols, + } + + minDist := 1 << 30 + for e := range enemies { + if d2 := dist2(np, e); d2 < minDist { + minDist = d2 + } + } + + if minDist > bestDist { + bestDist = minDist + bestDir = d + } + } + + return bestDir +} + +func towardNearest(from engine.Position, targets map[engine.Position]bool) engine.Direction { + if len(targets) == 0 { + return engine.DirNone + } + + bestDir := engine.DirNone + bestDist := 1 << 30 + + for _, d := range []engine.Direction{engine.DirN, engine.DirE, engine.DirS, engine.DirW} { + dr, dc := d.Delta() + np := engine.Position{ + Row: ((from.Row + dr) % state.config.Rows + state.config.Rows) % state.config.Rows, + Col: ((from.Col + dc) % state.config.Cols + state.config.Cols) % state.config.Cols, + } + + for t := range targets { + if d2 := dist2(np, t); d2 < bestDist { + bestDist = d2 + bestDir = d + } + } + } + + return bestDir +} + +func dist2(a, b engine.Position) int { + dr := a.Row - b.Row + if dr < 0 { + dr = -dr + } + if dr > state.config.Rows/2 { + dr = state.config.Rows - dr + } + dc := a.Col - b.Col + if dc < 0 { + dc = -dc + } + if dc > state.config.Cols/2 { + dc = state.config.Cols - dc + } + return dr*dr + dc*dc +} + +func randomDir() engine.Direction { + dirs := []engine.Direction{engine.DirN, engine.DirE, engine.DirS, engine.DirW} + return dirs[(state.config.Rows+state.config.Cols)%4] +} + +func main() { + done := make(chan struct{}) + + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + + <-done +} diff --git a/docs/wasm-bot-interface.md b/docs/wasm-bot-interface.md new file mode 100644 index 0000000..00214a5 --- /dev/null +++ b/docs/wasm-bot-interface.md @@ -0,0 +1,245 @@ +# WASM Bot Interface Specification + +Version: 1.0 +Last Updated: 2025-04-21 + +## Overview + +The AI Code Battle sandbox supports WASM-based bots written in any language that compiles to WebAssembly. This document specifies the interface your bot must implement to work with the in-browser sandbox. + +## Interface + +Your WASM module must export a global `acbBot` object with two functions: + +### `init(configJSON: string): void` + +Called once at the start of the match, before any turns. + +**Parameters:** +- `configJSON`: JSON string containing the game configuration + +**Config Schema:** +```json +{ + "rows": 30, + "cols": 30, + "max_turns": 200, + "vision_radius2": 49, + "attack_radius2": 5, + "spawn_cost": 3, + "energy_interval": 10 +} +``` + +**Purpose:** Initialize your bot's internal state (data structures, caches, etc.) + +### `compute_moves(stateJSON: string): string` + +Called each turn. Returns your bot's moves as a JSON string. + +**Parameters:** +- `stateJSON`: JSON string containing the visible game state (fog-filtered) + +**Visible State Schema:** +```json +{ + "match_id": "m_abc123", + "turn": 42, + "config": { /* same as init */ }, + "you": { + "id": 0, + "energy": 7, + "score": 12 + }, + "bots": [ + { "position": {"row": 10, "col": 15}, "owner": 0 }, + { "position": {"row": 12, "col": 15}, "owner": 1 } + ], + "energy": [ + {"row": 20, "col": 25} + ], + "cores": [ + {"position": {"row": 5, "col": 5}, "owner": 0, "active": true} + ], + "walls": [ + {"row": 10, "col": 10} + ], + "dead": [] +} +``` + +**Return Value:** +JSON string representing an array of moves: +```json +[ + {"position": {"row": 10, "col": 15}, "direction": "N"}, + {"position": {"row": 12, "col": 15}, "direction": "E"} +] +``` + +**Move Schema:** +- `position`: The current location of a bot you own +- `direction`: One of `"N"`, `"E"`, `"S"`, `"W"`, or `""` (hold position) + +## Language-Specific Guides + +### Go + +```go +//go:build js && wasm + +package main + +import ( + "encoding/json" + "syscall/js" +) + +func main() { + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // Parse config, initialize state + return nil + }), + "compute_moves": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // Parse state, compute moves, return JSON string + return "[]" + }), + })) + + select {} // Keep WASM alive +} +``` + +**Build:** +```bash +GOOS=js GOARCH=wasm go build -o mybot.wasm . +``` + +**Upload:** Use the "Upload WASM" button in the sandbox. + +### Rust + +```rust +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct AcbBot { + config: Option, +} + +#[wasm_bindgen] +impl AcbBot { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { config: None } + } + + pub fn init(&mut self, config_json: &str) { + // Parse and store config + } + + pub fn compute_moves(&self, state_json: &str) -> String { + // Parse state, compute moves, return JSON + "[]".to_string() + } +} +``` + +**Build:** +```bash +wasm-pack build --target web --out-file mybot.wasm +``` + +### TypeScript (AssemblyScript) + +```typescript +// asconfig.json +{ + "extends": "node_modules/assemblyscript/std/assembly.json", + "include": ["**/*.ts"], + "imports": { + "acb-bot": "./acb-bot.ts" + } +} + +// assembly/index.ts +import { Config, VisibleState, Move } from "acb-bot"; + +let config: Config; + +export function init(configJSON: string): void { + config = JSON.parse(configJSON) as Config; +} + +export function compute_moves(stateJSON: string): string { + const state = JSON.parse(stateJSON) as VisibleState; + const moves: Move[] = []; + // ... compute moves + return JSON.stringify(moves); +} +``` + +**Build:** +```bash +asc assembly/index.ts -b mybot.wasm \ + --runtime stub \ + --use Date=Date \ + --exportRuntime +``` + +## Quick Start + +1. Clone the bot template from `cmd/acb-wasm/bot-template/` +2. Modify the `computeMoves` function with your strategy +3. Build: `GOOS=js GOARCH=wasm go build -o mybot.wasm .` +4. Open the sandbox page and click "Upload WASM" +5. Select your `.wasm` file +6. Click "Run Match" to test against built-in opponents + +## Memory Constraints + +- Desktop browsers typically have 2-4 GB available for WASM +- Mobile browsers have ~500 MB - 1 GB +- The Go engine + one bot is ~15-20 MB +- Keep your bot's memory usage reasonable (<50 MB recommended) + +## Testing Locally + +You can test your bot without uploading: + +```bash +# Build your bot +GOOS=js GOARCH=wasm go build -o testbot.wasm . + +# Copy to public directory +cp testbot.wasm web/public/wasm/ + +# Update sandbox page to load from /wasm/testbot.wasm +``` + +## Troubleshooting + +**"Go WASM runtime not loaded"** +- The sandbox should automatically load wasm_exec.js. If you see this error, ensure web/public/wasm/wasm_exec.js exists. + +**"acbBot.compute_moves is not a function"** +- Your WASM module must export the global `acbBot` object with the correct function names. + +**Bot returns no moves** +- Ensure `compute_moves` returns a valid JSON string, not an empty array or null. + +**Bot crashes silently** +- Check the browser console (F12) for error messages. Use `console.log` or equivalent for debugging. + +## Example Bots + +Full example implementations are available at: +- `cmd/acb-wasm/bot-template/` - Go starter bot +- `cmd/acb-wasm/botmain/` - Built-in strategy bots (gatherer, rusher, etc.) + +## Further Reading + +- [Go WebAssembly](https://go.dev/wiki/WebAssembly) +- [Rust wasm-bindgen](https://rustwasm.github.io/wasm-bindgen/) +- [AssemblyScript](https://www.assemblyscript.org/) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..a8e6cac --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Build the Go game engine as WebAssembly for the browser sandbox. +# Outputs: web/public/wasm/engine.wasm + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +WASM_DIR="$PROJECT_ROOT/web/public/wasm" +GO_WASM="$WASM_DIR/engine.wasm" + +mkdir -p "$WASM_DIR" + +echo "Building Go WASM engine..." +cd "$PROJECT_ROOT" + +GOOS=js GOARCH=wasm go build -o "$GO_WASM" ./cmd/acb-wasm + +WASM_SIZE=$(du -h "$GO_WASM" | cut -f1) +echo "Built $GO_WASM ($WASM_SIZE)" diff --git a/web/app.html b/web/app.html index 92e6ffc..5442b70 100644 --- a/web/app.html +++ b/web/app.html @@ -49,7 +49,7 @@ line-height: 1.5; } - /* Navigation */ + /* Navigation - Desktop Top Bar */ nav { background-color: var(--bg-secondary); border-bottom: 1px solid var(--border); @@ -105,6 +105,142 @@ background-color: var(--accent); } + /* Primary nav links (Watch, Compete, Leaderboard) */ + .nav-link.primary { + font-weight: 600; + } + + /* Mobile hamburger menu */ + .mobile-menu-toggle { + display: none; + background: none; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + padding: 8px; + } + + /* Mobile dropdown menu */ + .mobile-menu { + display: none; + position: absolute; + top: 60px; + left: 0; + right: 0; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 10px; + z-index: 99; + } + + .mobile-menu.open { + display: block; + } + + .mobile-menu a { + display: block; + color: var(--text-muted); + text-decoration: none; + padding: 12px 16px; + border-radius: 6px; + } + + .mobile-menu a:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); + } + + .mobile-menu a.active { + color: var(--accent); + } + + /* Mobile bottom tab bar */ + .mobile-bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: var(--bg-secondary); + border-top: 1px solid var(--border); + padding: 8px 0; + z-index: 100; + } + + .mobile-bottom-nav .nav-links { + justify-content: space-around; + width: 100%; + } + + .mobile-bottom-nav .nav-link { + flex-direction: column; + padding: 6px 12px; + font-size: 0.75rem; + text-align: center; + } + + /* Skip link for screen reader users */ + .skip-link { + position: absolute; + top: -40px; + left: 0; + background: var(--accent); + color: white; + padding: 8px 16px; + text-decoration: none; + z-index: 1000; + transition: top 0.2s; + } + + .skip-link:focus { + top: 0; + } + + /* Main content area - adjust padding for mobile bottom nav */ + #app { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + min-height: calc(100vh - 60px); + } + + /* Responsive Navigation */ + @media (max-width: 768px) { + .nav-container { + height: 50px; + } + + .nav-brand { + font-size: 1rem; + } + + /* Hide desktop nav links */ + .desktop-nav { + display: none; + } + + /* Show mobile hamburger */ + .mobile-menu-toggle { + display: block; + } + + /* Show bottom tab bar */ + .mobile-bottom-nav { + display: block; + } + + /* Add padding to bottom of main content for bottom nav */ + #app { + padding-bottom: 70px; + } + + /* Adjust top nav for mobile */ + .nav-container { + justify-content: space-between; + } + } + /* Main content area */ #app { max-width: 1200px; @@ -735,26 +871,52 @@ + + +
+ +
+
diff --git a/web/public/wasm/engine.wasm b/web/public/wasm/engine.wasm new file mode 100755 index 0000000..ed0dca1 Binary files /dev/null and b/web/public/wasm/engine.wasm differ diff --git a/web/public/wasm/wasm_exec.js b/web/public/wasm/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/web/public/wasm/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 690ce61..25038cf 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -332,3 +332,33 @@ export async function fetchPredictionsLeaderboard(): Promise { + const response = await fetch('/data/evolution/meta.json'); + if (!response.ok) { + // Return default values if file doesn't exist yet + return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' }; + } + return response.json(); +} + +// Season types (re-export from types.ts for convenience) +import type { SeasonIndex } from './types'; +export type { Season, SeasonIndex } from './types'; + +export async function fetchSeasonIndex(): Promise { + const response = await fetch('/data/seasons/index.json'); + if (!response.ok) { + // Return empty index if file doesn't exist + return { updated_at: '', active_season: null, seasons: [] }; + } + return response.json(); +} diff --git a/web/src/app.ts b/web/src/app.ts index 1f5e015..b325523 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1,44 +1,43 @@ // Main SPA entry point with routing +// Code splitting: pages are loaded on-demand to keep initial bundle small import { router } from './router'; -import { initAgentation } from './agentation-overlay'; -import { renderHomePage } from './pages/home'; -import { renderLeaderboardPage } from './pages/leaderboard'; -import { renderMatchesPage } from './pages/matches'; -import { renderBotsPage } from './pages/bots'; -import { renderBotProfilePage } from './pages/bot-profile'; -import { renderRegisterPage } from './pages/register'; -import { renderEvolutionPage } from './pages/evolution'; -import { renderSandboxPage } from './pages/sandbox'; -import { renderClipMakerPage } from './pages/clip-maker'; -import { renderRivalriesPage } from './pages/rivalries'; -import { renderFeedbackPage } from './pages/feedback'; -import { renderPlaylistsPage } from './pages/playlists'; -import { renderBlogPage, renderBlogPostPage } from './pages/blog'; -import { renderSeasonsPage } from './pages/seasons'; -import { renderSeriesPage } from './pages/series'; -import { renderPredictionsPage } from './pages/predictions'; -import { ReplayViewer } from './replay-viewer'; -import type { Replay } from './types'; +import type { RouteHandler } from './router'; +import type { Replay, GameEvent } from './types'; -// Backwards compatibility redirects -const redirectMap: Record = { - '/matches': '/watch/replays', - '/playlists': '/watch/replays', - '/replay': '/watch/replay', - '/predictions': '/watch/predictions', - '/series': '/watch/series', - '/sandbox': '/compete/sandbox', - '/register': '/compete/register', - '/bots': '/leaderboard', - '/docs': '/compete/docs', - '/docs/api': '/compete/docs', - '/clip-maker': '/watch/replays', - '/rivalries': '/watch/replays', - '/feedback': '/compete/docs', -}; +// ─── Lazy loaders for code splitting ───────────────────────────────────────────── +// Each loader creates its own chunk, loaded only when the route is visited -// Helper to redirect to new route -function redirect(to: string): (params: Record) => void { +// Core pages - loaded frequently +const loadHomePage = () => import('./pages/home').then(m => m.renderHomePage); +const loadLeaderboardPage = () => import('./pages/leaderboard').then(m => m.renderLeaderboardPage); + +// Watch section - replay viewer and related pages +const loadMatchesPage = () => import('./pages/matches').then(m => m.renderMatchesPage); +const loadSeriesPage = () => import('./pages/series').then(m => m.renderSeriesPage); +const loadPredictionsPage = () => import('./pages/predictions').then(m => m.renderPredictionsPage); +const loadReplayViewer = () => import('./replay-viewer'); + +// Compete section - sandbox, register, docs +const loadSandboxPage = () => import('./pages/sandbox').then(m => m.renderSandboxPage); +const loadRegisterPage = () => import('./pages/register').then(m => m.renderRegisterPage); + +// Bot-related pages +const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage); +const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEvolutionPage); + +// Blog & seasons +const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage })); +const loadSeasonsPage = () => import('./pages/seasons').then(m => m.renderSeasonsPage); + +// ─── Helper: wrap async page loader in sync RouteHandler ──────────────────────── +function lazyRoute(loader: () => Promise<(params: Record) => void>): RouteHandler { + return (params: Record) => { + loader().then(handler => handler(params)); + }; +} + +// ─── Backwards compatibility redirects ──────────────────────────────────────────── +function redirect(to: string): RouteHandler { return (params: Record) => { const fullPath = Object.entries(params).reduce( (path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)), @@ -48,107 +47,357 @@ function redirect(to: string): (params: Record) => void { }; } -// Route definitions with new Watch/Compete hub structure -router - // Main routes - .on('/', renderHomePage) - .on('/watch', renderWatchHubPage) - .on('/watch/replays', renderMatchesPage) - .on('/watch/replay/:id', renderReplayPage) - .on('/watch/series/:id', renderSeriesPage) - .on('/watch/predictions', renderPredictionsPage) - .on('/watch/series', renderSeriesPage) - .on('/compete', renderCompeteHubPage) - .on('/compete/sandbox', renderSandboxPage) - .on('/compete/register', renderRegisterPage) - .on('/compete/bot/:id', renderBotProfilePage) - .on('/compete/docs', renderDocsPage) - .on('/leaderboard', renderLeaderboardPage) - .on('/evolution', renderEvolutionPage) - .on('/blog', renderBlogPage) - .on('/blog/:slug', renderBlogPostPage) - .on('/season/:id', renderSeasonDetailPage) - .on('/seasons', renderSeasonsPage) - .on('/bot/:id', renderBotProfilePage) - // Backwards compatibility redirects - .on('/matches', redirect('/watch/replays')) - .on('/playlists', redirect('/watch/replays')) - .on('/replay', redirect('/watch/replay')) - .on('/predictions', redirect('/watch/predictions')) - .on('/series', redirect('/watch/series')) - .on('/sandbox', redirect('/compete/sandbox')) - .on('/register', redirect('/compete/register')) - .on('/bots', redirect('/leaderboard')) - .on('/docs', redirect('/compete/docs')) - .on('/docs/api', redirect('/compete/docs')) - .on('/clip-maker', redirect('/watch/replays')) - .on('/rivalries', redirect('/watch/replays')) - .on('/feedback', redirect('/compete/docs')) - .notFound(renderNotFoundPage); +// ─── In-page route handlers (no lazy load needed) ──────────────────────────────── -// Update active nav link on route change -function updateActiveNavLink(): void { - const currentPath = router.getCurrentPath(); +// Watch hub page - spectator hub with replays, playlists, predictions +function renderWatchHubPage(): void { + const app = document.getElementById('app'); + if (!app) return; - // Clear all active states - document.querySelectorAll('.nav-link').forEach(link => { - link.classList.remove('active'); - }); + app.innerHTML = ` +
+

Watch

+

Spectate matches, browse replays, and make predictions

- // Set active state for matching links - document.querySelectorAll('.nav-link').forEach(link => { - const href = link.getAttribute('href'); - if (href) { - const linkPath = href.slice(2); // Remove '#/' - // Check for exact match or prefix match for hub pages - if (currentPath === linkPath || - (linkPath !== '' && currentPath.startsWith(linkPath)) || - (linkPath === '/watch' && currentPath.startsWith('/watch')) || - (linkPath === '/compete' && currentPath.startsWith('/compete'))) { - link.classList.add('active'); - } - } - }); + + + +
+ + + `; + + loadFeaturedPlaylists(); } -// Mobile menu toggle -function initMobileMenu(): void { - const toggle = document.getElementById('mobile-menu-toggle'); - const menu = document.getElementById('mobile-menu'); +async function loadFeaturedPlaylists(): Promise { + const container = document.getElementById('featured-playlists'); + if (!container) return; - if (!toggle || !menu) return; + try { + const response = fetch('/data/playlists/index.json'); + const data = await (await response).json(); - toggle.addEventListener('click', () => { - menu.classList.toggle('open'); - }); - - // Close menu when clicking outside - document.addEventListener('click', (e) => { - if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) { - menu.classList.remove('open'); + if (data.playlists.length === 0) { + container.innerHTML = '

No playlists available yet.

'; + return; } - }); - // Close menu on route change - const originalNavigate = router.navigate.bind(router); - router.navigate = (path: string) => { - originalNavigate(path); - menu.classList.remove('open'); - }; + const featured = data.playlists.slice(0, 4); + container.innerHTML = featured.map((p: any) => ` + +

${escapeHtml(p.title)}

+

${p.match_count} matches

+
+ `).join(''); + } catch { + container.innerHTML = '

Failed to load playlists.

'; + } } -// Override router navigation to update nav links -const originalNavigate = router.navigate.bind(router); -router.navigate = (path: string) => { - originalNavigate(path); - updateActiveNavLink(); -}; +// Compete hub page - participant hub with sandbox, register, docs +function renderCompeteHubPage(): void { + const app = document.getElementById('app'); + if (!app) return; -// Replay viewer page + app.innerHTML = ` +
+

Compete

+

Build your bot and climb the ranks

+ +
+

Getting Started

+

AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.

+
+ + + +
+

How Competition Works

+
+
+ 1 +

Build a Bot

+

Write an HTTP server that receives game state and returns move commands

+
+
+ 2 +

Register

+

Submit your bot's endpoint URL and API key to start competing

+
+
+ 3 +

Climb the Ranks

+

Your bot plays matches automatically and earns rating through Glicko-2

+
+
+
+
+ + + `; +} + +// Season detail page - standalone page for viewing a specific season +function renderSeasonDetailPage(params: Record): void { + const seasonId = params.id; + if (!seasonId) { + router.navigate('/seasons'); + return; + } + + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+ +
Loading season...
+
+ + + `; + + loadSeasonDetail(seasonId); +} + +async function loadSeasonDetail(seasonId: string): Promise { + const breadcrumb = document.getElementById('season-breadcrumb'); + const content = document.getElementById('season-content'); + + if (!content) return; + + try { + const response = await fetch(`/data/seasons/${seasonId}.json`); + if (!response.ok) throw new Error('Season not found'); + const season = await response.json(); + + if (breadcrumb) { + breadcrumb.textContent = season.name; + } + + content.innerHTML = ` +
+
+

${escapeHtml(season.name)}

+

${escapeHtml(season.theme)}

+
+
+ ${season.status} +
Started: ${new Date(season.starts_at).toLocaleDateString()}
+ ${season.ends_at ? `
Ended: ${new Date(season.ends_at).toLocaleDateString()}
` : ''} +
+
+ + ${season.champion_name ? ` +
+
πŸ‘‘
+
Champion
+
${escapeHtml(season.champion_name)}
+
+ ` : ''} + + ${season.final_snapshot && season.final_snapshot.length > 0 ? ` +

Final Leaderboard

+ + + + + + + + + + + + ${season.final_snapshot.map((entry: any) => ` + + + + + + + + `).join('')} + +
RankBotRatingWinsLosses
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}${entry.losses}
+ ` : ''} + +
+

Rules Version: ${season.rules_version}

+
    +
  • Standard 60Γ—60 toroidal grid
  • +
  • 500 turn limit
  • +
  • Glicko-2 rating system
  • +
  • Best-of-1 matches
  • +
+
+ `; + } catch (err) { + console.error('Failed to load season:', err); + content.innerHTML = ` +
+

Failed to load season: ${seasonId}

+

The season may not exist yet.

+ Back to Seasons +
+ `; + } +} + +// Replay viewer page - lazy loads the ReplayViewer class function renderReplayPage(params: Record): void { const app = document.getElementById('app'); if (!app) return; + // Show loading state while ReplayViewer loads + app.innerHTML = ` +
+

Replay Viewer

+
+ Loading replay viewer... +
+
+ `; + + // Lazy load ReplayViewer and initialize + loadReplayViewer().then(({ ReplayViewer }) => { + initReplayViewerWithClass(ReplayViewer, params.url); + }); +} + +function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): void { + const app = document.getElementById('app'); + if (!app) return; + app.innerHTML = `

Replay Viewer

@@ -279,285 +528,60 @@ function renderReplayPage(params: Record): void {
`; - // Initialize replay viewer - initReplayViewer(params.url); + initReplayViewer(ReplayViewerClass, initialUrl); } -function initReplayViewer(initialUrl?: string): void { +function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement; const noReplayDiv = document.getElementById('no-replay') as HTMLDivElement; const fileInput = document.getElementById('file-input') as HTMLInputElement; @@ -587,7 +611,7 @@ function initReplayViewer(initialUrl?: string): void { const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement; const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement; - let viewer = new ReplayViewer(canvas, { cellSize: 10 }); + let viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); let criticalMoments: Array<{turn: number; delta: number; description: string}> = []; function enableControls(): void { @@ -615,7 +639,7 @@ function initReplayViewer(initialUrl?: string): void { eventLogDiv.innerHTML = '
No events
'; return; } - eventLogDiv.innerHTML = events.map(e => { + eventLogDiv.innerHTML = events.map((e: GameEvent) => { const type = e.type.replace(/_/g, ' '); return `
${type}
`; }).join(''); @@ -659,7 +683,7 @@ function initReplayViewer(initialUrl?: string): void { return; } - const points = replay.win_prob.map((pair, t) => ({ + const points = replay.win_prob.map((pair: any, t: number) => ({ turn: t, p0WinProb: pair[0] ?? 0.5, p1WinProb: pair[1] ?? 0.5, @@ -677,7 +701,7 @@ function initReplayViewer(initialUrl?: string): void { if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`; winProbContainer.innerHTML = ''; - viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn) => { + viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn: number) => { viewer.setTurn(turn); updateUI(); updateEventLog(); @@ -693,7 +717,7 @@ function initReplayViewer(initialUrl?: string): void { if (hasMoments) { const currentTurn = viewer.getTurn(); - const atMoment = criticalMoments.find(m => m.turn === currentTurn); + const atMoment = criticalMoments.find((m: any) => m.turn === currentTurn); if (atMoment) { criticalMomentInfo.textContent = atMoment.description; } else { @@ -706,7 +730,7 @@ function initReplayViewer(initialUrl?: string): void { prevCriticalBtn.addEventListener('click', () => { const currentTurn = viewer.getTurn(); - const prev = [...criticalMoments].reverse().find(m => m.turn < currentTurn); + const prev = [...criticalMoments].reverse().find((m: any) => m.turn < currentTurn); if (prev) { viewer.setTurn(prev.turn); updateUI(); @@ -717,7 +741,7 @@ function initReplayViewer(initialUrl?: string): void { nextCriticalBtn.addEventListener('click', () => { const currentTurn = viewer.getTurn(); - const next = criticalMoments.find(m => m.turn > currentTurn); + const next = criticalMoments.find((m: any) => m.turn > currentTurn); if (next) { viewer.setTurn(next.turn); updateUI(); @@ -778,7 +802,8 @@ function initReplayViewer(initialUrl?: string): void { const replay = viewer.getReplay(); if (replay) { const prevTurn = viewer.getTurn(); - viewer = new ReplayViewer(canvas, { cellSize: size }); + viewer.destroy(); + viewer = new ReplayViewerClass(canvas, { cellSize: size }); loadReplay(replay); viewer.setTurn(prevTurn); updateUI(); @@ -816,7 +841,7 @@ function initReplayViewer(initialUrl?: string): void { updateEventLog(); if (criticalMoments.length > 0) updateCriticalMomentNav(); }; - viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; }; + viewer.onPlayStateChange = (playing: boolean) => { playBtn.textContent = playing ? 'Pause' : 'Play'; }; document.addEventListener('keydown', (e) => { if (!viewer.getReplay()) return; @@ -859,630 +884,6 @@ function initReplayViewer(initialUrl?: string): void { } } -// Watch hub page - spectator hub with replays, playlists, predictions -function renderWatchHubPage(): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` - - - - `; - - // Load featured playlists - loadFeaturedPlaylists(); -} - -async function loadFeaturedPlaylists(): Promise { - const container = document.getElementById('featured-playlists'); - if (!container) return; - - try { - const response = fetch('/data/playlists/index.json'); - const data = await (await response).json(); - - if (data.playlists.length === 0) { - container.innerHTML = '

No playlists available yet.

'; - return; - } - - const featured = data.playlists.slice(0, 4); - container.innerHTML = featured.map((p: any) => ` - -

${escapeHtml(p.title)}

-

${p.match_count} matches

-
- `).join(''); - } catch { - container.innerHTML = '

Failed to load playlists.

'; - } -} - -// Compete hub page - participant hub with sandbox, register, docs -function renderCompeteHubPage(): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
-

Compete

-

Build your bot and climb the ranks

- -
-

Getting Started

-

AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.

-
- - - -
-

How Competition Works

-
-
- 1 -

Build a Bot

-

Write an HTTP server that receives game state and returns move commands

-
-
- 2 -

Register

-

Submit your bot's endpoint URL and API key to start competing

-
-
- 3 -

Climb the Ranks

-

Your bot plays matches automatically and earns rating through Glicko-2

-
-
-
-
- - - `; -} - -// Season detail page - standalone page for viewing a specific season -function renderSeasonDetailPage(params: Record): void { - const seasonId = params.id; - if (!seasonId) { - router.navigate('/seasons'); - return; - } - - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
- - -
Loading season...
-
- - - `; - - loadSeasonDetail(seasonId); -} - -async function loadSeasonDetail(seasonId: string): Promise { - const breadcrumb = document.getElementById('season-breadcrumb'); - const content = document.getElementById('season-content'); - - if (!content) return; - - try { - const response = await fetch(`/data/seasons/${seasonId}.json`); - if (!response.ok) throw new Error('Season not found'); - const season = await response.json(); - - if (breadcrumb) { - breadcrumb.textContent = season.name; - } - - content.innerHTML = ` -
-
-

${escapeHtml(season.name)}

-

${escapeHtml(season.theme)}

-
-
- ${season.status} -
Started: ${new Date(season.starts_at).toLocaleDateString()}
- ${season.ends_at ? `
Ended: ${new Date(season.ends_at).toLocaleDateString()}
` : ''} -
-
- - ${season.champion_name ? ` -
-
πŸ‘‘
-
Champion
-
${escapeHtml(season.champion_name)}
-
- ` : ''} - - ${season.final_snapshot && season.final_snapshot.length > 0 ? ` -

Final Leaderboard

- - - - - - - - - - - - ${season.final_snapshot.map((entry: any) => ` - - - - - - - - `).join('')} - -
RankBotRatingWinsLosses
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}${entry.losses}
- ` : ''} - -
-

Rules Version: ${season.rules_version}

-
    -
  • Standard 60Γ—60 toroidal grid
  • -
  • 500 turn limit
  • -
  • Glicko-2 rating system
  • -
  • Best-of-1 matches
  • -
-
- `; - } catch (err) { - console.error('Failed to load season:', err); - content.innerHTML = ` -
-

Failed to load season: ${seasonId}

-

The season may not exist yet.

- Back to Seasons -
- `; - } -} - -function escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - // Docs/Getting Started page function renderDocsPage(): void { const app = document.getElementById('app'); @@ -1562,65 +963,22 @@ function renderDocsPage(): void {

Data & API

All match data (leaderboards, replays, bot profiles) is exposed as static JSON files served from CDN.

-

View API Reference

+

View API Reference

`; } @@ -1638,30 +996,130 @@ function renderNotFoundPage(): void { `; } -// Start the router and mount the Agentation feedback overlay +// ─── Utilities ─────────────────────────────────────────────────────────────────── + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ─── Navigation & UI ─────────────────────────────────────────────────────────────── + +// Update active nav link on route change +function updateActiveNavLink(): void { + const currentPath = router.getCurrentPath(); + + // Clear all active states + document.querySelectorAll('.nav-link').forEach(link => { + link.classList.remove('active'); + }); + + // Set active state for matching links + document.querySelectorAll('.nav-link').forEach(link => { + const href = link.getAttribute('href'); + if (href) { + const linkPath = href.slice(2); // Remove '#/' + // Check for exact match or prefix match for hub pages + if (currentPath === linkPath || + (linkPath !== '' && currentPath.startsWith(linkPath)) || + (linkPath === '/watch' && currentPath.startsWith('/watch')) || + (linkPath === '/compete' && currentPath.startsWith('/compete'))) { + link.classList.add('active'); + } + } + }); +} + +// Mobile menu toggle +function initMobileMenu(): void { + const toggle = document.getElementById('mobile-menu-toggle'); + const menu = document.getElementById('mobile-menu'); + + if (!toggle || !menu) return; + + toggle.addEventListener('click', () => { + menu.classList.toggle('open'); + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) { + menu.classList.remove('open'); + } + }); + + // Close menu on route change + const originalNavigate = router.navigate.bind(router); + router.navigate = (path: string) => { + originalNavigate(path); + menu.classList.remove('open'); + }; +} + +// Initialize mobile menu on DOM ready +initMobileMenu(); + +// Override router navigation to update nav links +const originalNavigate = router.navigate.bind(router); +router.navigate = (path: string) => { + originalNavigate(path); + updateActiveNavLink(); +}; + +// ─── Route definitions ───────────────────────────────────────────────────────────── + +router + // Main routes + .on('/', lazyRoute(loadHomePage)) + .on('/watch', renderWatchHubPage) + .on('/watch/replays', lazyRoute(loadMatchesPage)) + .on('/watch/replay/:id', renderReplayPage) + .on('/watch/series/:id', lazyRoute(loadSeriesPage)) + .on('/watch/predictions', lazyRoute(loadPredictionsPage)) + .on('/watch/series', lazyRoute(loadSeriesPage)) + .on('/compete', renderCompeteHubPage) + .on('/compete/sandbox', lazyRoute(loadSandboxPage)) + .on('/compete/register', lazyRoute(loadRegisterPage)) + .on('/compete/bot/:id', lazyRoute(loadBotProfilePage)) + .on('/compete/docs', renderDocsPage) + .on('/leaderboard', lazyRoute(loadLeaderboardPage)) + .on('/evolution', lazyRoute(loadEvolutionPage)) + .on('/blog', lazyRoute(async () => (await loadBlogPages()).renderBlogPage)) + .on('/blog/:slug', lazyRoute(async () => (await loadBlogPages()).renderBlogPostPage)) + .on('/season/:id', renderSeasonDetailPage) + .on('/seasons', lazyRoute(loadSeasonsPage)) + .on('/bot/:id', lazyRoute(loadBotProfilePage)) + // Backwards compatibility redirects + .on('/matches', redirect('/watch/replays')) + .on('/playlists', redirect('/watch/replays')) + .on('/replay', redirect('/watch/replay')) + .on('/predictions', redirect('/watch/predictions')) + .on('/series', redirect('/watch/series')) + .on('/sandbox', redirect('/compete/sandbox')) + .on('/register', redirect('/compete/register')) + .on('/bots', redirect('/leaderboard')) + .on('/docs', redirect('/compete/docs')) + .on('/docs/api', redirect('/compete/docs')) + .on('/clip-maker', redirect('/watch/replays')) + .on('/rivalries', redirect('/watch/replays')) + .on('/feedback', redirect('/compete/docs')) + .notFound(renderNotFoundPage); + +// ─── Initialization ──────────────────────────────────────────────────────────────── + +// Start the router - Agentation is no longer auto-loaded on every page document.addEventListener('DOMContentLoaded', () => { updateActiveNavLink(); router.start(); - initAgentation(); + // Agentation removed from auto-init - now loads only when needed }); // Update nav on initial load diff --git a/web/src/og-tags.ts b/web/src/og-tags.ts index fcd3469..be93d70 100644 --- a/web/src/og-tags.ts +++ b/web/src/og-tags.ts @@ -94,7 +94,7 @@ export function getReplayOGTags(match: { return { title: `Match: ${match.participants.map(p => p.name).join(' vs ')}`, description: `Winner: ${winnerName} | ${match.turns} turns | ${match.participants.map(p => `${p.name}: ${p.score}`).join(', ')}`, - url: `https://aicodebattle.com/#/replay/${match.id}`, + url: `https://aicodebattle.com/#/watch/replay/${match.id}`, image: thumbnailUrl, type: 'video.other', }; @@ -112,7 +112,7 @@ export function getPlaylistOGTags(playlist: { return { title: `${playlist.title} - Playlist`, description: `${playlist.description || 'Curated match collection'} | ${playlist.matchCount} matches`, - url: `https://aicodebattle.com/#/playlists/${playlist.slug}`, + url: `https://aicodebattle.com/#/watch/replays`, type: 'website', }; } diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts index 8dfe67a..4987ab5 100644 --- a/web/src/pages/bot-profile.ts +++ b/web/src/pages/bot-profile.ts @@ -12,7 +12,7 @@ export async function renderBotProfilePage(params: Record): Prom app.innerHTML = `
Loading...
@@ -45,7 +45,7 @@ export async function renderBotProfilePage(params: Record): Prom

Failed to load bot profile: ${error}

This bot may not exist or data is not yet available.

- Back to Bot Directory + Back to Leaderboard
`; } @@ -128,7 +128,7 @@ function renderRecentMatches(matches: BotProfile['recent_matches']): string { ${won ? 'W' : 'L'} ${opponent ? escapeHtml(opponent.name) : 'Unknown'} ${match.participants.map(p => p.score).join(' - ')} - Watch + Watch `; }).join(''); diff --git a/web/src/pages/bots.ts b/web/src/pages/bots.ts index c60daaf..802425c 100644 --- a/web/src/pages/bots.ts +++ b/web/src/pages/bots.ts @@ -38,7 +38,7 @@ function renderBotsList( container.innerHTML = `

No bots registered yet.

- Register a Bot + Register a Bot
`; return; diff --git a/web/src/pages/docs-api.ts b/web/src/pages/docs-api.ts index f73ab06..5bca8ab 100644 --- a/web/src/pages/docs-api.ts +++ b/web/src/pages/docs-api.ts @@ -464,7 +464,7 @@ export function renderDocsApiPage(): void { app.innerHTML = `

API Reference

diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index bc66d7b..16a8153 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -1,71 +1,574 @@ -// Home page - landing page with overview +// Home page - dynamic landing page with live data +import { + fetchLeaderboard, + fetchBlogIndex, + fetchPlaylistIndex, + fetchEvolutionMeta, + fetchSeasonIndex, + fetchMatchIndex, + type Season, + type MatchSummary +} from '../api-types'; -export function renderHomePage(): void { +const PAGES_BASE = ''; + +// Stale-while-revalidate cache +interface CacheEntry { + data: T; + timestamp: number; +} + +const cache = new Map>(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +async function fetchWithCache( + key: string, + fetcher: () => Promise, + defaultValue: T +): Promise { + const cached = cache.get(key) as CacheEntry | undefined; + const now = Date.now(); + + if (cached && now - cached.timestamp < CACHE_TTL) { + // Stale: return cached data immediately + fetcher().then(data => { + cache.set(key, { data, timestamp: now }); + // Trigger re-render with fresh data + requestAnimationFrame(() => renderHomePage()); + }).catch(() => { + // Silently fail on background refresh + }); + return cached.data; + } + + // No cache or expired: fetch fresh data + try { + const data = await fetcher(); + cache.set(key, { data, timestamp: now }); + return data; + } catch { + return defaultValue; + } +} + +// Find featured replay (highest-viewed recent match) +function findFeaturedReplay(matches: MatchSummary[]): MatchSummary | null { + const completed = matches.filter(m => m.completed_at && m.participants.length === 2); + if (completed.length === 0) return null; + // For now, just return the most recent completed match + // TODO: Add view_count to match index and sort by that + return completed.sort((a, b) => + new Date(b.completed_at!).getTime() - new Date(a.completed_at!).getTime() + )[0] || null; +} + +// Format time remaining +function formatTimeRemaining(endDate: string | null): string { + if (!endDate) return ''; + const now = Date.now(); + const end = new Date(endDate).getTime(); + const diff = end - now; + + if (diff <= 0) return 'Ending soon'; + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + + if (days > 0) return `${days} day${days === 1 ? '' : 's'} remaining`; + if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} remaining`; + return 'Less than an hour'; +} + +// Get current week of season +function getSeasonProgress(season: Season | null): { week: number; totalWeeks: number; timeRemaining: string } | null { + if (!season || season.status !== 'active') return null; + // Simple calculation - in production this would come from season data + const start = new Date(season.starts_at).getTime(); + season.ends_at ? new Date(season.ends_at).getTime() : start + (4 * 7 * 24 * 60 * 60 * 1000); + const now = Date.now(); + const totalWeeks = 4; + const week = Math.min(Math.floor((now - start) / (7 * 24 * 60 * 60 * 1000)) + 1, totalWeeks); + return { week, totalWeeks, timeRemaining: formatTimeRemaining(season.ends_at) }; +} + +export async function renderHomePage(): Promise { const app = document.getElementById('app'); if (!app) return; + // Fetch all data in parallel + const [ + leaderboardData, + blogData, + playlistsData, + evolutionMeta, + seasonData, + matchesData + ] = await Promise.all([ + fetchWithCache('leaderboard', fetchLeaderboard, { updated_at: '', entries: [] }), + fetchWithCache('blog', fetchBlogIndex, { updated_at: '', posts: [] }), + fetchWithCache('playlists', fetchPlaylistIndex, { updated_at: '', playlists: [] }), + fetchWithCache('evolution', fetchEvolutionMeta, { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' }), + fetchWithCache('seasons', fetchSeasonIndex, { updated_at: '', active_season: null, seasons: [] }), + fetchWithCache('matches', fetchMatchIndex, { updated_at: '', matches: [] }) + ]); + + const top5 = leaderboardData.entries.slice(0, 5); + const latestStories = blogData.posts.slice(0, 3); + const featuredPlaylists = playlistsData.playlists.slice(0, 6); + const featuredReplay = findFeaturedReplay(matchesData.matches); + const activeSeason = seasonData.active_season; + const seasonProgress = getSeasonProgress(activeSeason); + app.innerHTML = `
+

AI Code Battle

-

Program your bot. Compete for supremacy.

-

- Write an HTTP server that controls units on a grid world. - Collect energy, capture cores, and eliminate your opponents. -

+

Bots compete. Strategies evolve. You watch.

- - + Watch Battles + Build a Bot
-
-

How It Works

-
-
-

Write Code

-

Create a bot in any language that exposes an HTTP endpoint. - Your bot receives game state and returns moves each turn.

+ + ${featuredReplay ? ` + + ` : ''} + + +
+ +
+

Top 5 Bots

+
+ ${top5.length > 0 ? top5.map((entry: any, i: number) => ` +
+ #${entry.rank} + ${escapeHtml(entry.name)} + ${entry.rating} +
+ `).join('') : '

No bots yet

'}
-
-

Deploy

-

Host your bot anywhere - cloud, container, or bare metal. - Just make sure it's accessible via HTTP.

-
-
-

Compete

-

Your bot plays matches automatically against other registered bots. - Climb the leaderboard with victories.

-
-
-

Watch

-

View replays of every match. Analyze strategies, - learn from defeats, and improve your bot.

+ Full leaderboard β†’ +
+ + +
+

Latest Stories

+
+ ${latestStories.length > 0 ? latestStories.map((post: any) => ` + +
${escapeHtml(post.title)}
+ +
+ `).join('') : '

No stories yet

'}
+ All stories β†’
-
`; diff --git a/web/src/pages/playlists.ts b/web/src/pages/playlists.ts index cd57d42..24e5d95 100644 --- a/web/src/pages/playlists.ts +++ b/web/src/pages/playlists.ts @@ -334,7 +334,7 @@ async function showPlaylistDetail(slug: string): Promise { } function watchMatch(matchId: string): void { - window.location.hash = `/replay?match=${matchId}`; + window.location.hash = `/watch/replay?url=/replays/${matchId}.json`; } function copyEmbedCode(matchId: string): void { diff --git a/web/src/pages/register.ts b/web/src/pages/register.ts index afe68dd..832487b 100644 --- a/web/src/pages/register.ts +++ b/web/src/pages/register.ts @@ -41,7 +41,7 @@ export function renderRegisterPage(): void {
  • Your bot must expose an HTTPS endpoint accessible from the internet
  • The endpoint must respond to POST requests with game state JSON
  • Response time must be under 3 seconds per turn
  • -
  • See the Getting Started guide for protocol details
  • +
  • See the Getting Started guide for protocol details
  • diff --git a/web/src/pages/rivalries.ts b/web/src/pages/rivalries.ts index 07a5fee..6cc0b1a 100644 --- a/web/src/pages/rivalries.ts +++ b/web/src/pages/rivalries.ts @@ -274,7 +274,7 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void ${r.streak ? `${escapeHtml(r.streak.bot)} on ${r.streak.count}-win streak` : ''} ${r.draws > 0 ? `${r.draws} draw${r.draws !== 1 ? 's' : ''}` : ''} Last: ${dateStr(r.lastMatchAt)} - All Matches + All Matches
    `).join('')} diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 6f11235..245e217 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -1,5 +1,138 @@ import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode } from './types'; +// ── Particle System (pooled, 100 objects, zero GC) ────────────────────────────── +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + alpha: number; + color: string; + lifetime: number; // ms + elapsed: number; // ms + active: boolean; +} + +const PARTICLE_POOL_SIZE = 100; +const particlePool: Particle[] = Array.from({ length: PARTICLE_POOL_SIZE }, () => ({ + x: 0, y: 0, vx: 0, vy: 0, alpha: 1, color: '#fff', lifetime: 0, elapsed: 0, active: false, +})); + +function borrowParticle(x: number, y: number, vx: number, vy: number, color: string, lifetime: number): Particle | null { + for (const p of particlePool) { + if (!p.active) { + p.x = x; p.y = y; p.vx = vx; p.vy = vy; + p.color = color; p.lifetime = lifetime; p.elapsed = 0; + p.alpha = 1; p.active = true; + return p; + } + } + return null; +} + +function tickParticles(dt: number): void { + for (const p of particlePool) { + if (!p.active) continue; + p.elapsed += dt; + if (p.elapsed >= p.lifetime) { p.active = false; continue; } + p.x += p.vx * dt; + p.y += p.vy * dt; + p.alpha = 1 - p.elapsed / p.lifetime; + } +} + +function drawParticles(ctx: CanvasRenderingContext2D): void { + for (const p of particlePool) { + if (!p.active) continue; + ctx.globalAlpha = p.alpha; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; +} + +// ── One-shot effect slots (reusable, max 20 concurrent) ───────────────────────── +interface FloatText { x: number; y: number; text: string; color: string; elapsed: number; lifetime: number; active: boolean; } +interface Shockwave { x: number; y: number; radius: number; maxRadius: number; color: string; elapsed: number; lifetime: number; active: boolean; } +interface SpawnGlow { x: number; y: number; color: string; elapsed: number; lifetime: number; active: boolean; } +interface Trail { x: number; y: number; prevX: number; prevY: number; color: string; alpha: number; active: boolean; } + +const MAX_EFFECTS = 20; +const floatTexts: FloatText[] = Array.from({ length: MAX_EFFECTS }, () => ({ x: 0, y: 0, text: '', color: '', elapsed: 0, lifetime: 0, active: false })); +const shockwaves: Shockwave[] = Array.from({ length: MAX_EFFECTS }, () => ({ x: 0, y: 0, radius: 0, maxRadius: 0, color: '', elapsed: 0, lifetime: 0, active: false })); +const spawnGlows: SpawnGlow[] = Array.from({ length: MAX_EFFECTS }, () => ({ x: 0, y: 0, color: '', elapsed: 0, lifetime: 0, active: false })); +const trails: Trail[] = Array.from({ length: MAX_EFFECTS }, () => ({ x: 0, y: 0, prevX: 0, prevY: 0, color: '', alpha: 0, active: false })); + +function borrowSlot(arr: T[]): T | null { + for (const item of arr) { if (!item.active) return item; } + return null; +} + +function tickEffects(dt: number): void { + for (const e of floatTexts) { if (!e.active) continue; e.elapsed += dt; e.y -= 20 * dt / 1000; if (e.elapsed >= e.lifetime) e.active = false; } + for (const e of shockwaves) { if (!e.active) continue; e.elapsed += dt; if (e.elapsed >= e.lifetime) e.active = false; } + for (const e of spawnGlows) { if (!e.active) continue; e.elapsed += dt; if (e.elapsed >= e.lifetime) e.active = false; } + for (const e of trails) { if (!e.active) continue; e.alpha -= dt / 150; if (e.alpha <= 0) e.active = false; } +} + +function drawEffects(ctx: CanvasRenderingContext2D): void { + // Float texts + for (const e of floatTexts) { + if (!e.active) continue; + const t = e.elapsed / e.lifetime; + ctx.globalAlpha = 1 - t; + ctx.fillStyle = e.color; + ctx.font = 'bold 11px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(e.text, e.x, e.y); + } + + // Shockwaves + for (const e of shockwaves) { + if (!e.active) continue; + const t = e.elapsed / e.lifetime; + const r = e.maxRadius * t; + ctx.globalAlpha = 0.6 * (1 - t); + ctx.strokeStyle = e.color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(e.x, e.y, r, 0, Math.PI * 2); + ctx.stroke(); + } + + // Spawn glows + for (const e of spawnGlows) { + if (!e.active) continue; + const t = e.elapsed / e.lifetime; + const r = 12; + const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, r * (1 + t)); + grad.addColorStop(0, e.color + 'aa'); + grad.addColorStop(1, e.color + '00'); + ctx.globalAlpha = 1 - t; + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(e.x, e.y, r * (1 + t), 0, Math.PI * 2); + ctx.fill(); + } + + // Motion trails + for (const e of trails) { + if (!e.active) continue; + ctx.globalAlpha = e.alpha * 0.4; + ctx.strokeStyle = e.color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(e.prevX, e.prevY); + ctx.lineTo(e.x, e.y); + ctx.stroke(); + } + + ctx.globalAlpha = 1; +} + // Win probability point for sparkline export interface WinProbPoint { turn: number; @@ -243,7 +376,6 @@ export class ReplayViewer { private currentTurn: number = 0; private isPlaying: boolean = false; private animationFrame: number | null = null; - private lastFrameTime: number = 0; private cellSize: number; private showGrid: boolean; private fogOfWarPlayer: number | null; @@ -253,6 +385,19 @@ export class ReplayViewer { private showDebug: boolean; private screenReaderRegion: HTMLElement | null = null; + // Animation state + private turnStartTime: number = 0; + private lastRenderTime: number = 0; + private renderLoopRunning: boolean = false; + // Per-bot interpolated positions: map botId -> {renderX, renderY} + private botRenderPos: Map = new Map(); + // Per-bot previous turn positions (for lerp source) + private botPrevPos: Map = new Map(); + // Bots that spawned this turn (for spawn animation) + private spawnedBotIds: Set = new Set(); + // Global idle pulse phase (radians) + private idlePhase: number = 0; + // Event callbacks public onTurnChange?: (turn: number) => void; public onPlayStateChange?: (playing: boolean) => void; @@ -309,6 +454,11 @@ export class ReplayViewer { loadReplay(replay: Replay): void { this.replay = replay; this.currentTurn = 0; + this.turnStartTime = performance.now(); + this.botPrevPos.clear(); + this.botRenderPos.clear(); + this.spawnedBotIds.clear(); + this.idlePhase = 0; // Resize canvas to fit the grid this.resizeCanvas(); @@ -316,6 +466,9 @@ export class ReplayViewer { // Render initial state this.render(); + // Start the continuous render loop + this.startRenderLoop(); + if (this.onReplayLoad) this.onReplayLoad(replay); } @@ -334,9 +487,12 @@ export class ReplayViewer { setTurn(turn: number): void { if (!this.replay) return; - this.currentTurn = Math.max(0, Math.min(turn, this.replay.turns.length - 1)); - this.render(); - if (this.onTurnChange) this.onTurnChange(this.currentTurn); + const newTurn = Math.max(0, Math.min(turn, this.replay.turns.length - 1)); + if (newTurn !== this.currentTurn) { + this.advanceTurn(newTurn); + // Ensure render loop is running + this.startRenderLoop(); + } } getTurn(): number { @@ -350,17 +506,14 @@ export class ReplayViewer { play(): void { if (this.isPlaying || !this.replay) return; this.isPlaying = true; - this.lastFrameTime = performance.now(); - this.animationFrame = requestAnimationFrame(this.animate.bind(this)); + this.turnStartTime = performance.now(); + this.startRenderLoop(); if (this.onPlayStateChange) this.onPlayStateChange(true); } pause(): void { this.isPlaying = false; - if (this.animationFrame !== null) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - } + // Keep render loop running for idle animations and particles if (this.onPlayStateChange) this.onPlayStateChange(false); } @@ -424,6 +577,11 @@ export class ReplayViewer { return this.showDebug; } + destroy(): void { + this.stopRenderLoop(); + this.isPlaying = false; + } + // Get the active color palette based on accessibility settings private getPlayerColors(): string[] { if (this.accessibility.highContrast) { @@ -571,26 +729,177 @@ export class ReplayViewer { ctx.closePath(); } - private animate(timestamp: number): void { - if (!this.isPlaying || !this.replay) return; + // ── Continuous 60fps render loop (decoupled from tick rate) ───────────────── + private startRenderLoop(): void { + if (this.renderLoopRunning) return; + this.renderLoopRunning = true; + this.lastRenderTime = performance.now(); + this.renderLoopTick(this.lastRenderTime); + } - const elapsed = timestamp - this.lastFrameTime; - if (elapsed >= this.animationSpeed) { - this.lastFrameTime = timestamp; + private stopRenderLoop(): void { + this.renderLoopRunning = false; + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } - // Advance to next turn - if (this.currentTurn < this.replay.turns.length - 1) { - this.currentTurn++; - this.render(); - if (this.onTurnChange) this.onTurnChange(this.currentTurn); - } else { - // End of replay - this.pause(); - return; + private renderLoopTick(timestamp: number): void { + if (!this.renderLoopRunning) return; + + const dt = timestamp - this.lastRenderTime; + this.lastRenderTime = timestamp; + + // Advance idle pulse phase (2s cycle = Ο€ per second) + this.idlePhase += (Math.PI * dt) / 1000; + + // Tick particles and effects + if (!this.accessibility.reducedMotion) { + tickParticles(dt); + tickEffects(dt); + } + + // If playing, check if we should advance to next turn + if (this.isPlaying && this.replay) { + const turnElapsed = timestamp - this.turnStartTime; + if (turnElapsed >= this.animationSpeed) { + if (this.currentTurn < this.replay.turns.length - 1) { + this.advanceTurn(this.currentTurn + 1); + } else { + this.pause(); + // Render one last frame + this.render(); + return; + } } } - this.animationFrame = requestAnimationFrame(this.animate.bind(this)); + // Always render at display refresh rate + this.render(); + + this.animationFrame = requestAnimationFrame(this.renderLoopTick.bind(this)); + } + + private advanceTurn(newTurn: number): void { + if (!this.replay) return; + + // Store previous bot positions before advancing + const prevTurnData = this.replay.turns[this.currentTurn]; + this.botPrevPos.clear(); + if (prevTurnData) { + for (const bot of prevTurnData.bots) { + if (!bot.alive) continue; + this.botPrevPos.set(bot.id, { + x: bot.position.col * this.cellSize + this.cellSize / 2, + y: bot.position.row * this.cellSize + this.cellSize / 2, + }); + } + } + + this.currentTurn = newTurn; + this.turnStartTime = performance.now(); + + // Fire events for the new turn to spawn animations + const turnData = this.replay.turns[this.currentTurn]; + if (turnData && !this.accessibility.reducedMotion) { + this.fireTurnAnimations(turnData); + } + + if (this.onTurnChange) this.onTurnChange(this.currentTurn); + } + + private fireTurnAnimations(turnData: ReplayTurn): void { + const colors = this.getPlayerColors(); + const events = turnData.events ?? []; + + // Track spawned bot IDs for spawn animation + this.spawnedBotIds.clear(); + + for (const event of events) { + const d = event.details as Record; + if (!d) continue; + + switch (event.type) { + case 'bot_died': { + const pos = d.position as Position | undefined; + if (!pos) break; + const owner = d.owner as number ?? 0; + const cx = pos.col * this.cellSize + this.cellSize / 2; + const cy = pos.row * this.cellSize + this.cellSize / 2; + // Spawn 6-8 particles in random directions + const count = 6 + Math.floor(Math.random() * 3); + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5; + const speed = 40 + Math.random() * 60; // px/s + borrowParticle( + cx, cy, + Math.cos(angle) * speed / 1000, + Math.sin(angle) * speed / 1000, + colors[owner] ?? '#ef4444', + 400 + ); + } + break; + } + case 'energy_collected': { + const pos = d.position as Position | undefined; + if (!pos) break; + const cx = pos.col * this.cellSize + this.cellSize / 2; + const cy = pos.row * this.cellSize + this.cellSize / 2; + // 4-line starburst + for (let i = 0; i < 4; i++) { + const angle = (Math.PI / 2) * i; + borrowParticle(cx, cy, Math.cos(angle) * 0.05, Math.sin(angle) * 0.05, ENERGY_COLOR, 200); + } + // Floating '+1' + const ft = borrowSlot(floatTexts); + if (ft) { + ft.x = cx; ft.y = cy - 8; + ft.text = '+1'; ft.color = ENERGY_COLOR; + ft.elapsed = 0; ft.lifetime = 200; ft.active = true; + } + break; + } + case 'core_captured': { + const pos = d.position as Position | undefined; + if (!pos) break; + const newOwner = d.new_owner as number ?? 0; + const cx = pos.col * this.cellSize + this.cellSize / 2; + const cy = pos.row * this.cellSize + this.cellSize / 2; + const sw = borrowSlot(shockwaves); + if (sw) { + sw.x = cx; sw.y = cy; sw.radius = 0; + sw.maxRadius = this.cellSize * 2; + sw.color = colors[newOwner] ?? '#fff'; + sw.elapsed = 0; sw.lifetime = 500; sw.active = true; + } + break; + } + case 'bot_spawned': { + const botId = d.bot_id as number | undefined; + if (botId !== undefined) this.spawnedBotIds.add(botId); + const owner = d.owner as number ?? 0; + const pos = d.position as Position | undefined; + if (!pos) break; + const cx = pos.col * this.cellSize + this.cellSize / 2; + const cy = pos.row * this.cellSize + this.cellSize / 2; + const sg = borrowSlot(spawnGlows); + if (sg) { + sg.x = cx; sg.y = cy; + sg.color = colors[owner] ?? '#fff'; + sg.elapsed = 0; sg.lifetime = 200; sg.active = true; + } + break; + } + } + } + } + + // Lerp factor: 0 at turn start β†’ 1 at turn end + private getLerpT(): number { + const elapsed = performance.now() - this.turnStartTime; + return Math.min(1, elapsed / this.animationSpeed); } private render(): void { @@ -634,6 +943,12 @@ export class ReplayViewer { break; } + // Draw animated particles and effects (if not reduced motion) + if (!this.accessibility.reducedMotion) { + drawEffects(ctx); + drawParticles(ctx); + } + // Draw debug telemetry overlay if enabled if (this.showDebug && turnData.debug) { this.renderDebugOverlay(turnData.debug, colors); @@ -713,6 +1028,11 @@ export class ReplayViewer { // Draw combat effects from events this turn this.drawCombatEffects(turnData, colors, visible); + + // Draw threat lines between bots in attack range + if (!this.accessibility.reducedMotion) { + this.drawThreatLines(turnData, colors, visible); + } } private drawCombatEffects( @@ -793,6 +1113,48 @@ export class ReplayViewer { } } + // Draw threat lines between bots of different owners within attack range + private drawThreatLines( + turnData: ReplayTurn, + visible: Set | null + ): void { + const { ctx, cellSize } = this; + const aliveBots = turnData.bots.filter(b => b.alive); + const attackRadius2 = this.replay?.config?.attack_radius2 ?? 5; + const attackRadius = Math.sqrt(attackRadius2) * cellSize; + + for (let i = 0; i < aliveBots.length; i++) { + for (let j = i + 1; j < aliveBots.length; j++) { + const a = aliveBots[i]; + const b = aliveBots[j]; + if (a.owner === b.owner) continue; + + const ax = a.position.col * cellSize + cellSize / 2; + const ay = a.position.row * cellSize + cellSize / 2; + const bx = b.position.col * cellSize + cellSize / 2; + const by = b.position.row * cellSize + cellSize / 2; + + // Use toroidal distance + const dist = Math.hypot( + Math.min(Math.abs(ax - bx), this.replay!.map.cols * cellSize - Math.abs(ax - bx)), + Math.min(Math.abs(ay - by), this.replay!.map.rows * cellSize - Math.abs(ay - by)) + ); + + if (dist <= attackRadius) { + if (visible && (!visible.has(this.posKey(a.position)) || !visible.has(this.posKey(b.position)))) continue; + ctx.strokeStyle = 'rgba(239, 68, 68, 0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.setLineDash([]); + } + } + } + } + // Dots view - minimal, just bot positions as dots private renderDotsView( turnData: ReplayTurn, @@ -1174,11 +1536,49 @@ export class ReplayViewer { private drawBot(bot: ReplayBot, color: string): void { const { cellSize } = this; - const x = bot.position.col * cellSize + cellSize / 2; - const y = bot.position.row * cellSize + cellSize / 2; - const radius = (cellSize / 2) - 1; + const targetX = bot.position.col * cellSize + cellSize / 2; + const targetY = bot.position.row * cellSize + cellSize / 2; - // Draw bot with player-specific shape for accessibility + let x = targetX; + let y = targetY; + let scale = 1; + + if (!this.accessibility.reducedMotion) { + // Lerp from previous position + const prev = this.botPrevPos.get(bot.id); + const t = this.getLerpT(); + if (prev && t < 1) { + x = prev.x + (targetX - prev.x) * t; + y = prev.y + (targetY - prev.y) * t; + + // Motion trail (only if moved meaningfully) + const dx = targetX - prev.x; + const dy = targetY - prev.y; + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { + const tr = borrowSlot(trails); + if (tr) { + tr.x = targetX; tr.y = targetY; + tr.prevX = prev.x; tr.prevY = prev.y; + tr.color = color; tr.alpha = 1; tr.active = true; + } + } + } + + // Store interpolated position for this frame + this.botRenderPos.set(bot.id, { x, y }); + + // Idle pulse: 2% scale, 2s cycle + const pulse = 1 + 0.02 * Math.sin(this.idlePhase); + scale *= pulse; + + // Spawn animation: scale from 0β†’1 over 200ms + if (this.spawnedBotIds.has(bot.id)) { + const spawnT = Math.min(1, (performance.now() - this.turnStartTime) / 200); + scale *= spawnT; + } + } + + const radius = ((cellSize / 2) - 1) * scale; this.drawPlayerShape(x, y, radius, bot.owner, color); } diff --git a/web/src/router.ts b/web/src/router.ts index 12ced21..afe0ccf 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -65,7 +65,7 @@ class Router { /** * Handle the current route */ - private handleRoute(): void { + private async handleRoute(): Promise { const path = this.getCurrentPath(); for (const route of this.routes) { @@ -75,13 +75,13 @@ class Router { route.paramNames.forEach((name, idx) => { params[name] = decodeURIComponent(match[idx + 1]); }); - route.handler(params); + await route.handler(params); return; } } if (this.notFoundHandler) { - this.notFoundHandler({}); + await this.notFoundHandler({}); } } } diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..0984629 --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,190 @@ +/* ──────────────────────────────────────────────────────────────────────────────── */ +/* Base CSS Variables & Reset */ +/* ──────────────────────────────────────────────────────────────────────────────── */ + +:root { + /* Color Palette - Dark Theme Default */ + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + + --text-primary: #f8fafc; + --text-secondary: #e2e8f0; + --text-muted: #94a3b8; + + --accent: #3b82f6; + --accent-hover: #2563eb; + + --success: #22c55e; + --warning: #f59e0b; + --error: #ef4444; + + --border: #334155; + + /* Typography */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + --font-mono: 'Fira Code', 'Monaco', 'Courier New', monospace; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + + /* Z-index layers */ + --z-base: 1; + --z-dropdown: 100; + --z-sticky: 200; + --z-modal: 1000; + --z-toast: 1100; +} + +/* Reset & Base Styles */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + overflow-x: hidden; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + color: var(--text-primary); + line-height: 1.25; + margin-bottom: var(--space-md); +} + +h1 { font-size: 1.875rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.25rem; } +h4 { font-size: 1.125rem; } +h5 { font-size: 1rem; } +h6 { font-size: 0.875rem; } + +p { + margin-bottom: var(--space-md); + color: var(--text-muted); +} + +a { + color: var(--accent); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--accent-hover); +} + +/* Mobile heading adjustments */ +@media (max-width: 640px) { + h1 { font-size: 1.5rem; } + h2 { font-size: 1.25rem; } + h3 { font-size: 1.125rem; } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Selection */ +::selection { + background: rgba(59, 130, 246, 0.3); + color: var(--text-primary); +} + +/* Focus Styles */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Touch-friendly tap target size (min 44x44px per WCAG) */ +button, +a, +input, +select, +textarea { + min-height: 44px; + min-width: 44px; +} + +/* App container */ +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Utility: Hide scrollbar but allow scroll */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Utility: Text truncation */ +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Utility: Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/web/src/styles/components.css b/web/src/styles/components.css new file mode 100644 index 0000000..f4ce7fa --- /dev/null +++ b/web/src/styles/components.css @@ -0,0 +1,470 @@ +/* ──────────────────────────────────────────────────────────────────────────────── */ +/* Component Styles */ +/* ──────────────────────────────────────────────────────────────────────────────── */ + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); + text-decoration: none; + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn.primary { + background-color: var(--accent); + color: white; +} + +.btn.primary:hover:not(:disabled) { + background-color: var(--accent-hover); +} + +.btn.secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn.secondary:hover:not(:disabled) { + background-color: var(--text-muted); +} + +.btn.small { + padding: var(--space-xs) var(--space-sm); + font-size: 0.75rem; + min-height: 32px; + min-width: 32px; +} + +.btn.large { + padding: var(--space-md) var(--space-lg); + font-size: 1rem; +} + +/* Cards */ +.card { + background-color: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-md); + margin-bottom: var(--space-md); +} + +.card-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: var(--space-sm); +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: var(--space-md); +} + +/* Form Elements */ +input[type="text"], +input[type="number"], +input[type="email"], +input[type="password"], +input[type="url"], +select, +textarea { + width: 100%; + padding: var(--space-sm); + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + transition: border-color var(--transition-fast); +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-muted); +} + +label { + display: block; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: var(--space-xs); +} + +/* Tables */ +.table-container { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +thead { + background-color: var(--bg-tertiary); +} + +th { + padding: var(--space-sm) var(--space-md); + text-align: left; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +td { + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border); +} + +tr:last-child td { + border-bottom: none; +} + +tbody tr:hover { + background-color: var(--bg-tertiary); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px var(--space-sm); + font-size: 0.75rem; + font-weight: 500; + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.badge.success { background-color: rgba(34, 197, 94, 0.2); color: var(--success); } +.badge.warning { background-color: rgba(245, 158, 11, 0.2); color: var(--warning); } +.badge.error { background-color: rgba(239, 68, 68, 0.2); color: var(--error); } +.badge.info { background-color: rgba(59, 130, 246, 0.2); color: var(--accent); } + +/* Loading Spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-xl); + color: var(--text-muted); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: var(--space-xl); + color: var(--text-muted); +} + +.empty-state p { + margin-bottom: var(--space-md); +} + +/* Error State */ +.error { + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid var(--error); + border-radius: var(--radius-md); + padding: var(--space-md); + color: var(--error); +} + +.error p { + margin-bottom: var(--space-sm); +} + +.error .hint { + font-size: 0.875rem; + opacity: 0.8; +} + +/* Code Block */ +.code-block { + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-md); + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--text-secondary); +} + +pre { + margin: 0; +} + +code { + font-family: var(--font-mono); + font-size: 0.875em; + background-color: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +/* Page Layout */ +.page-title { + margin-bottom: var(--space-lg); +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-lg); + gap: var(--space-md); +} + +.page-header h1 { + margin-bottom: 0; +} + +/* Grid System */ +.grid { + display: grid; + gap: var(--space-md); +} + +.grid-2 { grid-template-columns: repeat(2, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } + +@media (max-width: 768px) { + .grid-2, .grid-3, .grid-4 { + grid-template-columns: 1fr; + } +} + +/* Flex Utilities */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } +.gap-sm { gap: var(--space-sm); } +.gap-md { gap: var(--space-md); } +.gap-lg { gap: var(--space-lg); } + +/* Panel */ +.panel { + background-color: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-md); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-md); + font-weight: 600; + color: var(--text-primary); +} + +/* Slider */ +.slider-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.slider-group label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 0; +} + +.slider-group input[type="range"] { + width: 100%; + height: 6px; + padding: 0; + border: none; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + appearance: none; + -webkit-appearance: none; +} + +.slider-group input[type="range"]::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} + +.slider-group input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + border: none; +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: var(--space-sm); + cursor: pointer; + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +/* Leaderboard Table */ +.leaderboard-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.leaderboard-table tbody tr { + transition: background-color var(--transition-fast); +} + +.leaderboard-table tbody tr.rank-1 { + background-color: rgba(245, 158, 11, 0.1); +} + +.leaderboard-table tbody tr.rank-2 { + background-color: rgba(148, 163, 184, 0.1); +} + +.leaderboard-table tbody tr.rank-3 { + background-color: rgba(180, 83, 9, 0.1); +} + +.leaderboard-table .rank { + font-weight: 600; + width: 60px; +} + +.leaderboard-table .rank-1 .rank { color: var(--warning); } +.leaderboard-table .rank-2 .rank { color: #94a3b8; } +.leaderboard-table .rank-3 .rank { color: #b45309; } + +.leaderboard-table .rating { + text-align: right; +} + +.leaderboard-table .rating-value { + font-weight: 600; +} + +.leaderboard-table .rating-dev { + font-size: 0.75rem; + color: var(--text-muted); +} + +.leaderboard-table .win-rate { + text-align: right; +} + +.leaderboard-table .status { + text-align: center; +} + +.status-healthy { color: var(--success); } +.status-unhealthy { color: var(--error); } +.status-unknown { color: var(--text-muted); } + +/* Replay Canvas */ +.canvas-wrapper { + background-color: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-sm); + overflow: hidden; +} + +.canvas-wrapper canvas { + display: block; + width: 100%; + height: auto; +} + +/* Event Log */ +.event-log { + max-height: 200px; + overflow-y: auto; + font-size: 0.75rem; + font-family: var(--font-mono); +} + +.event-log .event { + padding: var(--space-xs) 0; + border-bottom: 1px solid var(--bg-tertiary); +} + +.event-log .event:last-child { + border-bottom: none; +} + +/* Keyboard Shortcuts */ +.keyboard-shortcuts { + font-size: 0.75rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.keyboard-shortcuts kbd { + background-color: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 5e970a4..58533b1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,6 +12,42 @@ export default defineConfig({ app: resolve(__dirname, 'app.html'), embed: resolve(__dirname, 'embed.html'), }, + output: { + manualChunks(id) { + // Agentation: React + agentation library (lazy-loaded) + if (id.includes('react') || id.includes('agentation')) { + return 'agentation'; + } + // Replay viewer chunk (includes canvas rendering, charts) + if (id.includes('replay-viewer') || id.includes('win-probability')) { + return 'replay-viewer'; + } + // Sandbox chunk (includes engine orchestration) + if (id.includes('pages/sandbox')) { + return 'sandbox'; + } + // Evolution page (large, complex visualizations) + if (id.includes('pages/evolution')) { + return 'evolution'; + } + // Blog pages (markdown parsing) + if (id.includes('pages/blog')) { + return 'blog'; + } + // Clip maker (video processing) + if (id.includes('pages/clip-maker')) { + return 'clip-maker'; + } + // Series/predictions (chart-heavy) + if (id.includes('pages/series') || id.includes('pages/predictions')) { + return 'charts'; + } + // Feedback page (includes its own replay viewer) + if (id.includes('pages/feedback')) { + return 'feedback'; + } + }, + }, }, }, server: {