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:
parent
206189f914
commit
00069b1870
25 changed files with 4272 additions and 1211 deletions
|
|
@ -1 +1 @@
|
|||
24d95235c4fae892996dee64918ba954c3df0967
|
||||
206189f914d01d66ad700bce2e49f9e1ba361b81
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
196
cmd/acb-wasm/bot-template/main.go
Normal file
196
cmd/acb-wasm/bot-template/main.go
Normal 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
245
docs/wasm-bot-interface.md
Normal 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
20
scripts/build-wasm.sh
Executable 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)"
|
||||
194
web/app.html
194
web/app.html
|
|
@ -49,7 +49,7 @@
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
/* Navigation - Desktop Top Bar */
|
||||
nav {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
@ -105,6 +105,142 @@
|
|||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Primary nav links (Watch, Compete, Leaderboard) */
|
||||
.nav-link.primary {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Mobile hamburger menu */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Mobile dropdown menu */
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-menu a {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mobile-menu a:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-menu a.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Mobile bottom tab bar */
|
||||
.mobile-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav .nav-links {
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav .nav-link {
|
||||
flex-direction: column;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Skip link for screen reader users */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
transition: top 0.2s;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Main content area - adjust padding for mobile bottom nav */
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
/* Responsive Navigation */
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Hide desktop nav links */
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show mobile hamburger */
|
||||
.mobile-menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Show bottom tab bar */
|
||||
.mobile-bottom-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Add padding to bottom of main content for bottom nav */
|
||||
#app {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
/* Adjust top nav for mobile */
|
||||
.nav-container {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
|
|
@ -735,26 +871,52 @@
|
|||
<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
BIN
web/public/wasm/engine.wasm
Executable file
Binary file not shown.
575
web/public/wasm/wasm_exec.js
Normal file
575
web/public/wasm/wasm_exec.js
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
1654
web/src/app.ts
1654
web/src/app.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
190
web/src/styles/base.css
Normal 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;
|
||||
}
|
||||
470
web/src/styles/components.css
Normal file
470
web/src/styles/components.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue