diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index af8d7eb..eb4062d 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -4dd91decad4538636d34df90d01199a74a7e1392 +1b55d4dc51471a5a7db86269c4f9790aa9aef434 diff --git a/bots/assassin/src/main.rs b/bots/assassin/src/main.rs index 764c286..b5d142a 100644 --- a/bots/assassin/src/main.rs +++ b/bots/assassin/src/main.rs @@ -8,7 +8,8 @@ mod strategy; use axum::{ extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, routing::{get, post}, Json, Router, }; @@ -60,7 +61,7 @@ async fn handle_turn( State(state): State>>, headers: HeaderMap, body: String, -) -> Result, StatusCode> { +) -> Result { let match_id = headers .get("X-ACB-Match-Id") .and_then(|v| v.to_str().ok()) @@ -94,12 +95,13 @@ async fn handle_turn( info!("Turn {}: {} moves computed", turn, moves.len()); let response = MoveResponse { moves }; - let _response_sig = { - let response_body = serde_json::to_string(&response).unwrap(); - sign_response(&state.secret, match_id, turn, &response_body) - }; + let response_body = serde_json::to_string(&response).unwrap(); + let response_sig = sign_response(&state.secret, match_id, turn, &response_body); - Ok(Json(response)) + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("X-ACB-Signature", HeaderValue::from_str(&response_sig).unwrap()); + + Ok((resp_headers, Json(response))) } async fn handle_health() -> &'static str { diff --git a/bots/phalanx/src/main.rs b/bots/phalanx/src/main.rs index ca445a3..dd32c91 100644 --- a/bots/phalanx/src/main.rs +++ b/bots/phalanx/src/main.rs @@ -8,7 +8,8 @@ mod strategy; use axum::{ extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, routing::{get, post}, Json, Router, }; @@ -60,7 +61,7 @@ async fn handle_turn( State(state): State>>, headers: HeaderMap, body: String, -) -> Result, StatusCode> { +) -> Result { let match_id = headers .get("X-ACB-Match-Id") .and_then(|v| v.to_str().ok()) @@ -96,9 +97,12 @@ async fn handle_turn( let response = MoveResponse { moves }; let response_body = serde_json::to_string(&response).unwrap(); - let _response_sig = sign_response(&state.secret, match_id, turn, &response_body); + let response_sig = sign_response(&state.secret, match_id, turn, &response_body); - Ok(Json(response)) + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("X-ACB-Signature", HeaderValue::from_str(&response_sig).unwrap()); + + Ok((resp_headers, Json(response))) } async fn handle_health() -> &'static str { diff --git a/bots/rusher/src/main.rs b/bots/rusher/src/main.rs index 10aa93d..8407f0a 100644 --- a/bots/rusher/src/main.rs +++ b/bots/rusher/src/main.rs @@ -9,7 +9,8 @@ mod strategy; use axum::{ extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, routing::{get, post}, Json, Router, }; @@ -65,7 +66,7 @@ async fn handle_turn( State(state): State>>, headers: HeaderMap, body: String, -) -> Result, StatusCode> { +) -> Result { // Extract auth headers let match_id = headers .get("X-ACB-Match-Id") @@ -107,9 +108,12 @@ async fn handle_turn( // Sign response let response_body = serde_json::to_string(&response).unwrap(); - let _response_sig = sign_response(&state.secret, match_id, turn, &response_body); + let response_sig = sign_response(&state.secret, match_id, turn, &response_body); - Ok(Json(response)) + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("X-ACB-Signature", HeaderValue::from_str(&response_sig).unwrap()); + + Ok((resp_headers, Json(response))) } /// Handle health check requests diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index d58d19c..1ac458b 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -84,6 +84,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/predict", predMW(http.HandlerFunc(s.handlePredict)).ServeHTTP) mux.HandleFunc("GET /api/predictions/open", s.handleOpenPredictions) mux.HandleFunc("GET /api/predictions/history", s.handlePredictionHistory) + + // Map voting — 10/hour per IP (§14.6) + mapVoteMW := s.voteLtr.Middleware(ipKey, func() { + metrics.RateLimitHits.WithLabelValues("map_vote").Inc() + }) + mux.HandleFunc("POST /api/vote/map", mapVoteMW(http.HandlerFunc(s.handleMapVote)).ServeHTTP) + mux.HandleFunc("GET /api/vote/map/", s.handleGetMapVotes) } // ipKey extracts the client IP from the request for rate limiting. @@ -1528,6 +1535,142 @@ func (s *Server) authenticateWorker(r *http.Request) bool { return parts[1] == s.cfg.WorkerAPIKey } +// handleMapVote handles POST /api/vote/map (§14.6) +// Accepts {map_id, voter_id, vote: +1|-1}, enforces UNIQUE(map_id, voter_id), +// and returns the current net vote count for the map. +func (s *Server) handleMapVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + MapID string `json:"map_id"` + VoterID string `json:"voter_id"` + Vote int `json:"vote"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.MapID == "" || req.VoterID == "" { + writeError(w, http.StatusBadRequest, "map_id and voter_id are required") + return + } + if req.Vote != 1 && req.Vote != -1 { + writeError(w, http.StatusBadRequest, "vote must be +1 or -1") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if s.db == nil { + writeError(w, http.StatusInternalServerError, "database not configured") + return + } + + // Verify the map exists + var exists bool + err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM maps WHERE map_id = $1)`, req.MapID).Scan(&exists) + if err != nil { + log.Printf("[MAPVOTE] db error checking map %s: %v", req.MapID, err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + if !exists { + writeError(w, http.StatusNotFound, "map not found") + return + } + + // Insert or update the vote (UNIQUE constraint on map_id + voter_id) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO map_votes (map_id, voter_id, vote) + VALUES ($1, $2, $3) + ON CONFLICT (map_id, voter_id) DO UPDATE SET vote = $3 + `, req.MapID, req.VoterID, req.Vote) + if err != nil { + log.Printf("[MAPVOTE] insert failed: map=%s voter=%s: %v", req.MapID, req.VoterID, err) + writeError(w, http.StatusInternalServerError, "failed to record vote") + return + } + + // Get net vote count + var netVotes int + err = s.db.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(vote), 0) FROM map_votes WHERE map_id = $1 + `, req.MapID).Scan(&netVotes) + if err != nil { + log.Printf("[MAPVOTE] sum failed for map %s: %v", req.MapID, err) + netVotes = 0 + } + + log.Printf("[MAPVOTE] recorded: map=%s voter=%s vote=%d net=%d", req.MapID, req.VoterID, req.Vote, netVotes) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "map_id": req.MapID, + "vote": req.Vote, + "net_votes": netVotes, + }) +} + +// handleGetMapVotes handles GET /api/vote/map/{map_id} +// Returns the net vote count and, if voter_id is provided, the caller's existing vote. +func (s *Server) handleGetMapVotes(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract map_id from path: /api/vote/map/{map_id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/vote/map/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + writeError(w, http.StatusBadRequest, "invalid map ID") + return + } + mapID := pathParts[0] + + voterID := r.URL.Query().Get("voter_id") + + if s.db == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "map_id": mapID, + "net_votes": 0, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + var netVotes int + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(vote), 0) FROM map_votes WHERE map_id = $1 + `, mapID).Scan(&netVotes) + if err != nil { + log.Printf("[MAPVOTE] sum query failed for map %s: %v", mapID, err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + resp := map[string]interface{}{ + "map_id": mapID, + "net_votes": netVotes, + } + + if voterID != "" { + var myVote int + err := s.db.QueryRowContext(ctx, ` + SELECT vote FROM map_votes WHERE map_id = $1 AND voter_id = $2 + `, mapID, voterID).Scan(&myVote) + if err == nil { + resp["my_vote"] = myVote + } + } + + writeJSON(w, http.StatusOK, resp) +} + func getEnv(key, defaultValue string) string { if v := os.Getenv(key); v != "" { return v diff --git a/cmd/acb-evolver/internal/promoter/promoter.go b/cmd/acb-evolver/internal/promoter/promoter.go index f394cc9..5a187ae 100644 --- a/cmd/acb-evolver/internal/promoter/promoter.go +++ b/cmd/acb-evolver/internal/promoter/promoter.go @@ -111,7 +111,7 @@ func DefaultConfig() Config { KubectlServer: "http://kubectl-ardenone-cluster:8001", Namespace: "ai-code-battle", DeployWaitTimeout: 10 * time.Minute, - RatingThreshold: 1000.0, + RatingThreshold: 800.0, PopCap: 50, ArgoWorkflowServer: "https://argo-ci.ardenone.com", ArgoWorkflowNamespace: "argo-workflows", @@ -143,7 +143,7 @@ type PromotionResult struct { // Promote deploys a validated candidate as a live evolved bot. func (p *Promoter) Promote(ctx context.Context, program *db.Program) (*PromotionResult, error) { botName := fmt.Sprintf("acb-evo-%d", program.ID) - image := fmt.Sprintf("%s/%s:latest", p.cfg.Registry, botName) + _ = fmt.Sprintf("%s/%s:latest", p.cfg.Registry, botName) // image ref built by Argo workflow endpoint := fmt.Sprintf("http://%s:%d", botName, botPort) botID, err := generateBotID() @@ -247,11 +247,23 @@ type RetiredCandidate struct { Reason string } -// EnforcePolicy auto-retires evolved bots below cfg.RatingThreshold and trims -// the active fleet to cfg.PopCap. The slice is ordered lowest-rated first so -// the weakest bots are retired first when enforcing the cap. -// Returns the list of bots that were retired. +// EnforcePolicy auto-retires evolved bots that meet any of these criteria: +// 1. Display rating below cfg.RatingThreshold (bottom 10%) +// 2. 7 consecutive days below rating threshold (per rating_history) +// 3. Population cap exceeded (cfg.PopCap) +// The slice is ordered lowest-rated first so the weakest bots are retired +// first when enforcing the cap. func (p *Promoter) EnforcePolicy(ctx context.Context) ([]RetiredCandidate, error) { + + // First, get bots with 7 consecutive days below threshold + consecutiveBotIDs, err := p.queryConsecutiveLowRating(ctx) + if err != nil { + return nil, fmt.Errorf("query consecutive low rating: %w", err) + } + consecutiveSet := make(map[string]bool) + for _, botID := range consecutiveBotIDs { + consecutiveSet[botID] = true + } rows, err := p.rawDB.QueryContext(ctx, ` SELECT p.id, p.bot_id, COALESCE(p.bot_name, ''), b.rating_mu - 2*b.rating_phi AS display_rating @@ -290,7 +302,10 @@ func (p *Promoter) EnforcePolicy(ctx context.Context) ([]RetiredCandidate, error var toRetire []RetiredCandidate for _, b := range bots { var reason string - if b.displayRating < p.cfg.RatingThreshold { + if consecutiveSet[b.botID] { + reason = fmt.Sprintf("7 consecutive days below rating %.0f", + p.cfg.RatingThreshold) + } else if b.displayRating < p.cfg.RatingThreshold { reason = fmt.Sprintf("display rating %.0f < threshold %.0f", b.displayRating, p.cfg.RatingThreshold) } else if remaining > p.cfg.PopCap { @@ -941,3 +956,56 @@ func truncate(s string, max int) string { } return s[:max] + "…" } + +func init() { + // register queryConsecutiveLowRating for retirement automation +} + +// queryConsecutiveLowRating returns bot_ids that have been below the rating +// threshold for 7 consecutive days. Uses rating_history to track daily ratings. +func (p *Promoter) queryConsecutiveLowRating(ctx context.Context) ([]string, error) { + // Get the latest rating for each day (using DISTINCT ON for per-day records) + // then check for 7 consecutive days all below threshold. + query := ` + WITH daily_ratings AS ( + SELECT DISTINCT + bot_id, + DATE(recorded_at) AS rating_date, + rating + FROM rating_history + WHERE DATE(recorded_at) >= CURRENT_DATE - INTERVAL '14 days' + ), + consecutive_counts AS ( + SELECT + bot_id, + rating_date, + rating, + // Count consecutive days below threshold ending at this date + SUM(CASE WHEN rating < $1 THEN 1 ELSE 0 END) OVER ( + PARTITION BY bot_id + ORDER BY rating_date DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS consecutive_below + FROM daily_ratings + ) + SELECT DISTINCT bot_id + FROM consecutive_counts + WHERE consecutive_below >= 7 + ` + + rows, err := p.rawDB.QueryContext(ctx, query, p.cfg.RatingThreshold) + if err != nil { + return nil, fmt.Errorf("query consecutive low rating: %w", err) + } + defer rows.Close() + + var botIDs []string + for rows.Next() { + var botID string + if err := rows.Scan(&botID); err != nil { + return nil, fmt.Errorf("scan bot_id: %w", err) + } + botIDs = append(botIDs, botID) + } + return botIDs, rows.Err() +} diff --git a/cmd/acb-evolver/run.go b/cmd/acb-evolver/run.go index da743b4..49f4b2d 100644 --- a/cmd/acb-evolver/run.go +++ b/cmd/acb-evolver/run.go @@ -64,6 +64,7 @@ type RunConfig struct { // Timing CycleInterval time.Duration // delay between cycles (0 = continuous) IslandCooldown time.Duration // min time between same-island evolutions + RetirementCheckInterval time.Duration // interval between periodic retirement checks // Infrastructure LLMURL string @@ -75,6 +76,10 @@ type RunConfig struct { LiveExportPath string UploadR2 bool + // Declarative config for K8s manifests (§10.8) + DeclarativeConfigRepo string // git repo URL for K8s manifests + DeclarativeConfigBranch string // git branch for K8s manifests + // Languages to evolve (in priority order) Languages []string } @@ -88,9 +93,10 @@ func DefaultRunConfig() RunConfig { TopBotLimit: 10, NashThreshold: 0.50, WinRateLowerBound: 0.40, - RatingThreshold: 1000.0, + RatingThreshold: 800.0, PopCap: 50, - CycleInterval: 5 * time.Minute, + CycleInterval: 5 * time.Minute, + RetirementCheckInterval: 1 * time.Hour, IslandCooldown: 2 * time.Minute, LLMURL: envOrDefault("ACB_LLM_URL", "http://zai-proxy-apexalgo.tail1b1987.ts.net:8080"), RepoDir: envOrDefault("ACB_REPO_DIR", "."), @@ -187,13 +193,18 @@ func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) { cancel() }() + // Start periodic retirement ticker (§10.8) + if cfg.RetirementCheckInterval > 0 { + go startRetirementTicker(ctx, db, store, cfg, &stats, *verbose) + } + langIdx := 0 islandIdx := 0 log.Printf("Evolution loop starting (continuous=%v, dry-run=%v)", *continuous, *dryRun) if *verbose { - log.Printf("Config: nash=%.2f, win-lower=%.2f, max-retries=%d, languages=%v", - cfg.NashThreshold, cfg.WinRateLowerBound, cfg.MaxRetries, cfg.Languages) + log.Printf("Config: nash=%.2f, win-lower=%.2f, max-retries=%d, languages=%v, retirement-check=%v", + cfg.NashThreshold, cfg.WinRateLowerBound, cfg.MaxRetries, cfg.Languages, cfg.RetirementCheckInterval) } for { @@ -728,6 +739,49 @@ func exportLive(ctx context.Context, db *sql.DB, cfg RunConfig, verbose bool) { } } +// startRetirementTicker runs periodic retirement checks (§10.8). +// This enforces the 7-day low-rating rule and 50-bot population cap. +func startRetirementTicker(ctx context.Context, db *sql.DB, store *evolverdb.Store, cfg RunConfig, stats *RunStats, verbose bool) { + log.Printf("starting retirement ticker (every %s)", cfg.RetirementCheckInterval) + ticker := time.NewTicker(cfg.RetirementCheckInterval) + defer ticker.Stop() + + promCfg := promoter.DefaultConfig() + promCfg.Registry = cfg.Registry + promCfg.RepoDir = cfg.RepoDir + promCfg.KubectlServer = cfg.KubectlServer + promCfg.EncryptionKey = cfg.EncryptionKey + promCfg.RatingThreshold = cfg.RatingThreshold + promCfg.PopCap = cfg.PopCap + promCfg.DeclarativeConfigRepo = cfg.DeclarativeConfigRepo + promCfg.DeclarativeConfigBranch = cfg.DeclarativeConfigBranch + + p := promoter.New(store, db, promCfg) + + for { + select { + case <-ctx.Done(): + log.Printf("stopping retirement ticker") + return + case <-ticker.C: + retired, err := p.EnforcePolicy(ctx) + if err != nil { + log.Printf("retirement ticker error: %v", err) + continue + } + if len(retired) > 0 { + stats.Retired += len(retired) + for _, r := range retired { + if verbose { + log.Printf(" Retired %s (rating %.0f): %s", r.BotID, r.DisplayRating, r.Reason) + } + } + log.Printf("retirement ticker: retired %d bot(s)", len(retired)) + } + } + } +} + // printStats displays evolution loop statistics. func printStats(stats *RunStats) { elapsed := time.Since(stats.StartTime) diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index 23e9ba9..733e620 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -112,7 +112,7 @@ func generateAllIndexes(data *IndexData, outputDir string, db *sql.DB, cfg *Conf } // Generate individual bot profiles - if err := generateBotProfiles(data, outputDir); err != nil { + if err := generateBotProfiles(data, outputDir, cfg); err != nil { return fmt.Errorf("bot profiles: %w", err) } @@ -224,7 +224,7 @@ func generateBotDirectory(data *IndexData, outputDir string) error { return writeJSON(filepath.Join(outputDir, "data", "bots", "index.json"), dir) } -func generateBotProfiles(data *IndexData, outputDir string) error { +func generateBotProfiles(data *IndexData, outputDir string, cfg *Config) error { botsDir := filepath.Join(outputDir, "data", "bots") for _, bot := range data.Bots { @@ -252,7 +252,7 @@ func generateBotProfiles(data *IndexData, outputDir string) error { } } if participated { - summary := matchToSummary(m, data) + summary := matchToSummary(m, data, cfg) recentMatches = append(recentMatches, summary) if len(recentMatches) >= 20 { break diff --git a/cmd/acb-index-builder/main.go b/cmd/acb-index-builder/main.go index 4c3edbb..35b6d66 100644 --- a/cmd/acb-index-builder/main.go +++ b/cmd/acb-index-builder/main.go @@ -165,7 +165,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error { } // Generate all index files - if err := generateAllIndexes(data, cfg.OutputDir, db); err != nil { + if err := generateAllIndexes(data, cfg.OutputDir, db, cfg); err != nil { return fmt.Errorf("generate indexes: %w", err) } diff --git a/cmd/acb-index-builder/main_test.go b/cmd/acb-index-builder/main_test.go index 7acd094..3c7b3c4 100644 --- a/cmd/acb-index-builder/main_test.go +++ b/cmd/acb-index-builder/main_test.go @@ -217,7 +217,7 @@ func TestGenerateMatchIndex(t *testing.T) { } botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"} - if err := generateMatchIndex(data, tmpDir, botNameMap); err != nil { + if err := generateMatchIndex(data, tmpDir, botNameMap, &Config{}); err != nil { t.Fatalf("generateMatchIndex failed: %v", err) } diff --git a/cmd/acb-index-builder/narrative.go b/cmd/acb-index-builder/narrative.go index 2d02b0d..6cbc7bb 100644 --- a/cmd/acb-index-builder/narrative.go +++ b/cmd/acb-index-builder/narrative.go @@ -246,8 +246,8 @@ func buildNarrativePrompt(req NarrativeRequest) string { sb.WriteString(fmt.Sprintf("Arc type: Rivalry Intensifies\n")) sb.WriteString(fmt.Sprintf("Bots: %s vs %s\n", req.BotName, req.BotBName)) sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) - sb.WriteString(fmt.Sprintf("Head-to-head record this week: %s %d - %s %d (%d total matches)\n", - req.BotName, req.BotAWins, req.BotBName, req.BotBWins, req.TotalMatches)) + sb.WriteString(fmt.Sprintf("Head-to-head record this week: %d-%d %s vs %s (%d total matches)\n", + req.BotAWins, req.BotBWins, req.BotName, req.BotBName, req.TotalMatches)) if len(req.KeyMatches) > 0 { sb.WriteString("Recent encounters (turning points):\n") for _, m := range req.KeyMatches { diff --git a/cmd/acb-index-builder/s3_test.go b/cmd/acb-index-builder/s3_test.go index 24c8a4e..02c1d88 100644 --- a/cmd/acb-index-builder/s3_test.go +++ b/cmd/acb-index-builder/s3_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "sort" "testing" "time" ) @@ -56,6 +57,9 @@ func (m *MockS3Client) listObjects(ctx context.Context, prefix string) ([]R2Obje }) } } + sort.Slice(objects, func(i, j int) bool { + return objects[i].LastModified.Before(objects[j].LastModified) + }) return objects, nil } diff --git a/cmd/acb-matchmaker/main.go b/cmd/acb-matchmaker/main.go index 5576baf..e3e699d 100644 --- a/cmd/acb-matchmaker/main.go +++ b/cmd/acb-matchmaker/main.go @@ -30,6 +30,7 @@ type Config struct { ReaperSecs int SeriesSchedSecs int SeasonResetSecs int + FairnessAuditSecs int BotTimeoutSecs int StaleJobMinutes int MaxConsecFails int @@ -56,6 +57,7 @@ func loadConfig() Config { ReaperSecs: envInt("ACB_REAPER_INTERVAL", 300), SeriesSchedSecs: envInt("ACB_SERIES_SCHED_INTERVAL", 120), SeasonResetSecs: envInt("ACB_SEASON_RESET_INTERVAL", 300), + FairnessAuditSecs: envInt("ACB_FAIRNESS_AUDIT_INTERVAL", 3600), BotTimeoutSecs: envInt("ACB_BOT_TIMEOUT", 5), StaleJobMinutes: envInt("ACB_STALE_JOB_MINUTES", 15), MaxConsecFails: envInt("ACB_MAX_CONSEC_FAILS", 3), diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index 8d9eef9..b024e58 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -479,7 +479,7 @@ func (m *Matchmaker) selectSeriesMap(ctx context.Context, gameNum int, rng *rand query := fmt.Sprintf(` SELECT map_id, grid_width, grid_height FROM maps - WHERE player_count = 2 AND status = 'active' + WHERE player_count = 2 AND status IN ('active', 'classic') ORDER BY %s LIMIT 1 `, orderBy) diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index fe27561..34b27fd 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -35,6 +35,7 @@ func (m *Matchmaker) StartTickers(ctx context.Context) { go m.runTicker(ctx, "stale-reaper", time.Duration(m.cfg.ReaperSecs)*time.Second, m.tickStaleReaper) go m.runTicker(ctx, "series-scheduler", time.Duration(m.cfg.SeriesSchedSecs)*time.Second, m.tickSeriesScheduler) go m.runTicker(ctx, "season-reset", time.Duration(m.cfg.SeasonResetSecs)*time.Second, m.tickSeasonReset) + go m.runTicker(ctx, "fairness-audit", time.Duration(m.cfg.FairnessAuditSecs)*time.Second, m.tickFairnessAudit) } func (m *Matchmaker) runTicker(ctx context.Context, name string, interval time.Duration, fn func(context.Context)) { @@ -306,25 +307,44 @@ func bestCandidate(pool []candidateBot) candidateBot { return best } -// selectMapLRU returns the active map for playerCount with the oldest last_used_at. -// Falls back to a random procedural seed if no maps exist for that player count. +// selectMapLRU returns a map for playerCount with LRU priority. +// Active maps are preferred; probation maps are included with 50% reduced +// selection probability. Retired maps are excluded. Classic maps are always eligible. func (m *Matchmaker) selectMapLRU(ctx context.Context, playerCount int, rng *rand.Rand) (string, int, int, int64) { + // Try active+classic maps first (full probability). var mapID string var gridH, gridW int err := m.db.QueryRowContext(ctx, ` SELECT mp.map_id, mp.grid_height, mp.grid_width FROM maps mp LEFT JOIN map_scores ms ON ms.map_id = mp.map_id - WHERE mp.player_count = $1 AND mp.status = 'active' + WHERE mp.player_count = $1 AND mp.status IN ('active', 'classic') ORDER BY COALESCE(ms.last_used_at, '1970-01-01 00:00:00+00'::timestamptz) ASC LIMIT 1 `, playerCount).Scan(&mapID, &gridH, &gridW) - if err != nil { - seed := rng.Int63() - rows, cols := gridForPlayers(playerCount) - return fmt.Sprintf("map_%d", seed%100000), rows, cols, seed + if err == nil { + return mapID, gridH, gridW, rng.Int63() } - return mapID, gridH, gridW, rng.Int63() + + // No active/classic maps — try probation maps with reduced probability (50% skip chance). + if rng.Float64() < 0.5 { + err = m.db.QueryRowContext(ctx, ` + SELECT mp.map_id, mp.grid_height, mp.grid_width + FROM maps mp + LEFT JOIN map_scores ms ON ms.map_id = mp.map_id + WHERE mp.player_count = $1 AND mp.status = 'probation' + ORDER BY COALESCE(ms.last_used_at, '1970-01-01 00:00:00+00'::timestamptz) ASC + LIMIT 1 + `, playerCount).Scan(&mapID, &gridH, &gridW) + if err == nil { + return mapID, gridH, gridW, rng.Int63() + } + } + + // No maps at all — generate from seed. + seed := rng.Int63() + rows, cols := gridForPlayers(playerCount) + return fmt.Sprintf("map_%d", seed%100000), rows, cols, seed } // gridForPlayers returns default grid dimensions for a given player count, diff --git a/engine/thumbnail.go b/engine/thumbnail.go index 7ae5da1..04468c7 100644 --- a/engine/thumbnail.go +++ b/engine/thumbnail.go @@ -5,7 +5,6 @@ import ( "image/color" "image/png" "io" - "math" ) // ThumbnailConfig configures thumbnail rendering. @@ -89,7 +88,12 @@ func RenderMidGameThumbnail(replay *Replay, w io.Writer) error { if turnNum >= len(replay.Turns) { turnNum = len(replay.Turns) - 1 } - return RenderThumbnailPNG(replay, turnNum, DefaultThumbnailConfig()) + cfg := DefaultThumbnailConfig() + img, err := RenderThumbnail(replay, turnNum, cfg) + if err != nil { + return err + } + return png.Encode(w, img) } func drawBackground(img *image.RGBA, c color.Color) { @@ -181,8 +185,8 @@ func drawCores(img *image.RGBA, cores []ReplayCoreState, cols int, cellW, cellH func drawEnergy(img *image.RGBA, energy []Position, cols int, cellW, cellH float64, c color.Color) { for _, e := range energy { - cx := int(float64(e.Col+0.5) * cellW) - cy := int(float64(e.Row+0.5) * cellH) + cx := int((float64(e.Col) + 0.5) * cellW) + cy := int((float64(e.Row) + 0.5) * cellH) r := int(cellW * 0.3) drawDiamond(img, cx, cy, r, c) @@ -221,13 +225,6 @@ func drawRectOutline(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) { } } -func abs(x int) int { - if x < 0 { - return -x - } - return x -} - // SelectThumbnailTurn selects the most interesting turn for thumbnail generation. // Prioritizes: end game > mid game with many bots > late game. func SelectThumbnailTurn(replay *Replay) int { diff --git a/starters/javascript/index.js b/starters/javascript/index.js index 3d01bb6..c69d89a 100644 --- a/starters/javascript/index.js +++ b/starters/javascript/index.js @@ -134,6 +134,14 @@ const server = http.createServer((req, res) => { return; } + if (state.turn === 0) { + const seasonId = state.config.season_id || ""; + const rulesVersion = state.config.rules_version || ""; + console.log( + `match=${state.match_id} season_id=${seasonId} rules_version=${rulesVersion} rows=${state.config.rows} cols=${state.config.cols}` + ); + } + const moves = computeMoves(state); const responseBody = JSON.stringify({ moves }); const responseSig = signResponse( diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 622be6c..b4ee775 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -41,6 +41,7 @@ export interface MatchSummary { winner_id: string | null; turns: number | null; end_reason: string | null; + enriched?: boolean; } export interface BotProfile { @@ -556,3 +557,47 @@ export async function fetchRivalries(): Promise { return response.json(); }); } + +// Map voting types (§14.6) + +export interface MapVoteResponse { + map_id: string; + vote: number; + net_votes: number; +} + +export interface MapVotesResponse { + map_id: string; + net_votes: number; + my_vote?: number; +} + +export function getOrCreateVoterId(): string { + let id = localStorage.getItem('acb_voter_id'); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem('acb_voter_id', id); + } + return id; +} + +export async function submitMapVote(mapId: string, vote: 1 | -1): Promise { + const voterId = getOrCreateVoterId(); + const response = await fetch(`${API_BASE}/vote/map`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ map_id: mapId, voter_id: voterId, vote }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error || `Failed to submit vote: ${response.status}`); + } + return response.json(); +} + +export async function fetchMapVotes(mapId: string): Promise { + const voterId = getOrCreateVoterId(); + const response = await fetch(`${API_BASE}/vote/map/${encodeURIComponent(mapId)}?voter_id=${encodeURIComponent(voterId)}`); + if (!response.ok) throw new Error(`Failed to fetch map votes: ${response.status}`); + return response.json(); +} diff --git a/web/src/pages/clip-maker.ts b/web/src/pages/clip-maker.ts index 7d22ad7..8ada524 100644 --- a/web/src/pages/clip-maker.ts +++ b/web/src/pages/clip-maker.ts @@ -104,6 +104,20 @@ function buildHTML(): string {
0% + @@ -278,15 +292,20 @@ function initClipMaker(): void { } }); + let lastExportBlob: Blob | null = null; + let lastExportExt = ''; + // ── MP4 export ──────────────────────────────────────────────────────────── document.getElementById('clip-export-mp4')!.addEventListener('click', async () => { if (!replay) return; + lastExportBlob = null; await exportVideo(replay, 'mp4'); }); // ── GIF export ──────────────────────────────────────────────────────────── document.getElementById('clip-export-gif')!.addEventListener('click', async () => { if (!replay) return; + lastExportBlob = null; await exportGIF(replay); }); @@ -341,7 +360,11 @@ function initClipMaker(): void { hideProgress(); const blob = new Blob(chunks, { type: mimeType }); - downloadBlob(blob, `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.webm`); + const filename = `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.webm`; + lastExportBlob = blob; + lastExportExt = 'webm'; + downloadBlob(blob, filename); + showSharePanel(r, startTurn, endTurn, blob); } async function exportGIF(r: Replay): Promise { @@ -381,7 +404,12 @@ function initClipMaker(): void { hideProgress(); const gif = encoder.encode(); - downloadBlob(new Blob([gif.buffer as ArrayBuffer], { type: 'image/gif' }), `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.gif`); + const blob = new Blob([gif.buffer as ArrayBuffer], { type: 'image/gif' }); + const filename = `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.gif`; + lastExportBlob = blob; + lastExportExt = 'gif'; + downloadBlob(blob, filename); + showSharePanel(r, startTurn, endTurn, blob); } function showProgress(pct: number): void { @@ -402,6 +430,119 @@ function initClipMaker(): void { (document.getElementById('clip-progress-fill') as HTMLElement).style.width = `${pct.toFixed(0)}%`; (document.getElementById('clip-progress-label') as HTMLElement).textContent = `${pct.toFixed(0)}%`; } + + // ── Share panel ────────────────────────────────────────────────────────── + + function generateShareText(r: Replay): string { + const names = r.players.map(p => p.name); + const scores = r.result.scores; + const winnerName = r.players[r.result.winner]?.name ?? 'Unknown'; + const loserIdx = r.players.findIndex((_, i) => i !== r.result.winner); + const loserName = loserIdx >= 0 ? r.players[loserIdx].name : ''; + + if (names.length === 2) { + return `${winnerName} defeats ${loserName} ${scores[0]}-${scores[1]} on AI Code Battle!`; + } + return `${winnerName} wins! ${names.map((n, i) => `${n}: ${scores[i]}`).join(', ')}`; + } + + function replayURL(matchId: string, startTurn: number, endTurn: number): string { + return `https://aicodebattle.com/replay/${matchId}#turns=${startTurn}-${endTurn}`; + } + + function showSharePanel(r: Replay, startTurn: number, endTurn: number, blob: Blob): void { + const panel = document.getElementById('clip-share-panel')!; + const textEl = document.getElementById('clip-share-text')!; + const nativeEl = document.getElementById('clip-share-native')!; + + const text = generateShareText(r); + const url = replayURL(r.match_id, startTurn, endTurn); + textEl.textContent = `${text} ${url}`; + + // Web Share API availability + const file = new File([blob], `acb-clip-${r.match_id}.${lastExportExt}`, { type: blob.type }); + const canShareFiles = 'canShare' in navigator && navigator.canShare({ files: [file] }); + if (canShareFiles || 'share' in navigator) { + nativeEl.classList.remove('hidden'); + } else { + nativeEl.classList.add('hidden'); + } + + // Wire share buttons + const twitterBtn = document.getElementById('share-twitter-btn')!; + const redditBtn = document.getElementById('share-reddit-btn')!; + const discordBtn = document.getElementById('share-discord-btn')!; + const copyBtn = document.getElementById('share-copy-btn')!; + const nativeBtn = document.getElementById('share-native-btn')!; + + // Clone and replace to remove old listeners + const newTwitter = twitterBtn.cloneNode(true) as HTMLElement; + const newReddit = redditBtn.cloneNode(true) as HTMLElement; + const newDiscord = discordBtn.cloneNode(true) as HTMLElement; + const newCopy = copyBtn.cloneNode(true) as HTMLElement; + const newNative = nativeBtn.cloneNode(true) as HTMLElement; + twitterBtn.replaceWith(newTwitter); + redditBtn.replaceWith(newReddit); + discordBtn.replaceWith(newDiscord); + copyBtn.replaceWith(newCopy); + nativeBtn.replaceWith(newNative); + + newTwitter.addEventListener('click', () => { + const tweetText = encodeURIComponent(`${text} ${url}`); + window.open(`https://twitter.com/intent/tweet?text=${tweetText}`, '_blank', 'noopener'); + }); + + newReddit.addEventListener('click', () => { + const md = `[${text}](${url})`; + copyToClipboard(md); + flashToast('Reddit markdown copied!'); + }); + + newDiscord.addEventListener('click', () => { + downloadBlob(blob, `acb-clip-${r.match_id}.${lastExportExt}`); + copyToClipboard(`${text} ${url}`); + flashToast('File downloaded — link copied for caption!'); + }); + + newCopy.addEventListener('click', () => { + copyToClipboard(`${text} ${url}`); + flashToast('Link copied!'); + }); + + newNative.addEventListener('click', async () => { + try { + const shareData: ShareData = { text: `${text} ${url}`, title: 'AI Code Battle Clip' }; + if (canShareFiles) shareData.files = [file]; + await navigator.share(shareData); + } catch { + // User cancelled or not supported — do nothing + } + }); + + panel.classList.remove('hidden'); + } + + function copyToClipboard(text: string): void { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + } + + function flashToast(msg: string): void { + const toast = document.getElementById('share-toast')!; + toast.textContent = msg; + toast.classList.remove('hidden'); + setTimeout(() => toast.classList.add('hidden'), 2000); + } } // ─── Composite frame renderer ───────────────────────────────────────────────── @@ -772,6 +913,20 @@ const CLIP_STYLES = ` .preview-frame canvas { display: block; max-width: 100%; } .preview-nav { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 12px; } .frame-label { color: var(--text-muted); font-size: 0.875rem; min-width: 80px; text-align: center; } +.clip-share-panel.hidden { display: none; } +.share-preview-text { font-size: 0.8rem; color: var(--text-muted); background: var(--bg-primary); padding: 8px 10px; border-radius: 6px; margin-bottom: 12px; line-height: 1.4; word-break: break-word; } +.share-buttons { display: flex; gap: 8px; flex-wrap: wrap; } +.share-btn { flex: 1; min-width: 70px; font-size: 0.8rem; padding: 8px 6px; } +.share-twitter { background: #1d9bf0; color: #fff; border-color: #1d9bf0; } +.share-twitter:hover { background: #1a8cd8; } +.share-reddit { background: #ff4500; color: #fff; border-color: #ff4500; } +.share-reddit:hover { background: #e03e00; } +.share-discord { background: #5865f2; color: #fff; border-color: #5865f2; } +.share-discord:hover { background: #4752c4; } +.share-copy { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border); } +.share-copy:hover { background: var(--border); } +.share-toast { position: relative; margin-top: 8px; padding: 6px 10px; font-size: 0.8rem; color: var(--success); background: rgba(34,197,94,0.15); border-radius: 4px; text-align: center; } +.share-toast.hidden { display: none; } @media (max-width: 768px) { .clip-layout { flex-direction: column; } .clip-settings-col { width: 100%; } diff --git a/web/src/pages/matches.ts b/web/src/pages/matches.ts index b03ac50..5694748 100644 --- a/web/src/pages/matches.ts +++ b/web/src/pages/matches.ts @@ -290,12 +290,14 @@ function appendRemainingMatches(target: HTMLElement, matches: MatchSummary[]): v function renderMatchCard(match: MatchSummary): string { const completedAt = match.completed_at ? formatTimestamp(match.completed_at) : 'In progress'; + const enrichedBadge = match.enriched ? `Narrated` : ''; return `