feat(config): add season_id + rules_version to Config per §4.2
- SeasonID and RulesVersion already present in engine/types.go Config struct - Worker already populates from active season row via DB join - Config embedded in VisibleState sent to bots each turn (including turn 0) - All starter kits (go, python, rust, java, csharp) already expose and log fields - Add season_id/rules_version logging to JavaScript starter on turn 0 - TypeScript Config interface already includes season_id and rules_version Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1b55d4dc51
commit
6c1f031071
21 changed files with 869 additions and 57 deletions
|
|
@ -1 +1 @@
|
|||
4dd91decad4538636d34df90d01199a74a7e1392
|
||||
1b55d4dc51471a5a7db86269c4f9790aa9aef434
|
||||
|
|
|
|||
|
|
@ -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<Arc<Mutex<BotState>>>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Json<MoveResponse>, StatusCode> {
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Arc<Mutex<BotState>>>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Json<MoveResponse>, StatusCode> {
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Arc<Mutex<BotState>>>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Json<MoveResponse>, StatusCode> {
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<RivalriesIndex> {
|
|||
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<MapVoteResponse> {
|
||||
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<MapVotesResponse> {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,20 @@ function buildHTML(): string {
|
|||
<div class="progress-bar"><div id="clip-progress-fill" class="progress-fill" style="width:0%"></div></div>
|
||||
<span id="clip-progress-label">0%</span>
|
||||
</div>
|
||||
<div id="clip-share-panel" class="clip-share-panel hidden">
|
||||
<div class="panel-header"><span>Share</span></div>
|
||||
<div id="clip-share-text" class="share-preview-text"></div>
|
||||
<div class="share-buttons">
|
||||
<button id="share-twitter-btn" class="btn share-btn share-twitter">𝕏 Post</button>
|
||||
<button id="share-reddit-btn" class="btn share-btn share-reddit">Reddit</button>
|
||||
<button id="share-discord-btn" class="btn share-btn share-discord">Discord</button>
|
||||
<button id="share-copy-btn" class="btn share-btn share-copy">Copy Link</button>
|
||||
</div>
|
||||
<div id="clip-share-native" class="hidden" style="margin-top:8px">
|
||||
<button id="share-native-btn" class="btn primary" style="width:100%">Share via System…</button>
|
||||
</div>
|
||||
<div id="share-toast" class="share-toast hidden">Copied!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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<void> {
|
||||
|
|
@ -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%; }
|
||||
|
|
|
|||
|
|
@ -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 ? `<span class="enriched-badge" title="Narrated replay with AI commentary">Narrated</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="match-card" data-match-id="${escapeHtml(match.id)}">
|
||||
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false" aria-controls="match-details-${escapeHtml(match.id)}">
|
||||
<div class="match-header">
|
||||
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
|
||||
${enrichedBadge}
|
||||
<span class="match-time">${completedAt}</span>
|
||||
<span class="match-expand-icon" aria-hidden="true">▸</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode, EnrichedCommentary } from './types';
|
||||
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode, EnrichedCommentary, TranscriptEntry } from './types';
|
||||
|
||||
// Export TranscriptEntry type for use in other modules
|
||||
export type { TranscriptEntry };
|
||||
|
||||
// ── Particle System (pooled, 100 objects, zero GC) ──────────────────────────────
|
||||
interface Particle {
|
||||
|
|
@ -988,6 +991,307 @@ export class ReplayViewer {
|
|||
return `Turn ${this.currentTurn}: ${descriptions.join(', ')}.`;
|
||||
}
|
||||
|
||||
// ── Transcript Generation (§15.3 Screen Reader Transcript) ──────────────────────
|
||||
|
||||
/**
|
||||
* Generate a detailed turn-by-turn transcript for screen readers.
|
||||
* Returns an array of transcript entries, one per turn.
|
||||
*/
|
||||
generateTranscript(): TranscriptEntry[] {
|
||||
if (!this.replay) return [];
|
||||
|
||||
const transcript: TranscriptEntry[] = [];
|
||||
const { players, win_prob } = this.replay;
|
||||
|
||||
for (let turnIdx = 0; turnIdx < this.replay.turns.length; turnIdx++) {
|
||||
const turn = this.replay.turns[turnIdx];
|
||||
const entry = this.generateTurnTranscript(turnIdx, turn, players, win_prob);
|
||||
transcript.push(entry);
|
||||
}
|
||||
|
||||
return transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a detailed transcript entry for a single turn.
|
||||
*/
|
||||
private generateTurnTranscript(
|
||||
turnIdx: number,
|
||||
turn: ReplayTurn,
|
||||
players: ReplayPlayer[],
|
||||
winProb?: number[][]
|
||||
): TranscriptEntry {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Turn header
|
||||
parts.push(`Turn ${turnIdx}:`);
|
||||
|
||||
// Player moves summary
|
||||
const moveSummaries = this.summarizePlayerMoves(turn, turnIdx, players);
|
||||
if (moveSummaries.length > 0) {
|
||||
parts.push(moveSummaries.join('. '));
|
||||
}
|
||||
|
||||
// Combat events
|
||||
const combatSummary = this.summarizeCombatEvents(turn);
|
||||
if (combatSummary) {
|
||||
parts.push(combatSummary);
|
||||
}
|
||||
|
||||
// Core captures
|
||||
const captureSummary = this.summarizeCoreCaptures(turn);
|
||||
if (captureSummary) {
|
||||
parts.push(captureSummary);
|
||||
}
|
||||
|
||||
// Energy collection
|
||||
const energySummary = this.summarizeEnergyCollection(turn, players);
|
||||
if (energySummary) {
|
||||
parts.push(energySummary);
|
||||
}
|
||||
|
||||
// Bot spawns
|
||||
const spawnSummary = this.summarizeBotSpawns(turn, players);
|
||||
if (spawnSummary) {
|
||||
parts.push(spawnSummary);
|
||||
}
|
||||
|
||||
// Win probability
|
||||
if (winProb && winProb[turnIdx]) {
|
||||
const probs = winProb[turnIdx];
|
||||
const probSummary = probs.map((p, i) => `${players[i].name} ${Math.round(p * 100)}%`).join(', ');
|
||||
parts.push(`Win probability: ${probSummary}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
turn: turnIdx,
|
||||
text: parts.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize player movements for a turn.
|
||||
* Returns array of strings like "Player 1 (SwarmBot) moved 5 bots east."
|
||||
*/
|
||||
private summarizePlayerMoves(turn: ReplayTurn, turnIdx: number, players: ReplayPlayer[]): string[] {
|
||||
const movesByPlayer: Map<number, { byDirection: Map<string, number>, total: number }> = new Map();
|
||||
|
||||
// Initialize for all players
|
||||
players.forEach((_, idx) => {
|
||||
movesByPlayer.set(idx, { byDirection: new Map(), total: 0 });
|
||||
});
|
||||
|
||||
// Count moves by direction per player
|
||||
// We need to compare with previous turn to detect movements
|
||||
if (turnIdx > 0) {
|
||||
const prevTurn = this.replay!.turns[turnIdx - 1];
|
||||
const prevBots = new Map(prevTurn.bots.map(b => [b.id, b]));
|
||||
|
||||
for (const bot of turn.bots) {
|
||||
if (!bot.alive) continue;
|
||||
const prevBot = prevBots.get(bot.id);
|
||||
if (!prevBot || !prevBot.alive) continue;
|
||||
|
||||
const dr = bot.position.row - prevBot.position.row;
|
||||
const dc = bot.position.col - prevBot.position.col;
|
||||
|
||||
// Handle toroidal wrapping
|
||||
const rows = this.replay!.map.rows;
|
||||
const cols = this.replay!.map.cols;
|
||||
if (Math.abs(dr) > rows / 2) {
|
||||
// Wrapped vertically
|
||||
}
|
||||
if (Math.abs(dc) > cols / 2) {
|
||||
// Wrapped horizontally
|
||||
}
|
||||
|
||||
let direction: string | null = null;
|
||||
if (dr === -1 && dc === 0) direction = 'north';
|
||||
else if (dr === 1 && dc === 0) direction = 'south';
|
||||
else if (dr === 0 && dc === 1) direction = 'east';
|
||||
else if (dr === 0 && dc === -1) direction = 'west';
|
||||
|
||||
if (direction) {
|
||||
const playerMoves = movesByPlayer.get(bot.owner)!;
|
||||
const count = playerMoves.byDirection.get(direction) ?? 0;
|
||||
playerMoves.byDirection.set(direction, count + 1);
|
||||
playerMoves.total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: string[] = [];
|
||||
for (const [playerIdx, moves] of movesByPlayer) {
|
||||
if (moves.total === 0) continue;
|
||||
|
||||
const directionParts: string[] = [];
|
||||
const dirNames: Record<string, string> = {
|
||||
north: 'north',
|
||||
south: 'south',
|
||||
east: 'east',
|
||||
west: 'west',
|
||||
};
|
||||
|
||||
for (const [dir, count] of moves.byDirection) {
|
||||
directionParts.push(`${count} ${dirNames[dir]}`);
|
||||
}
|
||||
|
||||
const playerName = players[playerIdx].name;
|
||||
summaries.push(`${playerName} moved ${directionParts.join(', ')}.`);
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize combat events (bot deaths) for a turn.
|
||||
*/
|
||||
private summarizeCombatEvents(turn: ReplayTurn): string | null {
|
||||
const events = turn.events ?? [];
|
||||
const deathEvents = events.filter(e => e.type === 'bot_died');
|
||||
|
||||
if (deathEvents.length === 0) return null;
|
||||
|
||||
// Group deaths by position (combat at same location)
|
||||
const deathsByPosition = new Map<string, Array<{ owner: number; count: number }>>();
|
||||
|
||||
for (const event of deathEvents) {
|
||||
const details = event.details as Record<string, unknown>;
|
||||
const pos = details.position as Position | undefined;
|
||||
const owner = details.owner as number ?? 0;
|
||||
|
||||
if (!pos) continue;
|
||||
|
||||
const key = `${pos.row},${pos.col}`;
|
||||
if (!deathsByPosition.has(key)) {
|
||||
deathsByPosition.set(key, []);
|
||||
}
|
||||
|
||||
// Check if this owner already has deaths at this position
|
||||
const existing = deathsByPosition.get(key)!.find(d => d.owner === owner);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
deathsByPosition.get(key)!.push({ owner, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const combatParts: string[] = [];
|
||||
for (const [posKey, deaths] of deathsByPosition) {
|
||||
const [row, col] = posKey.split(',').map(Number);
|
||||
const deathDescriptions = deaths.map(d => {
|
||||
const playerName = this.replay!.players[d.owner].name;
|
||||
return `${d.count} ${playerName} unit${d.count > 1 ? 's' : ''}`;
|
||||
}).join(', ');
|
||||
|
||||
combatParts.push(`Combat at (${row},${col}): ${deathDescriptions} killed.`);
|
||||
}
|
||||
|
||||
return combatParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize core captures for a turn.
|
||||
*/
|
||||
private summarizeCoreCaptures(turn: ReplayTurn): string | null {
|
||||
const events = turn.events ?? [];
|
||||
const captureEvents = events.filter(e => e.type === 'core_captured');
|
||||
|
||||
if (captureEvents.length === 0) return null;
|
||||
|
||||
const captures = captureEvents.map(e => {
|
||||
const details = e.details as Record<string, unknown>;
|
||||
const oldOwner = details.old_owner as number ?? 0;
|
||||
const newOwner = details.new_owner as number ?? 0;
|
||||
const pos = details.position as Position | undefined;
|
||||
|
||||
const oldPlayerName = this.replay!.players[oldOwner].name;
|
||||
const newPlayerName = this.replay!.players[newOwner].name;
|
||||
const posStr = pos ? ` at (${pos.row},${pos.col})` : '';
|
||||
|
||||
return `${newPlayerName} captured ${oldPlayerName}'s core${posStr}.`;
|
||||
});
|
||||
|
||||
return captures.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize energy collection for a turn.
|
||||
*/
|
||||
private summarizeEnergyCollection(turn: ReplayTurn, players: ReplayPlayer[]): string | null {
|
||||
const events = turn.events ?? [];
|
||||
const energyEvents = events.filter(e => e.type === 'energy_collected');
|
||||
|
||||
if (energyEvents.length === 0) return null;
|
||||
|
||||
// Group by player
|
||||
const energyByPlayer = new Map<number, number>();
|
||||
for (const event of energyEvents) {
|
||||
const details = event.details as Record<string, unknown>;
|
||||
const owner = details.owner as number ?? 0;
|
||||
const count = energyByPlayer.get(owner) ?? 0;
|
||||
energyByPlayer.set(owner, count + 1);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [playerIdx, count] of energyByPlayer) {
|
||||
const playerName = players[playerIdx].name;
|
||||
const positions = energyEvents
|
||||
.filter(e => (e.details as Record<string, unknown>).owner === playerIdx)
|
||||
.map(e => {
|
||||
const pos = (e.details as Record<string, unknown>).position as Position | undefined;
|
||||
return pos ? `(${pos.row},${pos.col})` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 3); // Limit to first 3 positions
|
||||
|
||||
const posStr = positions.length > 0
|
||||
? ` at ${positions.join(', ')}${positions.length < energyEvents.filter(e => (e.details as Record<string, unknown>).owner === playerIdx).length ? '...' : ''}`
|
||||
: '';
|
||||
|
||||
parts.push(`${playerName} collected ${count} energy${posStr}.`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize bot spawns for a turn.
|
||||
*/
|
||||
private summarizeBotSpawns(turn: ReplayTurn, players: ReplayPlayer[]): string | null {
|
||||
const events = turn.events ?? [];
|
||||
const spawnEvents = events.filter(e => e.type === 'bot_spawned');
|
||||
|
||||
if (spawnEvents.length === 0) return null;
|
||||
|
||||
// Group by player
|
||||
const spawnsByPlayer = new Map<number, number>();
|
||||
for (const event of spawnEvents) {
|
||||
const details = event.details as Record<string, unknown>;
|
||||
const owner = details.owner as number ?? 0;
|
||||
const count = spawnsByPlayer.get(owner) ?? 0;
|
||||
spawnsByPlayer.set(owner, count + 1);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [playerIdx, count] of spawnsByPlayer) {
|
||||
const playerName = players[playerIdx].name;
|
||||
parts.push(`${playerName} spawned ${count} bot${count > 1 ? 's' : ''}.`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Get transcript for a specific turn (for ARIA announcements)
|
||||
getTranscriptForTurn(turn: number): string {
|
||||
if (!this.replay) return '';
|
||||
const turnData = this.replay.turns[turn];
|
||||
if (!turnData) return '';
|
||||
|
||||
const entry = this.generateTurnTranscript(turn, turnData, this.replay.players, this.replay.win_prob);
|
||||
return entry.text;
|
||||
}
|
||||
|
||||
// Draw a player shape (circle, square, triangle, etc.)
|
||||
private drawPlayerShape(x: number, y: number, radius: number, playerIdx: number, color: string): void {
|
||||
const { ctx } = this;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue