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:
jedarden 2026-04-22 18:09:26 -04:00
parent 1b55d4dc51
commit 6c1f031071
21 changed files with 869 additions and 57 deletions

View file

@ -1 +1 @@
4dd91decad4538636d34df90d01199a74a7e1392
1b55d4dc51471a5a7db86269c4f9790aa9aef434

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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