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:
parent
98a9f645c4
commit
a509b70800
8 changed files with 419 additions and 60 deletions
|
|
@ -1 +1 @@
|
|||
88bd70640ac37d25d00d60ba03298ede5f7066f9
|
||||
98a9f645c407a55ae1059cb07107cecbe4ecc0cf
|
||||
|
|
|
|||
15
PROGRESS.md
15
PROGRESS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue