feat(acb-api): implement bot registration, job coordination, and replay endpoints per plan §12 Phase 4

- POST /api/register: bot registration with URL + shared secret validation
- GET /api/job: worker polls for next pending match job (authenticated)
- POST /api/job/:id/result: worker submits match result (winner, replay JSON)
- GET /api/replay/🆔 serve replay JSON from R2 warm cache (falls back to B2)
- GET /api/bot/🆔 bot profile JSON (rating, elo, record, metadata)
- GET /api/bots: leaderboard snapshot with pagination
- POST /api/ui-feedback: accept Agentation UI feedback

Authentication via Bearer token (worker API key). Shared secrets encrypted
with AES-256-GCM using ACB_ENCRYPTION_KEY.
This commit is contained in:
jedarden 2026-04-21 08:58:42 -04:00
parent 206189f914
commit 00069b1870
25 changed files with 4272 additions and 1211 deletions

View file

@ -1 +1 @@
24d95235c4fae892996dee64918ba954c3df0967
206189f914d01d66ad700bce2e49f9e1ba361b81

View file

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

View file

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

245
docs/wasm-bot-interface.md Normal file
View file

@ -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<Config>,
}
#[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/)

20
scripts/build-wasm.sh Executable file
View file

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

View file

@ -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 @@
<a href="#app" class="skip-link">Skip to main content</a>
<nav>
<div class="nav-container">
<a href="#/" class="nav-brand">AI Code Battle</a>
<div class="nav-links">
<a href="#/leaderboard" class="nav-link">Leaderboard</a>
<a href="#/matches" class="nav-link">Matches</a>
<a href="#/bots" class="nav-link">Bots</a>
<a href="#/" class="nav-brand">⚔️ AI Code Battle</a>
<!-- Desktop navigation -->
<div class="nav-links desktop-nav">
<a href="#/watch" class="nav-link primary">Watch</a>
<a href="#/compete" class="nav-link primary">Compete</a>
<a href="#/leaderboard" class="nav-link primary">Leaderboard</a>
<a href="#/evolution" class="nav-link">Evolution</a>
<a href="#/blog" class="nav-link">Blog</a>
<a href="#/rivalries" class="nav-link">Rivalries</a>
<a href="#/sandbox" class="nav-link">Sandbox</a>
<a href="#/clip-maker" class="nav-link">Clip Maker</a>
<a href="#/feedback" class="nav-link">Feedback</a>
<a href="#/playlists" class="nav-link">Playlists</a>
<a href="#/seasons" class="nav-link">Seasons</a>
<a href="#/series" class="nav-link">Series</a>
<a href="#/predictions" class="nav-link">Predictions</a>
<a href="#/register" class="nav-link">Register</a>
<a href="#/replay" class="nav-link">Replay</a>
<a href="#/season/1" class="nav-link" id="current-season-link">Season 1</a>
</div>
<!-- Mobile hamburger menu toggle -->
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu"></button>
</div>
<!-- Mobile dropdown menu -->
<div class="mobile-menu" id="mobile-menu">
<a href="#/evolution">Evolution</a>
<a href="#/blog">Blog</a>
<a href="#/season/1" id="mobile-season-link">Season 1</a>
</div>
</nav>
<!-- Mobile bottom tab bar -->
<div class="mobile-bottom-nav">
<div class="nav-links">
<a href="#/" class="nav-link">
<span>🏠</span>
<span>Home</span>
</a>
<a href="#/watch" class="nav-link">
<span>👀</span>
<span>Watch</span>
</a>
<a href="#/compete" class="nav-link">
<span>⚔️</span>
<span>Compete</span>
</a>
<a href="#/leaderboard" class="nav-link">
<span>🏆</span>
<span>Board</span>
</a>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/app.ts"></script>
</body>

BIN
web/public/wasm/engine.wasm Executable file

Binary file not shown.

View file

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

View file

@ -332,3 +332,33 @@ export async function fetchPredictionsLeaderboard(): Promise<PredictionsLeaderbo
if (!response.ok) throw new Error(`Failed to fetch predictions leaderboard: ${response.status}`);
return response.json();
}
// Evolution meta types for homepage
export interface EvolutionMeta {
generation: number;
promoted_today: number;
top_10_count: number;
updated_at: string;
}
export async function fetchEvolutionMeta(): Promise<EvolutionMeta> {
const response = await fetch('/data/evolution/meta.json');
if (!response.ok) {
// Return default values if file doesn't exist yet
return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' };
}
return response.json();
}
// 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<SeasonIndex> {
const response = await fetch('/data/seasons/index.json');
if (!response.ok) {
// Return empty index if file doesn't exist
return { updated_at: '', active_season: null, seasons: [] };
}
return response.json();
}

File diff suppressed because it is too large Load diff

View file

@ -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',
};
}

View file

@ -12,7 +12,7 @@ export async function renderBotProfilePage(params: Record<string, string>): Prom
app.innerHTML = `
<div class="bot-profile-page">
<nav class="breadcrumb">
<a href="#/bots">Bots</a> / <span id="bot-breadcrumb-name">Loading...</span>
<a href="#/leaderboard">Leaderboard</a> / <span id="bot-breadcrumb-name">Loading...</span>
</nav>
<div id="profile-content" class="loading">Loading...</div>
</div>
@ -45,7 +45,7 @@ export async function renderBotProfilePage(params: Record<string, string>): Prom
<div class="error">
<p>Failed to load bot profile: ${error}</p>
<p class="hint">This bot may not exist or data is not yet available.</p>
<a href="#/bots" class="btn secondary">Back to Bot Directory</a>
<a href="#/leaderboard" class="btn secondary">Back to Leaderboard</a>
</div>
`;
}
@ -128,7 +128,7 @@ function renderRecentMatches(matches: BotProfile['recent_matches']): string {
<span class="match-result">${won ? 'W' : 'L'}</span>
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
<a href="#/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
`;
}).join('');

View file

@ -38,7 +38,7 @@ function renderBotsList(
container.innerHTML = `
<div class="empty-state">
<p>No bots registered yet.</p>
<a href="#/register" class="btn primary">Register a Bot</a>
<a href="#/compete/register" class="btn primary">Register a Bot</a>
</div>
`;
return;

View file

@ -464,7 +464,7 @@ export function renderDocsApiPage(): void {
app.innerHTML = `
<div class="docs-api-page">
<nav class="breadcrumb">
<a href="#/docs">Docs</a> / <span>API Reference</span>
<a href="#/compete/docs">Docs</a> / <span>API Reference</span>
</nav>
<h1 class="page-title">API Reference</h1>

View file

@ -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<T> {
data: T;
timestamp: number;
}
const cache = new Map<string, CacheEntry<unknown>>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function fetchWithCache<T>(
key: string,
fetcher: () => Promise<T>,
defaultValue: T
): Promise<T> {
const cached = cache.get(key) as CacheEntry<T> | 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<void> {
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 = `
<div class="home-page">
<!-- Hero Section -->
<section class="hero">
<h1>AI Code Battle</h1>
<p class="tagline">Program your bot. Compete for supremacy.</p>
<p class="description">
Write an HTTP server that controls units on a grid world.
Collect energy, capture cores, and eliminate your opponents.
</p>
<p class="tagline">Bots compete. Strategies evolve. You watch.</p>
<div class="cta-buttons">
<button class="btn primary" onclick="window.location.hash='/register'">Register Your Bot</button>
<button class="btn secondary" onclick="window.location.hash='/docs'">Get Started</button>
<a href="#/watch/replays" class="btn primary">Watch Battles</a>
<a href="#/compete/register" class="btn secondary">Build a Bot</a>
</div>
</section>
<section class="features">
<h2>How It Works</h2>
<div class="feature-grid">
<div class="feature">
<h3>Write Code</h3>
<p>Create a bot in any language that exposes an HTTP endpoint.
Your bot receives game state and returns moves each turn.</p>
<!-- Featured Replay -->
${featuredReplay ? `
<section class="featured-replay">
<div class="replay-embed" id="featured-replay-embed">
<iframe
src="${PAGES_BASE}/embed.html?match_id=${featuredReplay.id}&autoplay=true&speed=150&loop=true"
frameborder="0"
allowfullscreen
loading="lazy"
></iframe>
</div>
<div class="replay-info">
<p class="replay-title">
${featuredReplay.participants.map(p => `<strong>${escapeHtml(p.name)}</strong>`).join(' vs ')}
${featuredReplay.winner_id ? ` — Winner: <strong>${escapeHtml(featuredReplay.participants.find(p => p.bot_id === featuredReplay.winner_id)?.name || 'Unknown')}</strong>` : ''}
</p>
<a href="#/watch/replay?url=/replays/${featuredReplay.id}.json" class="btn small secondary">Watch Full Replay </a>
</div>
</section>
` : ''}
<!-- Two-column: Top 5 + Latest Stories -->
<section class="home-grid">
<!-- Top 5 Leaderboard -->
<div class="card leaderboard-summary">
<h2>Top 5 Bots</h2>
<div class="leaderboard-list">
${top5.length > 0 ? top5.map((entry: any, i: number) => `
<div class="leaderboard-row rank-${i + 1}">
<span class="rank">#${entry.rank}</span>
<a href="#/bot/${entry.bot_id}" class="bot-name">${escapeHtml(entry.name)}</a>
<span class="rating">${entry.rating}</span>
</div>
`).join('') : '<p class="empty">No bots yet</p>'}
</div>
<div class="feature">
<h3>Deploy</h3>
<p>Host your bot anywhere - cloud, container, or bare metal.
Just make sure it's accessible via HTTP.</p>
</div>
<div class="feature">
<h3>Compete</h3>
<p>Your bot plays matches automatically against other registered bots.
Climb the leaderboard with victories.</p>
</div>
<div class="feature">
<h3>Watch</h3>
<p>View replays of every match. Analyze strategies,
learn from defeats, and improve your bot.</p>
<a href="#/leaderboard" class="btn small secondary">Full leaderboard </a>
</div>
<!-- Latest Stories -->
<div class="card stories-summary">
<h2>Latest Stories</h2>
<div class="stories-list">
${latestStories.length > 0 ? latestStories.map((post: any) => `
<a href="#/blog/${post.slug}" class="story-link">
<div class="story-title">${escapeHtml(post.title)}</div>
<div class="story-meta">${post.date}</div>
</a>
`).join('') : '<p class="empty">No stories yet</p>'}
</div>
<a href="#/blog" class="btn small secondary">All stories </a>
</div>
</section>
<section class="quick-links">
<h2>Explore</h2>
<div class="link-grid">
<a href="#/leaderboard" class="link-card">
<h3>Leaderboard</h3>
<p>See how bots rank on the competitive ladder</p>
</a>
<a href="#/matches" class="link-card">
<h3>Match History</h3>
<p>Browse recent matches and watch replays</p>
</a>
<a href="#/bots" class="link-card">
<h3>Bot Directory</h3>
<p>View all registered bots and their profiles</p>
</a>
<a href="#/replay" class="link-card">
<h3>Replay Viewer</h3>
<p>Load and watch match replays</p>
</a>
<!-- Playlists Carousel -->
${featuredPlaylists.length > 0 ? `
<section class="playlists-section">
<h2>Playlists</h2>
<div class="playlists-carousel">
${featuredPlaylists.map((playlist: any) => `
<a href="#/watch/replays" class="playlist-card">
<div class="playlist-thumbnail">
${playlist.thumbnail_match_id
? `<img src="/replays/${playlist.thumbnail_match_id}.jpg" alt="${escapeHtml(playlist.title)}" loading="lazy">`
: `<div class="thumbnail-placeholder">⚔️</div>`
}
</div>
<div class="playlist-info">
<div class="playlist-title">${escapeHtml(playlist.title)}</div>
<div class="playlist-count">${playlist.match_count} matches</div>
</div>
</a>
`).join('')}
</div>
</section>
` : ''}
<!-- Season Status Bar -->
${activeSeason && seasonProgress ? `
<section class="season-bar">
<div class="season-info">
<span class="season-name">${escapeHtml(activeSeason.name)}</span>
<span class="season-progress">Week ${seasonProgress.week} of ${seasonProgress.totalWeeks}</span>
<span class="season-time">${seasonProgress.timeRemaining}</span>
</div>
<a href="#/watch/predictions" class="btn small primary">Predictions Open </a>
</section>
` : ''}
<!-- Evolution Observatory Mini -->
<section class="evolution-mini">
<div class="evolution-info">
<span class="evolution-icon">🧬</span>
<span class="evolution-text">
<strong>Evolution Observatory</strong> Gen #${evolutionMeta.generation}
${evolutionMeta.promoted_today > 0 ? ` · ${evolutionMeta.promoted_today} promoted today` : ''}
${evolutionMeta.top_10_count > 0 ? ` · ${evolutionMeta.top_10_count} in top 10` : ''}
</span>
</div>
<a href="#/evolution" class="btn small secondary">Watch evolution live </a>
</section>
</div>
<style>
.home-page {
max-width: 1200px;
margin: 0 auto;
}
/* Hero */
.hero {
text-align: center;
padding: 40px 20px;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
border-radius: 12px;
margin-bottom: 24px;
}
.hero h1 {
font-size: 2.5rem;
color: var(--text-primary);
margin-bottom: 8px;
}
.hero .tagline {
font-size: 1.25rem;
color: var(--accent);
margin-bottom: 24px;
}
.cta-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
/* Featured Replay */
.featured-replay {
background-color: var(--bg-secondary);
border-radius: 12px;
overflow: hidden;
margin-bottom: 24px;
}
.replay-embed {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background-color: #000;
}
.replay-embed iframe {
width: 100%;
height: 100%;
}
.replay-info {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.replay-title {
color: var(--text-primary);
font-size: 0.875rem;
}
/* Two-column grid */
.home-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.card {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
}
.card h2 {
font-size: 1.125rem;
color: var(--text-primary);
margin-bottom: 16px;
}
/* Leaderboard summary */
.leaderboard-row {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.leaderboard-row:last-child {
border-bottom: none;
}
.leaderboard-row .rank {
width: 32px;
font-weight: 700;
color: var(--text-muted);
}
.leaderboard-row.rank-1 .rank { color: #fbbf24; }
.leaderboard-row.rank-2 .rank { color: #94a3b8; }
.leaderboard-row.rank-3 .rank { color: #b45309; }
.leaderboard-row .bot-name {
flex: 1;
color: var(--text-secondary);
text-decoration: none;
}
.leaderboard-row .bot-name:hover {
color: var(--accent);
}
.leaderboard-row .rating {
font-weight: 600;
color: var(--text-primary);
}
/* Stories */
.stories-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.story-link {
display: block;
text-decoration: none;
padding: 8px 0;
}
.story-title {
color: var(--text-secondary);
margin-bottom: 4px;
}
.story-link:hover .story-title {
color: var(--accent);
}
.story-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Playlists carousel */
.playlists-section h2 {
font-size: 1.125rem;
color: var(--text-primary);
margin-bottom: 16px;
}
.playlists-carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg-secondary);
}
.playlists-carousel::-webkit-scrollbar {
height: 6px;
}
.playlists-carousel::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 3px;
}
.playlists-carousel::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.playlist-card {
flex-shrink: 0;
width: 180px;
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
text-decoration: none;
transition: transform 0.2s;
}
.playlist-card:hover {
transform: translateY(-2px);
}
.playlist-thumbnail {
width: 100%;
aspect-ratio: 16 / 9;
background-color: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
.playlist-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-placeholder {
font-size: 2rem;
color: var(--text-muted);
}
.playlist-info {
padding: 10px;
}
.playlist-title {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-count {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Season bar */
.season-bar {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
border-radius: 12px;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.season-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.season-name {
font-weight: 700;
color: white;
font-size: 1.125rem;
}
.season-progress,
.season-time {
color: rgba(255, 255, 255, 0.9);
font-size: 0.875rem;
}
.season-bar .btn {
background-color: white;
color: var(--accent);
}
.season-bar .btn:hover {
background-color: #f1f5f9;
}
/* Evolution mini */
.evolution-mini {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.evolution-info {
display: flex;
align-items: center;
gap: 10px;
}
.evolution-icon {
font-size: 1.5rem;
}
.evolution-text {
color: var(--text-secondary);
font-size: 0.875rem;
}
.evolution-text strong {
color: var(--text-primary);
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 20px 0;
}
/* Responsive */
@media (max-width: 768px) {
.home-grid {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 1.75rem;
}
.hero .tagline {
font-size: 1rem;
}
.season-bar {
flex-direction: column;
gap: 12px;
text-align: center;
}
.season-info {
flex-direction: column;
gap: 4px;
}
}
</style>
`;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View file

@ -39,7 +39,7 @@ function renderLeaderboardTable(
<div class="empty-state">
<p>No bots on the leaderboard yet.</p>
<p>Bots appear here after completing their first match.</p>
<a href="#/register" class="btn primary">Register a Bot</a>
<a href="#/compete/register" class="btn primary">Register a Bot</a>
</div>
`;
return;

View file

@ -76,7 +76,7 @@ function renderMatchCard(match: MatchSummary): string {
<div class="match-footer">
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
<a href="#/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
</div>
`;

View file

@ -334,7 +334,7 @@ async function showPlaylistDetail(slug: string): Promise<void> {
}
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 {

View file

@ -41,7 +41,7 @@ export function renderRegisterPage(): void {
<li>Your bot must expose an HTTPS endpoint accessible from the internet</li>
<li>The endpoint must respond to POST requests with game state JSON</li>
<li>Response time must be under 3 seconds per turn</li>
<li>See the <a href="#/docs">Getting Started guide</a> for protocol details</li>
<li>See the <a href="#/compete/docs">Getting Started guide</a> for protocol details</li>
</ul>
</div>
</div>

View file

@ -274,7 +274,7 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void
${r.streak ? `<span class="streak-badge">${escapeHtml(r.streak.bot)} on ${r.streak.count}-win streak</span>` : ''}
${r.draws > 0 ? `<span class="draws-tag">${r.draws} draw${r.draws !== 1 ? 's' : ''}</span>` : ''}
<span class="last-match">Last: ${dateStr(r.lastMatchAt)}</span>
<a href="#/matches?bot0=${r.bot0Id}&bot1=${r.bot1Id}" class="btn small secondary">All Matches</a>
<a href="#/watch/replays" class="btn small secondary">All Matches</a>
</div>
</div>
`).join('')}

View file

@ -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<T extends { active: boolean }>(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<number, { x: number; y: number }> = new Map();
// Per-bot previous turn positions (for lerp source)
private botPrevPos: Map<number, { x: number; y: number }> = new Map();
// Bots that spawned this turn (for spawn animation)
private spawnedBotIds: Set<number> = 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<string, unknown>;
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<string> | 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);
}

View file

@ -65,7 +65,7 @@ class Router {
/**
* Handle the current route
*/
private handleRoute(): void {
private async handleRoute(): Promise<void> {
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({});
}
}
}

190
web/src/styles/base.css Normal file
View file

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

View file

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

View file

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