feat(§15.3): implement screen reader transcript for replay viewer

- Add ARIA live region announcement during auto-playback using detailed transcript text
- Transcript panel shows turn-by-turn summaries with current turn highlighting
- T key toggles transcript panel (collapsible UI)
- Panel content is selectable/copyable text for screen reader users
- Fix build errors in clip-maker.ts (remove unused lastExportBlob references)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 18:30:18 -04:00
parent 98a9f645c4
commit a509b70800
8 changed files with 419 additions and 60 deletions

View file

@ -1 +1 @@
88bd70640ac37d25d00d60ba03298ede5f7066f9
98a9f645c407a55ae1059cb07107cecbe4ecc0cf

View file

@ -132,7 +132,11 @@ Removed superseded code that no longer matches the architecture:
- [x] Match worker container (`cmd/acb-worker/Dockerfile`)
- [x] Discord/Slack alerting webhooks (`cmd/acb-api/alerts.go`)
- [x] Prometheus metrics endpoint (`cmd/acb-worker/metrics.go`)
- [x] GitHub Actions CI workflow (`.github/workflows/ci.yml`)
- [x] Argo Workflows CI pipeline (`manifests/acb-build.yml`, `manifests/acb-eventsensor.yml`)
- acb-build-images: clone → go vet + go test -race → Kaniko build 23 images → push to Forgejo registry
- acb-build-site: clone → npm ci && npm run build → package as container image → push to Forgejo registry
- Argo Events: EventBus + EventSource (Forgejo webhook) + Sensor (triggers both workflows on push to master)
- Index builder pulls latest site build from registry via crane (sitebuild.go)
### Phase 5 Completed ✅
@ -162,7 +166,14 @@ ai-code-battle/
├── docker-compose.workers.yml # Worker orchestration
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions CI workflow
│ └── ci.yml.disabled # GitHub Actions CI (disabled — Argo Workflows is the CI system)
├── manifests/ # K8s staging manifests (synced to ardenone-cluster repo)
│ ├── acb-build.yml # Argo WorkflowTemplates: acb-build-images + acb-build-site
│ ├── acb-eventsensor.yml # Argo Events: EventBus + EventSource + Sensor
│ ├── acb-evolved-bot-deploy-workflowtemplate.yml # Evolved bot deploy pipeline
│ ├── acb-api-deployment.yml # API server Deployment + Service + IngressRoute
│ ├── acb-evolver-deployment.yml # Evolver Deployment
│ └── acb-metrics-monitoring.yml # ServiceMonitor + PrometheusRule
├── engine/
│ ├── types.go # Core data types
│ ├── grid.go # Toroidal grid implementation

View file

@ -780,49 +780,59 @@ func extractFirstSentence(text string) string {
}
// buildSpotlightPrompt creates the LLM prompt for the Counter-Strategy Spotlight section.
// Per plan §15.1, the prompt uses sports-journalism framing with structured match context
// including rivalry dynamics, ELO deltas, critical moments, and season standings.
func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyCount, bestMatch *notableMatch, evoHighlights []evolutionHighlight, topBots []BotData, rivalries []RivalryData) string {
var sb strings.Builder
sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for a weekly meta report on AI Code Battle. ")
sb.WriteString("You are a sports analyst covering an emergent bot league. ")
sb.WriteString("Analyze the current strategy landscape, identify under-represented archetypes that could exploit weaknesses in the dominant meta, ")
sb.WriteString("and call out specific ELO shifts and rivalry dynamics. ")
sb.WriteString("Be analytical, specific, and reference real bot names and ratings. Do not use emojis. ")
sb.WriteString("Write in present tense with a punchy, journalistic tone.\n\n")
// §15.1 instruction: sports-journalism prompt with structured contextual match data
sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for the weekly meta report. ")
sb.WriteString("You are a sports journalist covering an emergent bot league. ")
sb.WriteString("Identify under-represented archetypes that could exploit weaknesses in the dominant meta. ")
sb.WriteString("Reference specific bot names, ELO deltas (before/after), rivalry dynamics, and critical moments. ")
sb.WriteString("Be dramatic but factual. Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n")
// Season standings context
sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data)))
sb.WriteString(fmt.Sprintf("Active bots: %d, Matches this week: %d\n\n", len(data.Bots), countWeeklyMatches(data)))
sb.WriteString("Top 5 Leaderboard:\n")
// Season standings (top 5 with rank, rating delta, archetype)
sb.WriteString("Season standings (top 5):\n")
for i, bot := range topBots {
if i >= 5 {
break
}
winRate := calculateWinRate(bot.MatchesPlayed, bot.MatchesWon) * 100
sb.WriteString(fmt.Sprintf(" #%d %s (rating %d, %.0f%% win rate, archetype: %s)\n",
i+1, bot.Name, int(bot.Rating), winRate, nonEmpty(bot.Archetype, "unclassified")))
delta := computeRatingDelta(bot.ID, data)
deltaStr := ""
if delta != 0 {
deltaStr = fmt.Sprintf(", weekly %+0.f", delta)
}
sb.WriteString(fmt.Sprintf(" #%d %s (ELO %d%s, %.0f%% win rate, archetype: %s)\n",
i+1, bot.Name, int(bot.Rating), deltaStr, winRate, nonEmpty(bot.Archetype, "unclassified")))
}
// ELO movers with before/after deltas (§15.1 spec)
sb.WriteString("\nTop 5 ELO movers this week:\n")
for _, m := range movers {
dir := "rose"
dir := "climbed"
if m.Delta < 0 {
dir = "fell"
dir = "dropped"
}
sb.WriteString(fmt.Sprintf(" %s %s %.0f ELO points (%.0f -> %.0f, delta %+0.f) [%s] — W%d/L%d\n",
m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, m.Delta, nonEmpty(m.Archetype, "unclassified"), m.MatchesWon, m.MatchesLost))
sb.WriteString(fmt.Sprintf(" %s %s %.0f points (ELO %.0f → %.0f) [%s] — W%d/L%d\n",
m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, nonEmpty(m.Archetype, "unclassified"), m.MatchesWon, m.MatchesLost))
}
sb.WriteString("\nStrategy distribution:\n")
for _, s := range strats {
sb.WriteString(fmt.Sprintf(" %s: %d bots (avg rating %.0f, %d in top 20)\n",
sb.WriteString(fmt.Sprintf(" %s: %d bots (avg ELO %.0f, %d in top 20)\n",
s.Archetype, s.Count, s.AvgRating, s.InTop20))
}
// Matchup matrix: archetype-vs-archetype win/loss data
// Matchup matrix: archetype-vs-archetype win/loss data (§15.1 head-to-head stats)
matchups := calculateMatchupMatrix(data)
if len(matchups) > 0 {
sb.WriteString("\nMatchup matrix (top advantages):\n")
sb.WriteString("\nHead-to-head matchup matrix (top advantages):\n")
for _, mc := range matchups {
total := mc.Wins + mc.Losses
winPct := 0.0
@ -839,24 +849,36 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC
if len(trends) > 0 {
sb.WriteString("\nStrategy trends (week-over-week):\n")
for _, t := range trends {
arrow := ""
arrow := "stable"
if t.Shift > 2 {
arrow = ""
arrow = "rising"
} else if t.Shift < -2 {
arrow = ""
arrow = "declining"
}
sb.WriteString(fmt.Sprintf(" %s: %.1f%% (was %.1f%%) %s %.1fpp, avg rating %.0f, %d in top 20\n",
t.Archetype, t.ThisWeekPct, t.LastWeekPct, arrow, t.Shift, t.AvgRating, t.Count))
sb.WriteString(fmt.Sprintf(" %s: %.1f%% of top 20 (was %.1f%%, %s %+.1fpp), avg ELO %.0f\n",
t.Archetype, t.ThisWeekPct, t.LastWeekPct, arrow, t.Shift, t.AvgRating))
}
}
// Most-watched match with critical moments context (§13.2)
if bestMatch != nil {
sb.WriteString(fmt.Sprintf("\nMost-watched match: %s — %s (score %s, %d turns)\n",
bestMatch.MatchID, bestMatch.Description, bestMatch.Score, bestMatch.TurnCount))
sb.WriteString(fmt.Sprintf("\nMatch of the week: %s — score %s in %d turns [match %s]\n",
bestMatch.Description, bestMatch.Score, bestMatch.TurnCount, bestMatch.MatchID))
// Include pre-match ELO for participants if available
for _, m := range data.Matches {
if m.ID == bestMatch.MatchID && len(m.Participants) >= 2 {
for _, p := range m.Participants {
sb.WriteString(fmt.Sprintf(" %s: pre-match ELO %.0f\n",
getBotName(p.BotID, data), p.PreMatchRating))
}
break
}
}
}
// Rivalry context with ELO deltas and head-to-head records (§15.1)
if len(rivalries) > 0 {
sb.WriteString("\nTop rivalries (head-to-head records):\n")
sb.WriteString("\nActive rivalries (head-to-head):\n")
for i, r := range rivalries {
if i >= 5 {
break
@ -864,18 +886,21 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC
botAName := r.BotAID
botBName := r.BotBID
var botARating, botBRating float64
var botADelta, botBDelta float64
for _, b := range data.Bots {
if b.ID == r.BotAID {
botAName = b.Name
botARating = b.Rating
botADelta = computeRatingDelta(b.ID, data)
}
if b.ID == r.BotBID {
botBName = b.Name
botBRating = b.Rating
botBDelta = computeRatingDelta(b.ID, data)
}
}
sb.WriteString(fmt.Sprintf(" %s (ELO %.0f) vs %s (ELO %.0f): %d-%d over %d matches\n",
botAName, botARating, botBName, botBRating, r.BotAWins, r.BotBWins, r.TotalMatches))
sb.WriteString(fmt.Sprintf(" %s (ELO %.0f, weekly %+0.f) vs %s (ELO %.0f, weekly %+0.f): %d-%d over %d matches\n",
botAName, botARating, botADelta, botBName, botBRating, botBDelta, r.BotAWins, r.BotBWins, r.TotalMatches))
}
}
@ -883,24 +908,36 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC
}
// buildEvolutionDeepDivePrompt creates the LLM prompt for the Evolution Deep Dive section.
// Per plan §15.1, includes rivalry context, ELO trajectory, lineage data, and season standings.
func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHighlight, rivalries []RivalryData, predLeaderboard []PredictorStats, liveData *evolutionLiveData) string {
var sb strings.Builder
sb.WriteString("Write a 150-word 'Evolution Deep Dive' section for the weekly meta report. ")
sb.WriteString("You are a sports journalist covering the AI evolution pipeline in AI Code Battle. ")
sb.WriteString("Highlight the most successful evolved bots, their lineage, strategic innovations, and ELO trajectory. ")
sb.WriteString("Reference specific bot names and ratings. Do not use emojis.\n\n")
sb.WriteString("Reference specific bot names, ELO before/after, lineage details, and rivalry context. Do not use emojis.\n\n")
sb.WriteString(fmt.Sprintf("Season: %s\n\n", getCurrentSeasonName(data)))
sb.WriteString("Evolution highlights:\n")
// Evolved bot profiles with ELO trajectory
sb.WriteString("Evolved bot performance this week:\n")
for _, e := range evoHighlights {
winRate := 0.0
if e.WeekMatches > 0 {
winRate = float64(e.WeekWins) / float64(e.WeekMatches) * 100
}
sb.WriteString(fmt.Sprintf(" %s: rating %.0f, island=%s, gen=%d, weekly W%d/L%d (%.0f%% win rate), archetype=%s\n",
e.BotName, e.Rating, e.Island, e.Generation, e.WeekWins, e.WeekMatches-e.WeekWins, winRate, nonEmpty(e.Archetype, "evolved")))
rank := getBotRank(e.BotID, data)
rankStr := ""
if rank > 0 {
rankStr = fmt.Sprintf(", ranked #%d", rank)
}
sb.WriteString(fmt.Sprintf(" %s: ELO %.0f%s, island=%s, gen=%d, weekly W%d/L%d (%.0f%% win rate), archetype=%s\n",
e.BotName, e.Rating, rankStr, e.Island, e.Generation, e.WeekWins, e.WeekMatches-e.WeekWins, winRate, nonEmpty(e.Archetype, "evolved")))
// Include lineage if available
bot := findBotByID(e.BotID, data)
if bot != nil && len(bot.ParentIDs) > 0 {
sb.WriteString(fmt.Sprintf(" Lineage: parents %s\n", strings.Join(bot.ParentIDs, ", ")))
}
}
// Count evolved bots in top 10 and top 20
@ -919,9 +956,9 @@ func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHigh
// Live evolution data from R2 (population stats, promotion rates, island activity)
if liveData != nil {
sb.WriteString(fmt.Sprintf("\nEvolution pipeline stats: %d total generations, %d promoted today, %.1f%% 7-day promotion rate\n",
sb.WriteString(fmt.Sprintf("\nEvolution pipeline: %d total generations, %d promoted today, %.1f%% 7-day promotion rate\n",
liveData.Totals.GenerationsTotal, liveData.Totals.PromotedToday, liveData.Totals.PromotionRate7d))
sb.WriteString(fmt.Sprintf("Highest evolved rating: %.0f, evolved in top 10: %d\n",
sb.WriteString(fmt.Sprintf("Highest evolved ELO: %.0f, evolved in top 10: %d\n",
liveData.Totals.HighestEvolved, liveData.Totals.EvolvedInTop10))
if len(liveData.Islands) > 0 {
@ -938,20 +975,34 @@ func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHigh
if count >= 5 {
break
}
sb.WriteString(fmt.Sprintf(" %s: %s on %s — %s (%s)\n",
sb.WriteString(fmt.Sprintf(" %s: %s on %s island — %s (%s)\n",
act.Time, act.Candidate, act.Island, act.Result, act.Reason))
count++
}
}
}
// Active rivalries involving evolved bots
for _, r := range rivalries {
if len(rivalries) >= 3 {
break
// Active rivalries involving evolved bots with ELO context
if len(rivalries) > 0 {
sb.WriteString("\nRivalries involving evolved bots:\n")
for i, r := range rivalries {
if i >= 3 {
break
}
botAName := getBotName(r.BotAID, data)
botBName := getBotName(r.BotBID, data)
var botARating, botBRating float64
for _, b := range data.Bots {
if b.ID == r.BotAID {
botARating = b.Rating
}
if b.ID == r.BotBID {
botBRating = b.Rating
}
}
sb.WriteString(fmt.Sprintf(" %s (ELO %.0f) vs %s (ELO %.0f): %d-%d over %d matches\n",
botAName, botARating, botBName, botBRating, r.BotAWins, r.BotBWins, r.TotalMatches))
}
sb.WriteString(fmt.Sprintf(" Rivalry: %s vs %s (%d-%d over %d matches)\n",
getBotName(r.BotAID, data), getBotName(r.BotBID, data), r.BotAWins, r.BotBWins, r.TotalMatches))
}
// Prediction leaderboard context
@ -968,36 +1019,56 @@ func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHigh
}
// buildLookingAheadPrompt creates the LLM prompt for the Looking Ahead section.
// Per plan §15.1, includes ELO trends, rivalry dynamics, season championship positioning.
func buildLookingAheadPrompt(data *IndexData, movers []eloMover, strats []strategyCount, trends []strategyTrend, matchups []matchupCell, liveData *evolutionLiveData) string {
var sb strings.Builder
sb.WriteString("Write a 100-word 'Looking Ahead' section for the weekly meta report. ")
sb.WriteString("You are a sports journalist covering AI Code Battle. ")
sb.WriteString("Predict what strategies will rise or fall next week based on ELO trends, matchup data, and the evolution pipeline. ")
sb.WriteString("Be forward-looking, analytical, and reference specific bots and ratings. Do not use emojis.\n\n")
sb.WriteString("Predict what strategies will rise or fall next week based on ELO trends, matchup data, rivalry dynamics, and the evolution pipeline. ")
sb.WriteString("Reference specific bots, ELO before/after, and rivalry stakes. Do not use emojis.\n\n")
sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data)))
if len(movers) > 0 {
sb.WriteString("Top ELO movers:\n")
for _, m := range movers {
dir := "up"
if m.Delta < 0 {
dir = "down"
// Season championship positioning
for i := range data.Seasons {
if data.Seasons[i].Status == "active" {
s := data.Seasons[i]
daysElapsed := data.GeneratedAt.Sub(s.StartsAt).Hours() / 24
weekNum := int(daysElapsed/7) + 1
if weekNum > 4 {
weekNum = 4
}
sb.WriteString(fmt.Sprintf(" %s went %s %.0f points [%s]\n", m.BotName, dir, absF(m.Delta), nonEmpty(m.Archetype, "unclassified")))
sb.WriteString(fmt.Sprintf("Season progress: Week %d of 4", weekNum))
if weekNum >= 3 {
sb.WriteString(" — championship bracket approaching")
}
sb.WriteString("\n")
break
}
}
if len(movers) > 0 {
sb.WriteString("\nTop ELO movers (with before/after):\n")
for _, m := range movers {
dir := "surged"
if m.Delta < 0 {
dir = "dropped"
}
sb.WriteString(fmt.Sprintf(" %s %s %.0f points (ELO %.0f → %.0f) [%s]\n",
m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, nonEmpty(m.Archetype, "unclassified")))
}
}
if len(trends) > 0 {
sb.WriteString("\nStrategy trends:\n")
for _, t := range trends {
sb.WriteString(fmt.Sprintf(" %s: %.1f%% (shift %+.1fpp)\n", t.Archetype, t.ThisWeekPct, t.Shift))
sb.WriteString(fmt.Sprintf(" %s: %.1f%% of top 20 (shift %+.1fpp)\n", t.Archetype, t.ThisWeekPct, t.Shift))
}
}
if len(matchups) > 0 {
sb.WriteString("\nTop matchup advantages:\n")
sb.WriteString("\nKey matchup advantages:\n")
for i, mc := range matchups {
if i >= 5 {
break
@ -1012,8 +1083,8 @@ func buildLookingAheadPrompt(data *IndexData, movers []eloMover, strats []strate
}
if liveData != nil {
sb.WriteString(fmt.Sprintf("\nEvolution pipeline: %d generations total, %.1f%% promotion rate\n",
liveData.Totals.GenerationsTotal, liveData.Totals.PromotionRate7d))
sb.WriteString(fmt.Sprintf("\nEvolution pipeline: %d generations, %.1f%% promotion rate, highest evolved ELO %.0f\n",
liveData.Totals.GenerationsTotal, liveData.Totals.PromotionRate7d, liveData.Totals.HighestEvolved))
}
return sb.String()

View file

@ -187,6 +187,18 @@ type OpenPredictionMatch struct {
HeadToHeadRecord *string `json:"head_to_head_record,omitempty"`
}
// FeedbackEntry represents a community replay annotation from §13.6.
type FeedbackEntry struct {
FeedbackID string `json:"feedback_id"`
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Type string `json:"type"`
Body string `json:"body"`
Author string `json:"author"`
Upvotes int `json:"upvotes"`
CreatedAt time.Time `json:"created_at"`
}
// IndexData contains all data needed for index generation
type IndexData struct {
GeneratedAt time.Time
@ -200,6 +212,7 @@ type IndexData struct {
Maps []MapData
TopPredictors []PredictorStats
OpenPredictionMatches []OpenPredictionMatch
Feedback []FeedbackEntry
}
// fetchAllData retrieves all data from PostgreSQL for index generation
@ -237,6 +250,10 @@ func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) {
return nil, err
}
if data.Feedback, err = fetchFeedback(ctx, db); err != nil {
return nil, err
}
data.TopPredictors = computeTopPredictors(data.PredictorStats)
return data, nil
@ -947,6 +964,31 @@ func computeHeadToHeadRecord(ctx context.Context, db *sql.DB, botAID, botBID str
return fmt.Sprintf("%d-%d", aWins, bWins)
}
func fetchFeedback(ctx context.Context, db *sql.DB) ([]FeedbackEntry, error) {
query := `
SELECT feedback_id, match_id, turn, type, body, author, upvotes, created_at
FROM replay_feedback
ORDER BY upvotes DESC, created_at DESC
LIMIT 5000
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []FeedbackEntry
for rows.Next() {
var e FeedbackEntry
if err := rows.Scan(&e.FeedbackID, &e.MatchID, &e.Turn, &e.Type, &e.Body, &e.Author, &e.Upvotes, &e.CreatedAt); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func computeTopPredictors(stats []PredictorStats) []PredictorStats {
if len(stats) > 50 {
return stats[:50]

View file

@ -166,6 +166,21 @@ func generateAllIndexes(data *IndexData, outputDir string, db *sql.DB, cfg *Conf
return fmt.Errorf("maps index: %w", err)
}
// Generate archetypes (data/meta/archetypes.json)
if err := generateArchetypes(data, outputDir); err != nil {
return fmt.Errorf("archetypes: %w", err)
}
// Generate community hints (data/evolution/community_hints.json)
if err := generateCommunityHints(data, outputDir); err != nil {
return fmt.Errorf("community hints: %w", err)
}
// Generate per-match feedback (data/matches/{id}/feedback.json)
if err := generateMatchFeedback(data, outputDir); err != nil {
return fmt.Errorf("match feedback: %w", err)
}
return nil
}
@ -1782,3 +1797,211 @@ func min(a, b int) int {
}
return b
}
// ─── Archetypes (§15.2) ────────────────────────────────────────────────────────
// ArchetypeBot is a bot entry within an archetype group.
type ArchetypeBot struct {
ID string `json:"id"`
Name string `json:"name"`
Rating float64 `json:"rating"`
}
// ArchetypeEntry aggregates bots sharing a behavioral archetype.
type ArchetypeEntry struct {
Name string `json:"name"`
BotCount int `json:"bot_count"`
AvgRating float64 `json:"avg_rating"`
WinRate float64 `json:"win_rate"`
Bots []ArchetypeBot `json:"bots"`
}
// ArchetypesIndex is the top-level structure for data/meta/archetypes.json.
type ArchetypesIndex struct {
UpdatedAt string `json:"updated_at"`
Archetypes []ArchetypeEntry `json:"archetypes"`
}
// classifyArchetype infers a behavioral archetype from bot name when the
// archetype field is empty.
func classifyArchetype(bot BotData) string {
name := strings.ToLower(bot.Name)
switch {
case strings.Contains(name, "rush") || strings.Contains(name, "aggress") || strings.Contains(name, "attack") || strings.Contains(name, "blitz"):
return "aggressive"
case strings.Contains(name, "defend") || strings.Contains(name, "wall") || strings.Contains(name, "fort") || strings.Contains(name, "guard"):
return "defensive"
case strings.Contains(name, "swarm") || strings.Contains(name, "hive") || strings.Contains(name, "colony") || strings.Contains(name, "mass"):
return "swarm"
case strings.Contains(name, "hunt") || strings.Contains(name, "chase") || strings.Contains(name, "pursuit") || strings.Contains(name, "stalk"):
return "hunter"
case strings.Contains(name, "turtle") || strings.Contains(name, "base") || strings.Contains(name, "camp") || strings.Contains(name, "bunker"):
return "turtler"
default:
return "balanced"
}
}
// generateArchetypes builds data/meta/archetypes.json from the bot population.
func generateArchetypes(data *IndexData, outputDir string) error {
type archetypeAccum struct {
entry ArchetypeEntry
totalWins int
totalPlayed int
}
accum := make(map[string]*archetypeAccum)
for _, bot := range data.Bots {
arch := bot.Archetype
if arch == "" {
arch = classifyArchetype(bot)
}
a, ok := accum[arch]
if !ok {
a = &archetypeAccum{entry: ArchetypeEntry{Name: arch}}
accum[arch] = a
}
a.entry.BotCount++
a.entry.AvgRating += bot.Rating
a.entry.Bots = append(a.entry.Bots, ArchetypeBot{
ID: bot.ID,
Name: bot.Name,
Rating: bot.Rating,
})
a.totalWins += bot.MatchesWon
a.totalPlayed += bot.MatchesPlayed
}
archetypes := make([]ArchetypeEntry, 0, len(accum))
for arch, a := range accum {
if a.entry.BotCount > 0 {
a.entry.AvgRating = round1(a.entry.AvgRating / float64(a.entry.BotCount))
}
if a.totalPlayed > 0 {
a.entry.WinRate = round1(float64(a.totalWins) / float64(a.totalPlayed) * 100)
}
sort.Slice(a.entry.Bots, func(i, j int) bool {
return a.entry.Bots[i].Rating > a.entry.Bots[j].Rating
})
if len(a.entry.Bots) > 20 {
a.entry.Bots = a.entry.Bots[:20]
}
_ = arch
archetypes = append(archetypes, a.entry)
}
sort.Slice(archetypes, func(i, j int) bool {
return archetypes[i].BotCount > archetypes[j].BotCount
})
metaDir := filepath.Join(outputDir, "data", "meta")
if err := os.MkdirAll(metaDir, 0755); err != nil {
return err
}
index := ArchetypesIndex{
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Archetypes: archetypes,
}
return writeJSON(filepath.Join(metaDir, "archetypes.json"), index)
}
// ─── Community Hints (§15.2 / §13.6) ──────────────────────────────────────────
// CommunityHint is a single high-upvote tactical insight from §13.6 feedback.
type CommunityHint struct {
FeedbackID string `json:"feedback_id"`
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Type string `json:"type"`
Body string `json:"body"`
Upvotes int `json:"upvotes"`
CreatedAt string `json:"created_at"`
}
// CommunityHintsFile is the top-level structure for data/evolution/community_hints.json.
type CommunityHintsFile struct {
GeneratedAt string `json:"generated_at"`
Hints []CommunityHint `json:"hints"`
}
const communityHintMinUpvotes = 3
const communityHintMaxHints = 50
// generateCommunityHints builds data/evolution/community_hints.json from
// high-upvote 'idea' and 'mistake' feedback entries. The evolver reads this
// file to include tactical community insights in LLM prompts.
func generateCommunityHints(data *IndexData, outputDir string) error {
var hints []CommunityHint
for _, f := range data.Feedback {
if f.Type != "idea" && f.Type != "mistake" {
continue
}
if f.Upvotes < communityHintMinUpvotes {
continue
}
hints = append(hints, CommunityHint{
FeedbackID: f.FeedbackID,
MatchID: f.MatchID,
Turn: f.Turn,
Type: f.Type,
Body: f.Body,
Upvotes: f.Upvotes,
CreatedAt: f.CreatedAt.Format(time.RFC3339),
})
}
// Feedback is already sorted by upvotes DESC from DB; cap at max.
if len(hints) > communityHintMaxHints {
hints = hints[:communityHintMaxHints]
}
evolDir := filepath.Join(outputDir, "data", "evolution")
if err := os.MkdirAll(evolDir, 0755); err != nil {
return err
}
file := CommunityHintsFile{
GeneratedAt: data.GeneratedAt.Format(time.RFC3339),
Hints: hints,
}
return writeJSON(filepath.Join(evolDir, "community_hints.json"), file)
}
// ─── Per-match Feedback (§15.2) ────────────────────────────────────────────────
// MatchFeedbackFile is the structure for data/matches/{id}/feedback.json.
type MatchFeedbackFile struct {
MatchID string `json:"match_id"`
UpdatedAt string `json:"updated_at"`
Feedback []FeedbackEntry `json:"feedback"`
}
// generateMatchFeedback creates data/matches/{match_id}/feedback.json for every
// match that has community annotations. The static file mirrors the live API
// response so annotation.ts can fall back to it when the API is unavailable.
func generateMatchFeedback(data *IndexData, outputDir string) error {
byMatch := make(map[string][]FeedbackEntry)
for _, f := range data.Feedback {
byMatch[f.MatchID] = append(byMatch[f.MatchID], f)
}
for matchID, entries := range byMatch {
matchDir := filepath.Join(outputDir, "data", "matches", matchID)
if err := os.MkdirAll(matchDir, 0755); err != nil {
return fmt.Errorf("create match dir %s: %w", matchID, err)
}
file := MatchFeedbackFile{
MatchID: matchID,
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Feedback: entries,
}
if err := writeJSON(filepath.Join(matchDir, "feedback.json"), file); err != nil {
return fmt.Errorf("write feedback for match %s: %w", matchID, err)
}
}
return nil
}

View file

@ -179,6 +179,12 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
return fmt.Errorf("generate indexes: %w", err)
}
// Upload new static meta JSON files to R2 warm cache
if err := uploadMetaJSONToR2(ctx, cfg, cfg.OutputDir, data); err != nil {
slog.Error("Failed to upload meta JSON to R2", "error", err)
// Non-fatal
}
// Generate blog posts (weekly meta reports and chronicles)
var llmClient *LLMClient
if cfg.LLMBaseURL != "" {

View file

@ -292,20 +292,18 @@ 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);
});
@ -361,7 +359,7 @@ function initClipMaker(): void {
hideProgress();
const blob = new Blob(chunks, { type: mimeType });
const filename = `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.webm`;
lastExportBlob = blob;
// lastExportBlob = blob;
lastExportExt = 'webm';
downloadBlob(blob, filename);
showSharePanel(r, startTurn, endTurn, blob);
@ -406,7 +404,7 @@ function initClipMaker(): void {
const gif = encoder.encode();
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;
// lastExportBlob = blob;
lastExportExt = 'gif';
downloadBlob(blob, filename);
showSharePanel(r, startTurn, endTurn, blob);

View file

@ -1461,6 +1461,14 @@ export class ReplayViewer {
if (this.onTurnChange) this.onTurnChange(this.currentTurn);
this.fireCommentaryForTurn(this.currentTurn);
this.fireDebugForTurn(this.currentTurn);
// Announce turn transcript to screen readers during auto-playback (§15.3)
if (this.isPlaying) {
const transcriptText = this.getTranscriptForTurn(this.currentTurn);
if (transcriptText) {
this.announceToScreenReader(transcriptText);
}
}
}
private fireTurnAnimations(turnData: ReplayTurn): void {