diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index fa216df..1f45249 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -88bd70640ac37d25d00d60ba03298ede5f7066f9 +98a9f645c407a55ae1059cb07107cecbe4ecc0cf diff --git a/PROGRESS.md b/PROGRESS.md index 6651b81..121d02f 100644 --- a/PROGRESS.md +++ b/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 diff --git a/cmd/acb-index-builder/blog.go b/cmd/acb-index-builder/blog.go index 6514d6c..2bf5c51 100644 --- a/cmd/acb-index-builder/blog.go +++ b/cmd/acb-index-builder/blog.go @@ -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() diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index cfd86b4..75d5f4d 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -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] diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index 2fd6b87..813bc9c 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -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 +} diff --git a/cmd/acb-index-builder/main.go b/cmd/acb-index-builder/main.go index 72f4e7d..85ecc79 100644 --- a/cmd/acb-index-builder/main.go +++ b/cmd/acb-index-builder/main.go @@ -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 != "" { diff --git a/web/src/pages/clip-maker.ts b/web/src/pages/clip-maker.ts index 8ada524..574c1f7 100644 --- a/web/src/pages/clip-maker.ts +++ b/web/src/pages/clip-maker.ts @@ -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); diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index 8ef2e86..24d35ec 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -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 {