feat(playlists): add playlist curation and rebuild logic per §10, with series/seasons/enrichment

Implement auto-curated playlists in the index builder: 12 playlist types
(closest finishes, upsets, comebacks, marathons, rivalry classics, etc.)
with weekly highlight curation. Add DB persistence, R2 pruning exemptions,
frontend pages, and AI commentary enrichment pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 16:11:27 -04:00
parent 70b7337867
commit b1121ed6f8
34 changed files with 3519 additions and 309 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
/acb-api
/acb-matchmaker
/acb-evolver
/acb-index-builder
# Node modules
node_modules/

View file

@ -1 +1 @@
0d887ebeb2f2e3db51f92adc2225646f2b451fe2
70b73378678332a405a57342a001fdcec925adc7

View file

@ -32,21 +32,34 @@ CREATE TABLE IF NOT EXISTS predictor_stats (
);
CREATE TABLE IF NOT EXISTS series (
id BIGSERIAL PRIMARY KEY,
bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
format INTEGER NOT NULL DEFAULT 5, -- best of N (3, 5, 7...)
a_wins INTEGER NOT NULL DEFAULT 0,
b_wins INTEGER NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
winner_id VARCHAR(16),
season_id BIGINT REFERENCES seasons(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id BIGSERIAL PRIMARY KEY,
bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
format INTEGER NOT NULL DEFAULT 5, -- best of N (3, 5, 7...)
a_wins INTEGER NOT NULL DEFAULT 0,
b_wins INTEGER NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
winner_id VARCHAR(16),
season_id BIGINT REFERENCES seasons(id),
bracket_round VARCHAR(32), -- 'quarterfinal', 'semifinal', 'final' for championship
bracket_position INTEGER, -- position within the bracket round (0-based)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_series_bots ON series(bot_a_id, bot_b_id);
CREATE INDEX IF NOT EXISTS idx_series_status ON series(status);
CREATE INDEX IF NOT EXISTS idx_series_season ON series(season_id);
CREATE INDEX IF NOT EXISTS idx_series_bracket ON series(season_id, bracket_round) WHERE bracket_round IS NOT NULL;
-- Add bracket columns if they don't exist (idempotent migration)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_round') THEN
ALTER TABLE series ADD COLUMN bracket_round VARCHAR(32);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'series' AND column_name = 'bracket_position') THEN
ALTER TABLE series ADD COLUMN bracket_position INTEGER;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS series_games (
id BIGSERIAL PRIMARY KEY,
@ -210,6 +223,27 @@ CREATE TABLE IF NOT EXISTS programs (
);
CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island);
CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC);
-- Curated playlist definitions (§14.4 Replay Playlists)
CREATE TABLE IF NOT EXISTS playlists (
slug VARCHAR(64) PRIMARY KEY,
title VARCHAR(128) NOT NULL,
description TEXT NOT NULL DEFAULT '',
category VARCHAR(32) NOT NULL DEFAULT 'featured',
is_auto BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS playlist_matches (
playlist_slug VARCHAR(64) NOT NULL REFERENCES playlists(slug) ON DELETE CASCADE,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
sort_order INTEGER NOT NULL DEFAULT 0,
curation_tag TEXT NOT NULL DEFAULT '',
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playlist_slug, match_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_matches_playlist ON playlist_matches(playlist_slug, sort_order);
`
func ensureSchema(ctx context.Context, db *sql.DB) error {

View file

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
@ -49,6 +50,7 @@ type ParticipantData struct {
Score int `json:"score"`
Won bool `json:"won"`
PreMatchRating float64 `json:"pre_match_rating,omitempty"`
Evolved bool `json:"evolved,omitempty"`
}
// RatingHistoryEntry represents a rating history point
@ -59,41 +61,90 @@ type RatingHistoryEntry struct {
RecordedAt time.Time `json:"recorded_at"`
}
// SeriesData represents a series for the index
type SeriesData struct {
ID int64 `json:"id"`
BotAID string `json:"bot_a_id"`
BotBID string `json:"bot_b_id"`
Format int `json:"format"`
AWins int `json:"a_wins"`
BWins int `json:"b_wins"`
Status string `json:"status"`
WinnerID string `json:"winner_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// SeriesGameData represents one game within a series
type SeriesGameData struct {
MatchID string `json:"match_id"`
GameNum int `json:"game_number"`
WinnerID string `json:"winner_id,omitempty"`
WinnerSlot *int `json:"winner_slot"`
Turns int `json:"turns,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// SeasonData represents a season for the index
// SeriesData represents a series for the index, enriched with bot names and games.
type SeriesData struct {
ID int64 `json:"id"`
BotAID string `json:"bot1_id"`
BotBID string `json:"bot2_id"`
BotAName string `json:"bot1_name"`
BotBName string `json:"bot2_name"`
Format int `json:"best_of"`
AWins int `json:"bot1_wins"`
BWins int `json:"bot2_wins"`
Status string `json:"status"`
WinnerID string `json:"winner_id,omitempty"`
BracketRound string `json:"bracket_round,omitempty"`
BracketPosition int `json:"bracket_position,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Games []SeriesGameData `json:"games"`
}
// SeasonSnapshotData represents a bot's end-of-season snapshot
type SeasonSnapshotData struct {
BotID string `json:"bot_id"`
BotName string `json:"bot_name"`
Rating float64 `json:"rating"`
Rank int `json:"rank"`
Wins int `json:"wins"`
Losses int `json:"losses"`
}
// ChampionshipSeries is a lightweight series summary for bracket display on the season page.
type ChampionshipSeries struct {
ID int64 `json:"id"`
BotAID string `json:"bot1_id"`
BotBID string `json:"bot2_id"`
BotAName string `json:"bot1_name"`
BotBName string `json:"bot2_name"`
Format int `json:"best_of"`
AWins int `json:"bot1_wins"`
BWins int `json:"bot2_wins"`
Status string `json:"status"`
WinnerID string `json:"winner_id,omitempty"`
Round string `json:"round"`
BracketPosition int `json:"bracket_position"`
Games []SeriesGameData `json:"games"`
}
// SeasonData represents a season for the index, enriched with champion name, match count, and snapshots.
type SeasonData struct {
ID int64 `json:"id"`
Name string `json:"name"`
Theme string `json:"theme,omitempty"`
RulesVer string `json:"rules_version"`
ChampionID string `json:"champion_id,omitempty"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Theme string `json:"theme,omitempty"`
RulesVer string `json:"rules_version"`
Status string `json:"status"`
ChampionID string `json:"champion_id,omitempty"`
ChampionName string `json:"champion_name,omitempty"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at,omitempty"`
TotalMatches int `json:"total_matches"`
CreatedAt time.Time `json:"created_at"`
Snapshots []SeasonSnapshotData `json:"final_snapshot"`
ChampionshipBracket []ChampionshipSeries `json:"championship_bracket,omitempty"`
}
// PredictionData represents a prediction for the index
type PredictionData struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
PredictorID string `json:"predictor_id"`
PredictedBot string `json:"predicted_bot"`
Correct *bool `json:"correct,omitempty"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ID int64 `json:"id"`
MatchID string `json:"match_id"`
PredictorID string `json:"predictor_id"`
PredictedBot string `json:"predicted_bot"`
Correct *bool `json:"correct,omitempty"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// PredictorStats represents predictor statistics
@ -107,29 +158,29 @@ type PredictorStats struct {
// MapData represents a map for the index
type MapData struct {
MapID string `json:"map_id"`
PlayerCount int `json:"player_count"`
Status string `json:"status"`
Engagement float64 `json:"engagement"`
WallDensity float64 `json:"wall_density"`
EnergyCount int `json:"energy_count"`
GridWidth int `json:"grid_width"`
GridHeight int `json:"grid_height"`
CreatedAt time.Time `json:"created_at"`
MapID string `json:"map_id"`
PlayerCount int `json:"player_count"`
Status string `json:"status"`
Engagement float64 `json:"engagement"`
WallDensity float64 `json:"wall_density"`
EnergyCount int `json:"energy_count"`
GridWidth int `json:"grid_width"`
GridHeight int `json:"grid_height"`
CreatedAt time.Time `json:"created_at"`
}
// IndexData contains all data needed for index generation
type IndexData struct {
GeneratedAt time.Time
Bots []BotData
Matches []MatchData
RatingHistory []RatingHistoryEntry
Series []SeriesData
Seasons []SeasonData
Predictions []PredictionData
PredictorStats []PredictorStats
Maps []MapData
TopPredictors []PredictorStats
GeneratedAt time.Time
Bots []BotData
Matches []MatchData
RatingHistory []RatingHistoryEntry
Series []SeriesData
Seasons []SeasonData
Predictions []PredictionData
PredictorStats []PredictorStats
Maps []MapData
TopPredictors []PredictorStats
}
// fetchAllData retrieves all data from PostgreSQL for index generation
@ -164,7 +215,6 @@ func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) {
return nil, err
}
// Get top predictors (sorted by accuracy)
data.TopPredictors = computeTopPredictors(data.PredictorStats)
return data, nil
@ -224,7 +274,6 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) {
bots = append(bots, b)
}
// Calculate matches played and won from match_participants
for i := range bots {
mp, mw, err := getBotMatchStats(ctx, db, bots[i].ID)
if err != nil {
@ -258,7 +307,13 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) {
'bot_id', mp.bot_id,
'player_slot', mp.player_slot,
'score', mp.score,
'won', mp.player_slot = m.winner
'won', mp.player_slot = m.winner,
'pre_match_rating', COALESCE(
(SELECT rh.rating FROM rating_history rh
WHERE rh.bot_id = mp.bot_id AND rh.match_id = m.match_id
LIMIT 1), 0),
'evolved', COALESCE(
(SELECT b.evolved FROM bots b WHERE b.bot_id = mp.bot_id), false)
)
ORDER BY mp.player_slot
) FILTER (WHERE mp.bot_id IS NOT NULL),
@ -300,6 +355,14 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) {
return nil, err
}
// PlayedAt is used for weekly filtering in blog/stats generation.
// CompletedAt is the authoritative timestamp; fall back to CreatedAt.
if !m.CompletedAt.IsZero() {
m.PlayedAt = m.CompletedAt
} else {
m.PlayedAt = m.CreatedAt
}
matches = append(matches, m)
}
@ -334,9 +397,15 @@ func fetchRatingHistory(ctx context.Context, db *sql.DB) ([]RatingHistoryEntry,
func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) {
query := `
SELECT id, bot_a_id, bot_b_id, format, a_wins, b_wins, status, winner_id, created_at, updated_at
FROM series
ORDER BY created_at DESC
SELECT s.id, s.bot_a_id, s.bot_b_id,
ba.name, bb.name,
s.format, s.a_wins, s.b_wins, s.status, s.winner_id,
COALESCE(s.bracket_round, ''), COALESCE(s.bracket_position, 0),
s.created_at, s.updated_at
FROM series s
JOIN bots ba ON s.bot_a_id = ba.bot_id
JOIN bots bb ON s.bot_b_id = bb.bot_id
ORDER BY s.created_at DESC
`
rows, err := db.QueryContext(ctx, query)
@ -351,8 +420,11 @@ func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) {
var winnerID sql.NullString
err := rows.Scan(
&s.ID, &s.BotAID, &s.BotBID, &s.Format, &s.AWins, &s.BWins,
&s.Status, &winnerID, &s.CreatedAt, &s.UpdatedAt,
&s.ID, &s.BotAID, &s.BotBID,
&s.BotAName, &s.BotBName,
&s.Format, &s.AWins, &s.BWins, &s.Status, &winnerID,
&s.BracketRound, &s.BracketPosition,
&s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
@ -364,14 +436,75 @@ func fetchSeries(ctx context.Context, db *sql.DB) ([]SeriesData, error) {
series = append(series, s)
}
for i := range series {
games, err := fetchSeriesGames(ctx, db, series[i].ID)
if err != nil {
return nil, err
}
series[i].Games = games
}
return series, nil
}
func fetchSeriesGames(ctx context.Context, db *sql.DB, seriesID int64) ([]SeriesGameData, error) {
rows, err := db.QueryContext(ctx, `
SELECT sg.match_id, sg.game_num, sg.winner_id,
COALESCE(m.turn_count, 0), m.completed_at,
CASE WHEN sg.winner_id IS NOT NULL THEN
(SELECT mp.player_slot FROM match_participants mp
WHERE mp.match_id = sg.match_id AND mp.bot_id = sg.winner_id)
END
FROM series_games sg
LEFT JOIN matches m ON sg.match_id = m.match_id
WHERE sg.series_id = $1
ORDER BY sg.game_num
`, seriesID)
if err != nil {
return nil, err
}
defer rows.Close()
var games []SeriesGameData
for rows.Next() {
var g SeriesGameData
var winnerID sql.NullString
var winnerSlot sql.NullInt64
var turns sql.NullInt64
var completedAt sql.NullTime
err := rows.Scan(&g.MatchID, &g.GameNum, &winnerID, &turns, &completedAt, &winnerSlot)
if err != nil {
return nil, err
}
if winnerID.Valid {
g.WinnerID = winnerID.String
}
if winnerSlot.Valid {
slot := int(winnerSlot.Int64)
g.WinnerSlot = &slot
}
if turns.Valid && turns.Int64 > 0 {
g.Turns = int(turns.Int64)
}
if completedAt.Valid {
g.CompletedAt = &completedAt.Time
}
games = append(games, g)
}
return games, nil
}
func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) {
query := `
SELECT id, name, theme, rules_version, champion_id, starts_at, ends_at, created_at
FROM seasons
ORDER BY starts_at DESC
SELECT s.id, s.name, s.theme, s.rules_version, s.status,
s.champion_id, b.name,
s.starts_at, s.ends_at, s.created_at
FROM seasons s
LEFT JOIN bots b ON s.champion_id = b.bot_id
ORDER BY s.starts_at DESC
`
rows, err := db.QueryContext(ctx, query)
@ -383,11 +516,12 @@ func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) {
var seasons []SeasonData
for rows.Next() {
var s SeasonData
var theme, championID sql.NullString
var theme, championID, championName sql.NullString
var endsAt sql.NullTime
err := rows.Scan(
&s.ID, &s.Name, &theme, &s.RulesVer, &championID,
&s.ID, &s.Name, &theme, &s.RulesVer, &s.Status,
&championID, &championName,
&s.StartsAt, &endsAt, &s.CreatedAt,
)
if err != nil {
@ -400,15 +534,119 @@ func fetchSeasons(ctx context.Context, db *sql.DB) ([]SeasonData, error) {
if championID.Valid {
s.ChampionID = championID.String
}
if championName.Valid {
s.ChampionName = championName.String
}
if endsAt.Valid {
s.EndsAt = endsAt.Time
}
seasons = append(seasons, s)
}
// Enrich each season with match count, snapshots, and championship bracket
for i := range seasons {
seasons[i].TotalMatches, _ = getSeasonMatchCount(ctx, db, seasons[i].ID)
snapshots, err := fetchSeasonSnapshots(ctx, db, seasons[i].ID)
if err == nil && len(snapshots) > 0 {
seasons[i].Snapshots = snapshots
}
bracket, err := fetchChampionshipBracket(ctx, db, seasons[i].ID)
if err == nil && len(bracket) > 0 {
seasons[i].ChampionshipBracket = bracket
}
}
return seasons, nil
}
func getSeasonMatchCount(ctx context.Context, db *sql.DB, seasonID int64) (int, error) {
// Count matches from series in this season
var count int
err := db.QueryRowContext(ctx, `
SELECT COUNT(DISTINCT sg.match_id)
FROM series_games sg
JOIN series s ON sg.series_id = s.id
WHERE s.season_id = $1 AND sg.match_id IS NOT NULL
`, seasonID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func fetchSeasonSnapshots(ctx context.Context, db *sql.DB, seasonID int64) ([]SeasonSnapshotData, error) {
rows, err := db.QueryContext(ctx, `
SELECT ss.bot_id, b.name, ss.rating, ss.rank, ss.wins, ss.losses
FROM season_snapshots ss
JOIN bots b ON ss.bot_id = b.bot_id
WHERE ss.season_id = $1
ORDER BY ss.rank
`, seasonID)
if err != nil {
return nil, err
}
defer rows.Close()
var snapshots []SeasonSnapshotData
for rows.Next() {
var snap SeasonSnapshotData
if err := rows.Scan(&snap.BotID, &snap.BotName, &snap.Rating, &snap.Rank, &snap.Wins, &snap.Losses); err != nil {
return nil, err
}
snapshots = append(snapshots, snap)
}
return snapshots, nil
}
func fetchChampionshipBracket(ctx context.Context, db *sql.DB, seasonID int64) ([]ChampionshipSeries, error) {
rows, err := db.QueryContext(ctx, `
SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name,
s.format, s.a_wins, s.b_wins, s.status, s.winner_id,
COALESCE(s.bracket_round, 'quarterfinal'), COALESCE(s.bracket_position, 0)
FROM series s
JOIN bots ba ON s.bot_a_id = ba.bot_id
JOIN bots bb ON s.bot_b_id = bb.bot_id
WHERE s.season_id = $1 AND s.bracket_round IS NOT NULL
ORDER BY
CASE s.bracket_round
WHEN 'quarterfinal' THEN 0
WHEN 'semifinal' THEN 1
WHEN 'final' THEN 2
END,
s.bracket_position
`, seasonID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChampionshipSeries
for rows.Next() {
var cs ChampionshipSeries
var winnerID sql.NullString
if err := rows.Scan(&cs.ID, &cs.BotAID, &cs.BotAName, &cs.BotBID, &cs.BotBName,
&cs.Format, &cs.AWins, &cs.BWins, &cs.Status, &winnerID,
&cs.Round, &cs.BracketPosition); err != nil {
return nil, err
}
if winnerID.Valid {
cs.WinnerID = winnerID.String
}
result = append(result, cs)
}
// Fetch games for each series
for i := range result {
games, err := fetchSeriesGames(ctx, db, result[i].ID)
if err == nil {
result[i].Games = games
}
}
return result, nil
}
func fetchPredictions(ctx context.Context, db *sql.DB) ([]PredictionData, error) {
query := `
SELECT id, match_id, predictor_id, predicted_bot, correct, created_at, resolved_at
@ -505,10 +743,66 @@ func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) {
}
func computeTopPredictors(stats []PredictorStats) []PredictorStats {
// Sort by accuracy (correct / total)
// Already sorted in query, just return top 50
if len(stats) > 50 {
return stats[:50]
}
return stats
}
// persistPlaylists writes generated playlist definitions and their match associations
// to the playlists and playlist_matches tables. It uses upsert semantics so playlists
// are updated in place without creating duplicates.
func persistPlaylists(ctx context.Context, db *sql.DB, playlists []persistedPlaylist) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, pl := range playlists {
_, err := tx.ExecContext(ctx, `
INSERT INTO playlists (slug, title, description, category, is_auto, updated_at)
VALUES ($1, $2, $3, $4, TRUE, NOW())
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
category = EXCLUDED.category,
updated_at = NOW()
`, pl.Slug, pl.Title, pl.Description, pl.Category)
if err != nil {
return fmt.Errorf("persist playlist %s: %w", pl.Slug, err)
}
// Delete old match associations and re-insert
_, err = tx.ExecContext(ctx, `DELETE FROM playlist_matches WHERE playlist_slug = $1`, pl.Slug)
if err != nil {
return fmt.Errorf("clear playlist_matches for %s: %w", pl.Slug, err)
}
for _, pm := range pl.Matches {
_, err := tx.ExecContext(ctx, `
INSERT INTO playlist_matches (playlist_slug, match_id, sort_order, curation_tag)
VALUES ($1, $2, $3, $4)
`, pl.Slug, pm.MatchID, pm.SortOrder, pm.CurationTag)
if err != nil {
return fmt.Errorf("persist playlist_match %s/%s: %w", pl.Slug, pm.MatchID, err)
}
}
}
return tx.Commit()
}
type persistedPlaylist struct {
Slug string
Title string
Description string
Category string
Matches []persistedPlaylistMatch
}
type persistedPlaylistMatch struct {
MatchID string
SortOrder int
CurationTag string
}

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
@ -14,66 +15,52 @@ import (
)
// fetchExemptMatchIDs retrieves match IDs that should never be pruned (from series, seasons, playlists)
func fetchExemptMatchIDs(ctx context.Context, db *sql.DB) (map[string]bool, error) {
if db == nil {
return make(map[string]bool), nil
}
func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map[string]bool, error) {
exempt := make(map[string]bool)
// Matches in active series
seriesQuery := `
SELECT DISTINCT sm.match_id
FROM series_matches sm
JOIN series s ON sm.series_id = s.id
WHERE s.status IN ('active', 'pending')
`
rows, err := db.QueryContext(ctx, seriesQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
if db != nil {
// Matches in active series
seriesQuery := `
SELECT DISTINCT sm.match_id
FROM series_matches sm
JOIN series s ON sm.series_id = s.id
WHERE s.status IN ('active', 'pending')
`
rows, err := db.QueryContext(ctx, seriesQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
// Matches in active seasons
seasonQuery := `
SELECT DISTINCT match_id
FROM season_matches
WHERE season_id IN (
SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW()
)
`
rows, err = db.QueryContext(ctx, seasonQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
rows.Close()
}
// Matches in active seasons
seasonQuery := `
SELECT DISTINCT match_id
FROM season_matches
WHERE season_id IN (
SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW()
)
`
rows, err = db.QueryContext(ctx, seasonQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
// Matches in featured playlists
playlistQuery := `
SELECT DISTINCT pm.match_id
FROM playlist_matches pm
JOIN playlists p ON pm.playlist_id = p.id
WHERE p.featured = true
`
rows, err = db.QueryContext(ctx, playlistQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
// Matches in generated playlist files (file-based playlists from index builder)
playlistMatchIDs := fetchPlaylistMatchIDsFromFiles(outputDir)
for id := range playlistMatchIDs {
exempt[id] = true
}
slog.Debug("Fetched exempt match IDs for pruning", "count", len(exempt))
@ -164,7 +151,7 @@ func pruneR2CacheWithDB(ctx context.Context, cfg *Config, db *sql.DB) error {
// Get exempt match IDs if db is provided
exemptMatchIDs := make(map[string]bool)
if db != nil {
exemptMatchIDs, err = fetchExemptMatchIDs(ctx, db)
exemptMatchIDs, err = fetchExemptMatchIDs(ctx, db, cfg.OutputDir)
if err != nil {
slog.Warn("Failed to fetch exempt match IDs, will proceed without exemptions", "error", err)
}
@ -397,5 +384,47 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// fetchPlaylistMatchIDsFromFiles reads generated playlist JSON files from the
// output directory and returns all match IDs referenced in them. This replaces
// the old approach of querying non-existent playlist_matches DB tables.
func fetchPlaylistMatchIDsFromFiles(outputDir string) map[string]bool {
ids := make(map[string]bool)
playlistsDir := filepath.Join(outputDir, "data", "playlists")
entries, err := os.ReadDir(playlistsDir)
if err != nil {
return ids
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
// Skip index.json — only individual playlist files have match lists
if entry.Name() == "index.json" {
continue
}
data, err := os.ReadFile(filepath.Join(playlistsDir, entry.Name()))
if err != nil {
continue
}
var pl struct {
Matches []struct {
MatchID string `json:"match_id"`
} `json:"matches"`
}
if err := json.Unmarshal(data, &pl); err != nil {
continue
}
for _, m := range pl.Matches {
ids[m.MatchID] = true
}
}
return ids
}
// ensure valid function references
var _ = strings.Join

View file

@ -0,0 +1,523 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"strings"
"time"
)
// CommentaryEntry is one commentary subtitle in an enriched replay.
type CommentaryEntry struct {
Turn int `json:"turn"`
Text string `json:"text"`
Type string `json:"type"` // setup, action, reaction, climax, denouement
}
// EnrichedCommentary wraps all AI commentary for a single match.
type EnrichedCommentary struct {
MatchID string `json:"match_id"`
Generated string `json:"generated_at"`
Criteria []string `json:"criteria"` // why this match was selected
Entries []CommentaryEntry `json:"entries"`
}
// shouldEnrich returns true and lists criteria if the match qualifies for
// AI commentary enrichment per §13.3.
func shouldEnrich(m MatchData, data *IndexData) ([]string, bool) {
if len(m.Participants) < 2 || m.WinnerID == "" {
return nil, false
}
var criteria []string
// 1. Back-and-forth: win_prob crossed 0.5 at least 3 times
// We can't read the full replay here, so we use a proxy:
// close score + long match suggests back-and-forth
scoreDiff := minScoreDiff(m)
if scoreDiff <= 2 && m.TurnCount >= 200 {
criteria = append(criteria, "back_and_forth")
}
// 2. Upset: lower-rated bot wins by >200 rating points
if upset := ratingUpsetMagnitude(m); upset > 200 {
criteria = append(criteria, fmt.Sprintf("upset_%d", upset))
}
// 3. Evolution milestone: evolved bot's first top-10 appearance
for _, p := range m.Participants {
for _, bot := range data.Bots {
if bot.ID == p.BotID && bot.Evolved && p.Won {
rank := getBotRank(bot.ID, data)
if rank > 0 && rank <= 10 {
criteria = append(criteria, "evolution_milestone")
}
}
}
}
// 4. High interest score as a general qualifier
if score := interestScore(m); score >= 5.0 {
criteria = append(criteria, "high_interest")
}
return criteria, len(criteria) > 0
}
// enrichReplays selects featured matches from IndexData, downloads their
// replays from B2, generates AI commentary via LLM, and uploads the
// commentary JSON to R2 alongside the replay.
func enrichReplays(ctx context.Context, data *IndexData, cfg *Config, llm *LLMClient) error {
if llm == nil {
slog.Debug("No LLM client, skipping replay enrichment")
return nil
}
// Select matches eligible for enrichment
var candidates []struct {
match MatchData
criteria []string
}
for _, m := range data.Matches {
if crit, ok := shouldEnrich(m, data); ok {
candidates = append(candidates, struct {
match MatchData
criteria []string
}{match: m, criteria: crit})
}
}
if len(candidates) == 0 {
slog.Debug("No matches eligible for replay enrichment")
return nil
}
// Cap at 10 enrichments per cycle to control cost
if len(candidates) > 10 {
candidates = candidates[:10]
}
slog.Info("Enriching replays with AI commentary", "candidates", len(candidates))
for _, c := range candidates {
if ctx.Err() != nil {
return ctx.Err()
}
commentary, err := enrichSingleReplay(ctx, c.match, c.criteria, data, cfg, llm)
if err != nil {
slog.Error("Failed to enrich replay", "match_id", c.match.ID, "error", err)
continue
}
// Upload commentary to R2
if err := uploadCommentaryToR2(ctx, cfg, c.match.ID, commentary); err != nil {
slog.Error("Failed to upload commentary", "match_id", c.match.ID, "error", err)
continue
}
slog.Info("Enriched replay", "match_id", c.match.ID, "entries", len(commentary.Entries), "criteria", c.criteria)
}
return nil
}
// enrichSingleReplay downloads the replay from B2, extracts key moments,
// calls the LLM for commentary, and returns the EnrichedCommentary.
func enrichSingleReplay(ctx context.Context, m MatchData, criteria []string, data *IndexData, cfg *Config, llm *LLMClient) (*EnrichedCommentary, error) {
// Download replay from B2
replayJSON, err := downloadReplayFromB2(ctx, cfg, m.ID)
if err != nil {
return nil, fmt.Errorf("download replay: %w", err)
}
// Parse just enough of the replay for commentary context
var replay struct {
WinProb [][]float64 `json:"win_prob"`
CriticalMoments []struct {
Turn int `json:"turn"`
Delta float64 `json:"delta"`
Description string `json:"description"`
} `json:"critical_moments"`
Result struct {
Winner int `json:"winner"`
Reason string `json:"reason"`
Turns int `json:"turns"`
Scores []int `json:"scores"`
} `json:"result"`
Players []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"players"`
Turns []struct {
Turn int `json:"turn"`
Events []struct {
Type string `json:"type"`
Turn int `json:"turn"`
Details any `json:"details"`
} `json:"events"`
Scores []int `json:"scores"`
} `json:"turns"`
}
if err := json.Unmarshal(replayJSON, &replay); err != nil {
return nil, fmt.Errorf("parse replay: %w", err)
}
// Refine criteria using actual replay data
if len(replay.WinProb) > 0 {
crossings := countWinProbCrossings(replay.WinProb)
if crossings >= 3 {
// Add precise back-and-forth criterion if not already present via proxy
found := false
for _, c := range criteria {
if c == "back_and_forth" {
found = true
break
}
}
if !found {
criteria = append(criteria, fmt.Sprintf("back_and_forth_%d_crossings", crossings))
}
}
}
// Build the prompt with match context
prompt := buildCommentaryPrompt(m, replay, criteria, data)
// Call LLM
response, err := llm.chatCompletion(ctx, prompt)
if err != nil {
return nil, fmt.Errorf("llm commentary: %w", err)
}
// Parse the LLM response into commentary entries
entries := parseCommentaryResponse(response)
if len(entries) == 0 {
return nil, fmt.Errorf("no commentary entries generated")
}
return &EnrichedCommentary{
MatchID: m.ID,
Generated: time.Now().UTC().Format(time.RFC3339),
Criteria: criteria,
Entries: entries,
}, nil
}
// buildCommentaryPrompt creates the LLM prompt for commentary generation.
func buildCommentaryPrompt(m MatchData, replay struct {
WinProb [][]float64 `json:"win_prob"`
CriticalMoments []struct {
Turn int `json:"turn"`
Delta float64 `json:"delta"`
Description string `json:"description"`
} `json:"critical_moments"`
Result struct {
Winner int `json:"winner"`
Reason string `json:"reason"`
Turns int `json:"turns"`
Scores []int `json:"scores"`
} `json:"result"`
Players []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"players"`
Turns []struct {
Turn int `json:"turn"`
Events []struct {
Type string `json:"type"`
Turn int `json:"turn"`
Details any `json:"details"`
} `json:"events"`
Scores []int `json:"scores"`
} `json:"turns"`
}, criteria []string, data *IndexData) string {
var sb strings.Builder
sb.WriteString("You are an AI Code Battle commentator. Generate 5-15 lines of play-by-play commentary for this match.\n")
sb.WriteString("Each line must be exactly: TURN|TYPE|TEXT\n")
sb.WriteString("Where TYPE is one of: setup, action, reaction, climax, denouement\n")
sb.WriteString("Only cover key moments. Be dramatic but factual. No emojis. Keep each text under 120 chars.\n\n")
sb.WriteString("MATCH CONTEXT:\n")
playerNames := make([]string, len(replay.Players))
for i, p := range replay.Players {
playerNames[i] = p.Name
}
sb.WriteString(fmt.Sprintf("Players: %s\n", strings.Join(playerNames, " vs ")))
winnerName := "Draw"
if replay.Result.Winner >= 0 && replay.Result.Winner < len(replay.Players) {
winnerName = replay.Players[replay.Result.Winner].Name
}
sb.WriteString(fmt.Sprintf("Winner: %s by %s in %d turns\n", winnerName, replay.Result.Reason, replay.Result.Turns))
sb.WriteString(fmt.Sprintf("Final scores: %v\n", replay.Result.Scores))
sb.WriteString(fmt.Sprintf("Selection criteria: %s\n", strings.Join(criteria, ", ")))
// Pre-match ratings
for _, p := range m.Participants {
name := getBotName(p.BotID, data)
sb.WriteString(fmt.Sprintf(" %s: pre-match rating %d (evolved: %v)\n",
name, int(p.PreMatchRating), isEvolved(p.BotID, data)))
}
// Win probability summary
if len(replay.WinProb) > 0 {
crossings := countWinProbCrossings(replay.WinProb)
sb.WriteString(fmt.Sprintf("Win prob crossed 0.5: %d times\n", crossings))
// Biggest swing
maxSwing := 0.0
maxSwingTurn := 0
for i, wp := range replay.WinProb {
if len(wp) >= 2 {
swing := wp[0] - 0.5
if swing < 0 {
swing = -swing
}
if i > 0 {
prev := replay.WinProb[i-1]
if len(prev) >= 2 {
delta := wp[0] - prev[0]
if delta < 0 {
delta = -delta
}
if delta > maxSwing {
maxSwing = delta
maxSwingTurn = i
}
}
}
}
}
if maxSwing > 0.1 {
sb.WriteString(fmt.Sprintf("Biggest swing: %.0f%% at turn %d\n", maxSwing*100, maxSwingTurn))
}
}
// Critical moments
if len(replay.CriticalMoments) > 0 {
sb.WriteString("Critical moments:\n")
for _, cm := range replay.CriticalMoments {
sb.WriteString(fmt.Sprintf(" Turn %d: %s (delta %.0f%%)\n", cm.Turn, cm.Description, cm.Delta*100))
}
}
// Key events (cores captured, mass deaths) - scan at most every 10th turn
sb.WriteString("Key events:\n")
step := max(1, len(replay.Turns)/30)
for i := 0; i < len(replay.Turns); i += step {
t := replay.Turns[i]
for _, e := range t.Events {
if e.Type == "core_captured" || e.Type == "combat_death" {
sb.WriteString(fmt.Sprintf(" Turn %d: %s\n", t.Turn, e.Type))
}
}
}
sb.WriteString("\nGenerate commentary now. One entry per line, format: TURN|TYPE|TEXT\n")
return sb.String()
}
// parseCommentaryResponse converts the LLM text output into CommentaryEntry slice.
func parseCommentaryResponse(response string) []CommentaryEntry {
var entries []CommentaryEntry
for _, line := range strings.Split(response, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
parts := strings.SplitN(line, "|", 3)
if len(parts) != 3 {
continue
}
var turn int
if _, err := fmt.Sscanf(strings.TrimSpace(parts[0]), "%d", &turn); err != nil {
continue
}
entryType := strings.TrimSpace(parts[1])
text := strings.TrimSpace(parts[2])
validTypes := map[string]bool{
"setup": true, "action": true, "reaction": true,
"climax": true, "denouement": true,
}
if !validTypes[entryType] {
entryType = "action"
}
entries = append(entries, CommentaryEntry{
Turn: turn,
Text: text,
Type: entryType,
})
}
return entries
}
// countWinProbCrossings counts how many times p0's win_prob crosses 0.5.
func countWinProbCrossings(winProb [][]float64) int {
if len(winProb) < 2 {
return 0
}
crossings := 0
for i := 1; i < len(winProb); i++ {
prev := winProb[i-1]
cur := winProb[i]
if len(prev) >= 1 && len(cur) >= 1 {
if (prev[0] < 0.5 && cur[0] >= 0.5) || (prev[0] >= 0.5 && cur[0] < 0.5) {
crossings++
}
}
}
return crossings
}
// downloadReplayFromB2 downloads a replay JSON from B2, handling .json.gz.
func downloadReplayFromB2(ctx context.Context, cfg *Config, matchID string) ([]byte, error) {
b2Client, err := getB2Client(cfg)
if err != nil {
return nil, fmt.Errorf("create B2 client: %w", err)
}
// Try .json.gz first (standard format)
key := fmt.Sprintf("replays/%s.json.gz", matchID)
body, err := b2Client.downloadObject(ctx, key)
if err != nil {
// Try uncompressed
key = fmt.Sprintf("replays/%s.json", matchID)
body, err = b2Client.downloadObject(ctx, key)
if err != nil {
return nil, fmt.Errorf("download replay %s: %w", matchID, err)
}
}
defer body.Close()
raw, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("read replay body: %w", err)
}
// Decompress gzip if needed
if len(raw) > 2 && raw[0] == 0x1f && raw[1] == 0x8b {
gzReader, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
defer gzReader.Close()
decompressed, err := io.ReadAll(gzReader)
if err != nil {
return nil, fmt.Errorf("gzip decompress: %w", err)
}
return decompressed, nil
}
return raw, nil
}
// uploadCommentaryToR2 uploads the enriched commentary JSON to R2.
func uploadCommentaryToR2(ctx context.Context, cfg *Config, matchID string, commentary *EnrichedCommentary) error {
r2Client, err := getR2Client(cfg)
if err != nil {
return fmt.Errorf("create R2 client: %w", err)
}
data, err := json.Marshal(commentary)
if err != nil {
return fmt.Errorf("marshal commentary: %w", err)
}
key := fmt.Sprintf("commentary/%s.json", matchID)
return r2Client.uploadFile(ctx, key, bytes.NewReader(data), "application/json")
}
// isEvolved checks if a bot is an evolved bot.
func isEvolved(botID string, data *IndexData) bool {
for _, bot := range data.Bots {
if bot.ID == botID {
return bot.Evolved
}
}
return false
}
// generateEnrichedIndex creates data/commentary/index.json listing enriched match IDs.
// The frontend uses this to discover which matches have AI commentary available.
func generateEnrichedIndex(ctx context.Context, data *IndexData, cfg *Config, outputDir string) error {
commentaryDir := filepath.Join(outputDir, "data", "commentary")
type enrichedEntry struct {
MatchID string `json:"match_id"`
Criteria []string `json:"criteria"`
}
// Check R2 for existing commentary files
r2Client, err := getR2Client(cfg)
if err != nil {
slog.Debug("Cannot list R2 commentary, skipping enriched index", "error", err)
return nil
}
objects, err := r2Client.listObjects(ctx, "commentary/")
if err != nil {
slog.Debug("Failed to list commentary objects", "error", err)
return nil
}
var entries []enrichedEntry
for _, obj := range objects {
if !strings.HasSuffix(obj.Key, ".json") {
continue
}
// Extract match_id from commentary/{match_id}.json
matchID := strings.TrimPrefix(obj.Key, "commentary/")
matchID = strings.TrimSuffix(matchID, ".json")
if matchID == "" || matchID == "index" {
continue
}
// Try to read criteria from the commentary file
criteria := []string{}
body, err := r2Client.downloadObject(ctx, obj.Key)
if err == nil {
var comm EnrichedCommentary
if json.NewDecoder(body).Decode(&comm) == nil {
criteria = comm.Criteria
}
body.Close()
}
entries = append(entries, enrichedEntry{
MatchID: matchID,
Criteria: criteria,
})
}
if len(entries) == 0 {
return nil
}
type enrichedIndex struct {
UpdatedAt string `json:"updated_at"`
Entries []enrichedEntry `json:"entries"`
}
index := enrichedIndex{
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Entries: entries,
}
return writeJSON(filepath.Join(commentaryDir, "index.json"), index)
}

View file

@ -1,10 +1,13 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@ -89,7 +92,7 @@ type MatchIndex struct {
}
// generateAllIndexes creates all JSON index files
func generateAllIndexes(data *IndexData, outputDir string) error {
func generateAllIndexes(data *IndexData, outputDir string, db *sql.DB) error {
botNameMap := make(map[string]string)
for _, bot := range data.Bots {
botNameMap[bot.ID] = bot.Name
@ -135,6 +138,14 @@ func generateAllIndexes(data *IndexData, outputDir string) error {
return fmt.Errorf("playlists: %w", err)
}
// Persist playlists to DB for incremental queries and R2 pruning exemptions
if db != nil {
if err := persistGeneratedPlaylists(context.Background(), db, outputDir); err != nil {
// Non-fatal: playlists are still written as JSON files
fmt.Fprintf(os.Stderr, "persist playlists to DB: %v\n", err)
}
}
return nil
}
@ -301,6 +312,14 @@ func matchToSummary(m MatchData, data *IndexData) MatchSummary {
}
func generateSeriesIndex(data *IndexData, outputDir string) error {
seriesDir := filepath.Join(outputDir, "data", "series")
for _, s := range data.Series {
if err := writeJSON(filepath.Join(seriesDir, fmt.Sprintf("%d.json", s.ID)), s); err != nil {
return err
}
}
type SeriesIndex struct {
UpdatedAt string `json:"updated_at"`
Series []SeriesData `json:"series"`
@ -311,21 +330,39 @@ func generateSeriesIndex(data *IndexData, outputDir string) error {
Series: data.Series,
}
return writeJSON(filepath.Join(outputDir, "data", "series", "index.json"), index)
return writeJSON(filepath.Join(seriesDir, "index.json"), index)
}
func generateSeasonsIndex(data *IndexData, outputDir string) error {
seasonsDir := filepath.Join(outputDir, "data", "seasons")
for _, s := range data.Seasons {
if err := writeJSON(filepath.Join(seasonsDir, fmt.Sprintf("%d.json", s.ID)), s); err != nil {
return err
}
}
var activeSeason *SeasonData
for i := range data.Seasons {
if data.Seasons[i].Status == "active" {
activeSeason = &data.Seasons[i]
break
}
}
type SeasonsIndex struct {
UpdatedAt string `json:"updated_at"`
Seasons []SeasonData `json:"seasons"`
UpdatedAt string `json:"updated_at"`
ActiveSeason *SeasonData `json:"active_season"`
Seasons []SeasonData `json:"seasons"`
}
index := SeasonsIndex{
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Seasons: data.Seasons,
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
ActiveSeason: activeSeason,
Seasons: data.Seasons,
}
return writeJSON(filepath.Join(outputDir, "data", "seasons", "index.json"), index)
return writeJSON(filepath.Join(seasonsDir, "index.json"), index)
}
func generatePredictionsIndex(data *IndexData, outputDir string) error {
@ -345,12 +382,524 @@ func generatePredictionsIndex(data *IndexData, outputDir string) error {
func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error {
playlistsDir := filepath.Join(outputDir, "data", "playlists")
// Closest finishes: matches with smallest score differential
closest := filterMatches(data.Matches, func(m MatchData) bool {
if len(m.Participants) < 2 {
return false
type playlistDef struct {
slug string
title string
description string
category string
filter func(MatchData) bool
sort func([]MatchData)
}
defs := []playlistDef{
{
slug: "closest-finishes",
title: "Closest Finishes",
description: "Matches decided by the thinnest margins — nail-biters to the very end",
category: "close_games",
filter: func(m MatchData) bool {
if len(m.Participants) < 2 || m.WinnerID == "" {
return false
}
return minScoreDiff(m) <= 2
},
sort: func(matches []MatchData) {
sortByScoreDiff(matches)
},
},
{
slug: "biggest-upsets",
title: "Biggest Upsets",
description: "Lower-rated bots triumph against higher-rated opponents",
category: "upsets",
filter: func(m MatchData) bool {
if m.WinnerID == "" || len(m.Participants) < 2 {
return false
}
return ratingUpsetMagnitude(m) >= 100
},
sort: func(matches []MatchData) {
sortByUpsetMagnitude(matches)
},
},
{
slug: "best-comebacks",
title: "Best Comebacks",
description: "Bots that were down but never out — dramatic turnarounds and improbable victories",
category: "comebacks",
filter: func(m MatchData) bool {
return isComeback(m)
},
sort: func(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return turnaroundMagnitude(matches[i]) > turnaroundMagnitude(matches[j])
})
},
},
{
slug: "marathon-matches",
title: "Marathon Matches",
description: "The longest, most grueling matches — endurance-tested battles",
category: "long_games",
filter: func(m MatchData) bool {
return m.TurnCount >= 300
},
sort: func(matches []MatchData) {
sortByTurnCount(matches)
},
},
{
slug: "highest-rated",
title: "Clash of Titans",
description: "Matches between the highest-rated opponents on the ladder",
category: "featured",
filter: func(m MatchData) bool {
if len(m.Participants) < 2 {
return false
}
return combinedRating(m) >= 3200
},
sort: func(matches []MatchData) {
sortByCombinedRating(matches)
},
},
{
slug: "evolution-breakthroughs",
title: "Evolution Breakthroughs",
description: "Evolved bots defeating top-rated opponents — AI strategy milestones",
category: "featured",
filter: func(m MatchData) bool {
return isEvolutionBreakthrough(m, data)
},
sort: func(matches []MatchData) {
sortByUpsetMagnitude(matches)
},
},
{
slug: "rivalry-classics",
title: "Rivalry Classics",
description: "The most closely contested matchups between frequent opponents",
category: "rivalry",
filter: func(m MatchData) bool {
return isRivalryMatch(m, data)
},
sort: func(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return minScoreDiff(matches[i]) < minScoreDiff(matches[j])
})
},
},
{
slug: "domination",
title: "Total Domination",
description: "One-sided victories where the winner crushed all opposition",
category: "domination",
filter: func(m MatchData) bool {
if m.WinnerID == "" || len(m.Participants) < 2 {
return false
}
return maxScoreDiff(m) >= 5
},
sort: func(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return maxScoreDiff(matches[i]) > maxScoreDiff(matches[j])
})
},
},
{
slug: "new-bot-debuts",
title: "New Bot Debuts",
description: "First matches of newly registered bots — watch their opening games",
category: "tutorial",
filter: func(m MatchData) bool {
return isNewBotDebut(m, data)
},
sort: func(matches []MatchData) {
// Newest debuts first
sortSlice(matches, func(i, j int) bool {
return matches[i].CompletedAt.After(matches[j].CompletedAt)
})
},
},
{
slug: "season-highlights",
title: "Season Highlights",
description: "Top matches from the current season ranked by excitement",
category: "season",
filter: func(m MatchData) bool {
return isCurrentSeasonMatch(m, data)
},
sort: func(matches []MatchData) {
sortByInterestScore(matches)
},
},
{
slug: "featured",
title: "Featured Matches",
description: "Recent highlights from the ladder",
category: "featured",
filter: func(m MatchData) bool {
return m.WinnerID != ""
},
sort: func(matches []MatchData) {
// Most recent first (already sorted by completed_at DESC from DB)
},
},
{
slug: "best-of-week",
title: "Best of the Week",
description: "This week's top matches ranked by excitement: close finishes, upsets, marathon battles, and elite clashes",
category: "weekly",
filter: func(m MatchData) bool {
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
return m.CompletedAt.After(weekAgo) && m.WinnerID != ""
},
sort: func(matches []MatchData) {
sortByInterestScore(matches)
},
},
}
var summaries []PlaylistSummary
for _, def := range defs {
// Special handling for best-of-week: use curated selection with tags
if def.slug == "best-of-week" {
weekAgo := data.GeneratedAt.AddDate(0, 0, -7)
curated := curateWeeklyHighlights(data.Matches, weekAgo)
curatedMatches := make([]MatchData, 0, len(curated))
tags := make(map[string]string, len(curated))
for _, c := range curated {
curatedMatches = append(curatedMatches, c.Match)
tags[c.Match.ID] = c.Tag
}
if err := writePlaylistWithTags(playlistsDir, def.slug+".json", def.title, def.description, def.category, curatedMatches, tags, data); err != nil {
return err
}
var thumbMatchID string
if len(curatedMatches) > 0 {
thumbMatchID = curatedMatches[0].ID
}
summaries = append(summaries, PlaylistSummary{
Slug: def.slug,
Title: def.title,
Description: def.description,
Category: def.category,
MatchCount: len(curatedMatches),
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
ThumbnailMatchID: thumbMatchID,
})
continue
}
// Check if score difference is small (1-2 points)
filtered := filterMatches(data.Matches, def.filter)
if def.sort != nil {
def.sort(filtered)
}
filtered = filtered[:min(20, len(filtered))]
if err := writePlaylist(playlistsDir, def.slug+".json", def.title, def.description, def.category, filtered, data); err != nil {
return err
}
var thumbMatchID string
if len(filtered) > 0 {
thumbMatchID = filtered[0].ID
}
summaries = append(summaries, PlaylistSummary{
Slug: def.slug,
Title: def.title,
Description: def.description,
Category: def.category,
MatchCount: len(filtered),
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
ThumbnailMatchID: thumbMatchID,
})
}
index := PlaylistIndex{
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Playlists: summaries,
}
return writeJSON(filepath.Join(playlistsDir, "index.json"), index)
}
type PlaylistIndex struct {
UpdatedAt string `json:"updated_at"`
Playlists []PlaylistSummary `json:"playlists"`
}
type PlaylistSummary struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
MatchCount int `json:"match_count"`
UpdatedAt string `json:"updated_at"`
ThumbnailMatchID string `json:"thumbnail_match_id,omitempty"`
}
type Playlist struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
MatchCount int `json:"match_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Matches []PlaylistMatch `json:"matches"`
}
type PlaylistMatch struct {
MatchID string `json:"match_id"`
Order int `json:"order"`
Title string `json:"title,omitempty"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
CurationTag string `json:"curation_tag,omitempty"`
Participants []MatchParticipantSummary `json:"participants,omitempty"`
Score string `json:"score,omitempty"`
Turns int `json:"turns,omitempty"`
EndReason string `json:"end_reason,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// curatedWeeklyMatch is a match selected by the weekly curation algorithm
// with a tag explaining why it was selected.
type curatedWeeklyMatch struct {
Match MatchData
Tag string
}
// curateWeeklyHighlights selects the best matches from the past 7 days
// using explicit criteria: upsets, elite clashes, marathon battles, closest finishes.
// It processes specific criteria first so distinctive matches aren't consumed
// by generic tags. It returns deduplicated matches tagged with their selection reason.
func curateWeeklyHighlights(matches []MatchData, cutoff time.Time) []curatedWeeklyMatch {
seen := make(map[string]string) // match_id -> tag (first selection reason)
maxPerCriterion := 7
recent := filterMatches(matches, func(m MatchData) bool {
return m.CompletedAt.After(cutoff) && m.WinnerID != "" && len(m.Participants) >= 2
})
// 1. Biggest upsets first (most distinctive — underdog victories)
upsetMatches := make([]MatchData, len(recent))
copy(upsetMatches, recent)
sortByUpsetMagnitude(upsetMatches)
for i, m := range upsetMatches {
if i >= maxPerCriterion {
break
}
mag := ratingUpsetMagnitude(m)
if mag < 50 {
continue
}
if _, exists := seen[m.ID]; !exists {
seen[m.ID] = fmt.Sprintf("Upset victory (underdog by %d rating)", mag)
}
}
// 2. Highest-rated opponents (elite clashes)
ratedMatches := make([]MatchData, len(recent))
copy(ratedMatches, recent)
sortByCombinedRating(ratedMatches)
for i, m := range ratedMatches {
if i >= maxPerCriterion {
break
}
cr := int(combinedRating(m))
if cr < 3000 {
continue
}
if _, exists := seen[m.ID]; !exists {
seen[m.ID] = fmt.Sprintf("Elite clash (combined rating: %d)", cr)
}
}
// 3. Most turns (longest endurance battles)
longMatches := make([]MatchData, len(recent))
copy(longMatches, recent)
sortByTurnCount(longMatches)
for i, m := range longMatches {
if i >= maxPerCriterion {
break
}
if m.TurnCount < 300 {
continue
}
if _, exists := seen[m.ID]; !exists {
seen[m.ID] = fmt.Sprintf("Marathon battle (%d turns)", m.TurnCount)
}
}
// 4. Closest results last (most generic — fills remaining slots)
closeMatches := make([]MatchData, len(recent))
copy(closeMatches, recent)
sortByScoreDiff(closeMatches)
for i, m := range closeMatches {
if i >= maxPerCriterion {
break
}
diff := minScoreDiff(m)
if _, exists := seen[m.ID]; !exists {
seen[m.ID] = fmt.Sprintf("Closest finish (score diff: %d)", diff)
}
}
// Build result in criterion order: upsets, elite, marathon, closest
var result []curatedWeeklyMatch
for _, m := range recent {
if tag, ok := seen[m.ID]; ok {
result = append(result, curatedWeeklyMatch{Match: m, Tag: tag})
}
}
if len(result) > 20 {
result = result[:20]
}
return result
}
func writePlaylist(dir, filename, title, description, category string, matches []MatchData, data *IndexData) error {
slug := filename[:len(filename)-5]
pm := make([]PlaylistMatch, 0, len(matches))
for i, m := range matches {
pm = append(pm, buildPlaylistMatch(m, i, data, ""))
}
playlist := Playlist{
Slug: slug,
Title: title,
Description: description,
Category: category,
MatchCount: len(pm),
CreatedAt: data.GeneratedAt.Format(time.RFC3339),
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Matches: pm,
}
return writeJSON(filepath.Join(dir, filename), playlist)
}
func writePlaylistWithTags(dir, filename, title, description, category string, matches []MatchData, tags map[string]string, data *IndexData) error {
slug := filename[:len(filename)-5]
pm := make([]PlaylistMatch, 0, len(matches))
for i, m := range matches {
pm = append(pm, buildPlaylistMatch(m, i, data, tags[m.ID]))
}
playlist := Playlist{
Slug: slug,
Title: title,
Description: description,
Category: category,
MatchCount: len(pm),
CreatedAt: data.GeneratedAt.Format(time.RFC3339),
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Matches: pm,
}
return writeJSON(filepath.Join(dir, filename), playlist)
}
func formatMatchTitle(m MatchData, data *IndexData) string {
names := make([]string, 0, len(m.Participants))
scores := make([]int, 0, len(m.Participants))
for _, p := range m.Participants {
name := "Unknown"
for _, bot := range data.Bots {
if bot.ID == p.BotID {
name = bot.Name
break
}
}
names = append(names, name)
scores = append(scores, p.Score)
}
if len(names) == 2 {
return fmt.Sprintf("%s %d %d %s", names[0], scores[0], scores[1], names[1])
}
return fmt.Sprintf("%s (%d players)", m.ID[:min(8, len(m.ID))], len(names))
}
func buildPlaylistMatch(m MatchData, order int, data *IndexData, curationTag string) PlaylistMatch {
participants := make([]MatchParticipantSummary, 0, len(m.Participants))
scoreParts := make([]string, 0, len(m.Participants))
for _, p := range m.Participants {
name := "Unknown"
for _, bot := range data.Bots {
if bot.ID == p.BotID {
name = bot.Name
break
}
}
participants = append(participants, MatchParticipantSummary{
BotID: p.BotID,
Name: name,
Score: p.Score,
Won: p.BotID == m.WinnerID,
})
scoreParts = append(scoreParts, fmt.Sprintf("%d", p.Score))
}
title := formatMatchTitle(m, data)
completedAt := ""
if !m.CompletedAt.IsZero() {
completedAt = m.CompletedAt.Format(time.RFC3339)
}
return PlaylistMatch{
MatchID: m.ID,
Order: order,
Title: title,
CurationTag: curationTag,
Participants: participants,
Score: strings.Join(scoreParts, "-"),
Turns: m.TurnCount,
EndReason: m.EndCondition,
CompletedAt: completedAt,
}
}
func ratingUpsetMagnitude(m MatchData) int {
if m.WinnerID == "" || len(m.Participants) < 2 {
return 0
}
var winnerRating, bestLoserRating float64
found := false
for _, p := range m.Participants {
if p.BotID == m.WinnerID {
winnerRating = p.PreMatchRating
found = true
}
}
if !found || winnerRating == 0 {
return 0
}
for _, p := range m.Participants {
if p.BotID != m.WinnerID && p.PreMatchRating > bestLoserRating {
bestLoserRating = p.PreMatchRating
}
}
if bestLoserRating == 0 {
return 0
}
return int(bestLoserRating - winnerRating)
}
func combinedRating(m MatchData) float64 {
total := 0.0
for _, p := range m.Participants {
total += p.PreMatchRating
}
return total
}
func interestScore(m MatchData) float64 {
score := 0.0
// Close finishes are interesting
if len(m.Participants) >= 2 {
minDiff := 999
for i, p1 := range m.Participants {
for _, p2 := range m.Participants[i+1:] {
@ -360,66 +909,86 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]
}
}
}
return minDiff <= 2
})
if err := writePlaylist(playlistsDir, "closest-finishes.json", "Closest Finishes", closest, data); err != nil {
return err
}
// Biggest upsets: lower-rated bot won
// This would need rating data at match time, simplified here
upsets := filterMatches(data.Matches, func(m MatchData) bool {
// Simplified: check if winner had fewer wins overall
if m.WinnerID == "" {
return false
if minDiff <= 1 {
score += 3.0
} else if minDiff <= 2 {
score += 2.0
}
return true // Placeholder - would need actual rating delta
})
if err := writePlaylist(playlistsDir, "biggest-upsets.json", "Biggest Upsets", upsets[:min(20, len(upsets))], data); err != nil {
return err
}
// Best comebacks: winner had low win probability at some point
// Would need win probability data - placeholder
comebacks := filterMatches(data.Matches, func(m MatchData) bool {
return false // Placeholder - needs win_prob data
})
if err := writePlaylist(playlistsDir, "best-comebacks.json", "Best Comebacks", comebacks, data); err != nil {
return err
// Upsets are interesting
upset := ratingUpsetMagnitude(m)
if upset >= 200 {
score += 4.0
} else if upset >= 100 {
score += 2.0
}
// Featured: recent high-profile matches
featured := data.Matches[:min(20, len(data.Matches))]
if err := writePlaylist(playlistsDir, "featured.json", "Featured Matches", featured, data); err != nil {
return err
// Long matches are interesting
if m.TurnCount >= 400 {
score += 2.0
} else if m.TurnCount >= 300 {
score += 1.0
}
return nil
// High-rated opponents
cr := combinedRating(m)
if cr >= 3400 {
score += 2.0
} else if cr >= 3200 {
score += 1.0
}
return score
}
type Playlist struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
UpdatedAt string `json:"updated_at"`
Matches []MatchSummary `json:"matches"`
func sortByScoreDiff(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return minScoreDiff(matches[i]) < minScoreDiff(matches[j])
})
}
func writePlaylist(dir, filename, title string, matches []MatchData, data *IndexData) error {
summaries := make([]MatchSummary, 0, len(matches))
for _, m := range matches {
summaries = append(summaries, matchToSummary(m, data))
}
func sortByUpsetMagnitude(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return ratingUpsetMagnitude(matches[i]) > ratingUpsetMagnitude(matches[j])
})
}
playlist := Playlist{
Slug: filename[:len(filename)-5], // remove .json
Title: title,
Description: fmt.Sprintf("Auto-curated playlist: %s", title),
UpdatedAt: data.GeneratedAt.Format(time.RFC3339),
Matches: summaries,
}
func sortByTurnCount(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return matches[i].TurnCount > matches[j].TurnCount
})
}
return writeJSON(filepath.Join(dir, filename), playlist)
func sortByCombinedRating(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return combinedRating(matches[i]) > combinedRating(matches[j])
})
}
func sortByInterestScore(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
return interestScore(matches[i]) > interestScore(matches[j])
})
}
func minScoreDiff(m MatchData) int {
minDiff := 999
for i, p1 := range m.Participants {
for _, p2 := range m.Participants[i+1:] {
diff := abs(p1.Score - p2.Score)
if diff < minDiff {
minDiff = diff
}
}
}
return minDiff
}
func sortSlice(s []MatchData, less func(i, j int) bool) {
for i := 0; i < len(s)-1; i++ {
for j := i + 1; j < len(s); j++ {
if less(j, i) {
s[i], s[j] = s[j], s[i]
}
}
}
}
func filterMatches(matches []MatchData, pred func(MatchData) bool) []MatchData {
@ -432,6 +1001,148 @@ func filterMatches(matches []MatchData, pred func(MatchData) bool) []MatchData {
return result
}
// maxScoreDiff returns the maximum score difference between winner and any loser
func maxScoreDiff(m MatchData) int {
if m.WinnerID == "" || len(m.Participants) < 2 {
return 0
}
var winnerScore int
for _, p := range m.Participants {
if p.BotID == m.WinnerID {
winnerScore = p.Score
break
}
}
maxDiff := 0
for _, p := range m.Participants {
if p.BotID != m.WinnerID {
diff := winnerScore - p.Score
if diff > maxDiff {
maxDiff = diff
}
}
}
return maxDiff
}
// isNewBotDebut detects the first match of each bot by finding the earliest
// completed match for each bot.
func isNewBotDebut(m MatchData, data *IndexData) bool {
if m.WinnerID == "" {
return false
}
for _, p := range m.Participants {
earliest := true
for _, other := range data.Matches {
if other.ID == m.ID || other.CompletedAt.IsZero() {
continue
}
for _, op := range other.Participants {
if op.BotID == p.BotID {
if other.CompletedAt.Before(m.CompletedAt) {
earliest = false
}
}
}
}
if earliest {
return true
}
}
return false
}
// isCurrentSeasonMatch checks if a match belongs to the current active season.
func isCurrentSeasonMatch(m MatchData, data *IndexData) bool {
for _, s := range data.Seasons {
if s.Status != "active" {
continue
}
// Check if match falls within season date range
if m.CompletedAt.After(s.StartsAt) || m.CompletedAt.Equal(s.StartsAt) {
if s.EndsAt.IsZero() || m.CompletedAt.Before(s.EndsAt) {
return m.WinnerID != ""
}
}
}
return false
}
// isComeback detects matches where the winner was behind on score at some point
// but rallied to win. Uses a heuristic: winner scored more than loser despite
// having a lower pre-match rating (unlikely comeback) or the match had many turns
// (late-game rally) with a close final score.
func isComeback(m MatchData) bool {
if m.WinnerID == "" || len(m.Participants) < 2 {
return false
}
// An upset with a close score is a comeback
upset := ratingUpsetMagnitude(m)
scoreDiff := minScoreDiff(m)
return upset >= 80 && scoreDiff <= 3
}
// turnaroundMagnitude measures how dramatic a comeback was.
// Higher = more surprising turnaround.
func turnaroundMagnitude(m MatchData) float64 {
upset := float64(ratingUpsetMagnitude(m))
closeFactor := 1.0 / float64(max(minScoreDiff(m), 1))
turnFactor := float64(m.TurnCount) / 500.0
return upset*closeFactor + turnFactor*50
}
// isEvolutionBreakthrough detects matches where an evolved bot beat a high-rated opponent.
func isEvolutionBreakthrough(m MatchData, data *IndexData) bool {
if m.WinnerID == "" || len(m.Participants) < 2 {
return false
}
winnerEvolved := false
for _, bot := range data.Bots {
if bot.ID == m.WinnerID && bot.Evolved {
winnerEvolved = true
}
}
if !winnerEvolved {
return false
}
// Winner must have beaten someone rated >= 1600
for _, p := range m.Participants {
if p.BotID != m.WinnerID && p.PreMatchRating >= 1600 && !p.Won {
return true
}
}
return false
}
// isRivalryMatch detects matches between bots that have played each other frequently.
// Builds a frequency map from all matches and checks if this pair qualifies.
func isRivalryMatch(m MatchData, data *IndexData) bool {
if len(m.Participants) != 2 || m.WinnerID == "" {
return false
}
a, b := m.Participants[0].BotID, m.Participants[1].BotID
if a > b {
a, b = b, a
}
pairKey := a + ":" + b
// Count occurrences of this pair across all matches
count := 0
for _, other := range data.Matches {
if len(other.Participants) != 2 {
continue
}
oa, ob := other.Participants[0].BotID, other.Participants[1].BotID
if oa > ob {
oa, ob = ob, oa
}
if oa+":"+ob == pairKey {
count++
}
}
return count >= 3
}
func writeJSON(path string, data interface{}) error {
f, err := os.Create(path)
if err != nil {
@ -445,6 +1156,52 @@ func writeJSON(path string, data interface{}) error {
return enc.Encode(data)
}
// persistGeneratedPlaylists reads the generated playlist JSON files from the output
// directory and writes them to the playlists and playlist_matches DB tables.
func persistGeneratedPlaylists(ctx context.Context, db *sql.DB, outputDir string) error {
playlistsDir := filepath.Join(outputDir, "data", "playlists")
indexContent, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
if err != nil {
return fmt.Errorf("read playlist index: %w", err)
}
var index PlaylistIndex
if err := json.Unmarshal(indexContent, &index); err != nil {
return fmt.Errorf("parse playlist index: %w", err)
}
var persisted []persistedPlaylist
for _, summary := range index.Playlists {
plContent, err := os.ReadFile(filepath.Join(playlistsDir, summary.Slug+".json"))
if err != nil {
continue // skip playlists without files
}
var pl Playlist
if err := json.Unmarshal(plContent, &pl); err != nil {
continue
}
matches := make([]persistedPlaylistMatch, 0, len(pl.Matches))
for _, m := range pl.Matches {
matches = append(matches, persistedPlaylistMatch{
MatchID: m.MatchID,
SortOrder: m.Order,
CurationTag: m.CurationTag,
})
}
persisted = append(persisted, persistedPlaylist{
Slug: pl.Slug,
Title: pl.Title,
Description: pl.Description,
Category: pl.Category,
Matches: matches,
})
}
return persistPlaylists(ctx, db, persisted)
}
func round1(v float64) float64 {
return float64(int(v*10+0.5)) / 10
}

View file

@ -127,6 +127,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
cfg.OutputDir + "/data/meta",
cfg.OutputDir + "/data/evolution",
cfg.OutputDir + "/data/blog",
cfg.OutputDir + "/data/commentary",
cfg.OutputDir + "/cards",
}
for _, dir := range dirs {
@ -151,7 +152,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
}
// Generate all index files
if err := generateAllIndexes(data, cfg.OutputDir); err != nil {
if err := generateAllIndexes(data, cfg.OutputDir, db); err != nil {
return fmt.Errorf("generate indexes: %w", err)
}
@ -160,7 +161,7 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
if cfg.LLMBaseURL != "" {
llmClient = NewLLMClient(cfg.LLMBaseURL, cfg.LLMAPIKey)
}
if err := generateBlog(data, cfg.OutputDir, llmClient); err != nil {
if err := generateBlog(data, cfg.OutputDir, llmClient, cfg); err != nil {
slog.Error("Failed to generate blog", "error", err)
// Non-fatal - continue with rest of build
}
@ -189,6 +190,18 @@ func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
// Non-fatal
}
// Enrich featured replays with AI commentary (§13.3)
if err := enrichReplays(ctx, data, cfg, llmClient); err != nil {
slog.Error("Failed to enrich replays", "error", err)
// Non-fatal - unenriched replays are still valid
}
// Generate enriched commentary index (list of matches with AI commentary)
if err := generateEnrichedIndex(ctx, data, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to generate enriched index", "error", err)
// Non-fatal
}
return nil
}
@ -206,7 +219,7 @@ func promoteRecentReplaysForCycle(ctx context.Context, db *sql.DB, cfg *Config)
}
// Get exempt match IDs (playlists, series, seasons)
exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db)
exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db, cfg.OutputDir)
if err != nil {
slog.Warn("Failed to fetch exempt match IDs, promoting all", "error", err)
exemptMatchIDs = make(map[string]bool)

View file

@ -6,6 +6,7 @@ import (
"image/color"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@ -248,18 +249,29 @@ func TestGeneratePlaylists(t *testing.T) {
CompletedAt: now,
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 2, Won: false}, // Close finish (diff = 1)
{BotID: "bot2", Score: 2, Won: false},
},
},
{
ID: "match2",
WinnerID: "bot2",
TurnCount: 150,
TurnCount: 350,
EndCondition: "dominance",
CompletedAt: now.Add(-time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 0, Won: false},
{BotID: "bot2", Score: 10, Won: true}, // Not close (diff = 10)
{BotID: "bot1", Score: 0, Won: false, PreMatchRating: 1800},
{BotID: "bot2", Score: 10, Won: true, PreMatchRating: 1500},
},
},
{
ID: "match3",
WinnerID: "bot1",
TurnCount: 400,
EndCondition: "turn_limit",
CompletedAt: now.Add(-2 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1700},
{BotID: "bot2", Score: 3, Won: false, PreMatchRating: 1600},
},
},
},
@ -276,20 +288,565 @@ func TestGeneratePlaylists(t *testing.T) {
t.Fatalf("generatePlaylists failed: %v", err)
}
// Check closest-finishes playlist
// Verify index.json was generated
indexContent, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
if err != nil {
t.Fatalf("Failed to read playlists/index.json: %v", err)
}
var index PlaylistIndex
if err := json.Unmarshal(indexContent, &index); err != nil {
t.Fatalf("Failed to parse playlists/index.json: %v", err)
}
if len(index.Playlists) == 0 {
t.Error("Expected playlists in index.json, got 0")
}
// Verify each playlist has required fields
for _, p := range index.Playlists {
if p.Slug == "" {
t.Error("Playlist summary missing slug")
}
if p.Title == "" {
t.Error("Playlist summary missing title")
}
if p.Category == "" {
t.Error("Playlist summary missing category")
}
}
// Verify closest-finishes playlist content
content, err := os.ReadFile(filepath.Join(playlistsDir, "closest-finishes.json"))
if err != nil {
t.Fatalf("Failed to read closest-finishes.json: %v", err)
}
var playlist Playlist
if err := json.Unmarshal(content, &playlist); err != nil {
t.Fatalf("Failed to parse closest-finishes.json: %v", err)
}
if playlist.Category != "close_games" {
t.Errorf("closest-finishes category: got %q, want %q", playlist.Category, "close_games")
}
if len(playlist.Matches) != 2 {
t.Errorf("closest-finishes: expected 2 matches, got %d", len(playlist.Matches))
}
if len(playlist.Matches) > 0 && playlist.Matches[0].MatchID != "match1" {
t.Errorf("closest-finishes first (closest): got %q, want %q", playlist.Matches[0].MatchID, "match1")
}
// Should only include match1 (close finish)
if len(playlist.Matches) != 1 {
t.Errorf("closest-finishes: expected 1 match, got %d", len(playlist.Matches))
// Verify marathon-matches playlist
marathonContent, err := os.ReadFile(filepath.Join(playlistsDir, "marathon-matches.json"))
if err != nil {
t.Fatalf("Failed to read marathon-matches.json: %v", err)
}
var marathon Playlist
if err := json.Unmarshal(marathonContent, &marathon); err != nil {
t.Fatalf("Failed to parse marathon-matches.json: %v", err)
}
if marathon.Category != "long_games" {
t.Errorf("marathon-matches category: got %q, want %q", marathon.Category, "long_games")
}
// Should include match2 (350) and match3 (400), sorted by turn count desc
if len(marathon.Matches) != 2 {
t.Errorf("marathon-matches: expected 2 matches, got %d", len(marathon.Matches))
}
if len(marathon.Matches) > 0 && marathon.Matches[0].MatchID != "match3" {
t.Errorf("marathon-matches first: got %q, want %q", marathon.Matches[0].MatchID, "match3")
}
// Verify biggest-upsets playlist
upsetContent, err := os.ReadFile(filepath.Join(playlistsDir, "biggest-upsets.json"))
if err != nil {
t.Fatalf("Failed to read biggest-upsets.json: %v", err)
}
var upsets Playlist
if err := json.Unmarshal(upsetContent, &upsets); err != nil {
t.Fatalf("Failed to parse biggest-upsets.json: %v", err)
}
// match2 has winner rating 1500 vs loser 1800 → upset of 300
if len(upsets.Matches) != 1 {
t.Errorf("biggest-upsets: expected 1 match, got %d", len(upsets.Matches))
}
}
func TestInterestScore(t *testing.T) {
now := time.Now()
// Close finish + upset + long game → high score
m := MatchData{
WinnerID: "bot2",
TurnCount: 450,
CompletedAt: now,
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800},
{BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1400},
},
}
score := interestScore(m)
if score < 5.0 {
t.Errorf("interestScore for exciting match: got %f, want >= 5.0", score)
}
// Boring match → low score
m2 := MatchData{
WinnerID: "bot1",
TurnCount: 100,
CompletedAt: now,
Participants: []ParticipantData{
{BotID: "bot1", Score: 10, Won: true, PreMatchRating: 1500},
{BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500},
},
}
score2 := interestScore(m2)
if score2 >= 2.0 {
t.Errorf("interestScore for boring match: got %f, want < 2.0", score2)
}
}
func TestFormatMatchTitle(t *testing.T) {
data := &IndexData{
Bots: []BotData{
{ID: "bot1", Name: "SwarmBot"},
{ID: "bot2", Name: "HunterBot"},
},
}
m := MatchData{
ID: "match1",
Participants: []ParticipantData{
{BotID: "bot1", Score: 3},
{BotID: "bot2", Score: 2},
},
}
title := formatMatchTitle(m, data)
if title != "SwarmBot 3 2 HunterBot" {
t.Errorf("formatMatchTitle: got %q, want %q", title, "SwarmBot 3 2 HunterBot")
}
}
func TestIsComeback(t *testing.T) {
// Close upset (rating diff >= 80, score diff <= 3) = comeback
m := MatchData{
WinnerID: "bot2",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800},
{BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1600},
},
}
if !isComeback(m) {
t.Error("Expected close upset to be a comeback")
}
// Decisive win, no upset = not a comeback
m2 := MatchData{
WinnerID: "bot1",
TurnCount: 150,
Participants: []ParticipantData{
{BotID: "bot1", Score: 8, Won: true, PreMatchRating: 1700},
{BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1500},
},
}
if isComeback(m2) {
t.Error("Expected decisive non-upset to not be a comeback")
}
// No winner = not a comeback
m3 := MatchData{
WinnerID: "",
Participants: []ParticipantData{
{BotID: "bot1", Score: 3},
{BotID: "bot2", Score: 2},
},
}
if isComeback(m3) {
t.Error("Expected no-winner match to not be a comeback")
}
}
func TestTurnaroundMagnitude(t *testing.T) {
// Bigger upset + closer score = bigger turnaround
big := MatchData{
WinnerID: "underdog",
TurnCount: 400,
Participants: []ParticipantData{
{BotID: "favored", Score: 2, Won: false, PreMatchRating: 1900},
{BotID: "underdog", Score: 3, Won: true, PreMatchRating: 1500},
},
}
small := MatchData{
WinnerID: "slight_underdog",
TurnCount: 200,
Participants: []ParticipantData{
{BotID: "favored", Score: 1, Won: false, PreMatchRating: 1600},
{BotID: "slight_underdog", Score: 3, Won: true, PreMatchRating: 1500},
},
}
bigMag := turnaroundMagnitude(big)
smallMag := turnaroundMagnitude(small)
if bigMag <= smallMag {
t.Errorf("Expected bigger turnaround (%f) > smaller (%f)", bigMag, smallMag)
}
}
func TestIsEvolutionBreakthrough(t *testing.T) {
data := &IndexData{
Bots: []BotData{
{ID: "evo1", Name: "EvolvedBot", Evolved: true},
{ID: "human1", Name: "HumanBot", Evolved: false},
},
}
// Evolved bot beats high-rated opponent
m := MatchData{
WinnerID: "evo1",
Participants: []ParticipantData{
{BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400},
{BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650},
},
}
if !isEvolutionBreakthrough(m, data) {
t.Error("Expected evolved bot beating rated opponent to be a breakthrough")
}
// Human bot wins = not a breakthrough
m2 := MatchData{
WinnerID: "human1",
Participants: []ParticipantData{
{BotID: "evo1", Score: 1, Won: false, PreMatchRating: 1400},
{BotID: "human1", Score: 5, Won: true, PreMatchRating: 1650},
},
}
if isEvolutionBreakthrough(m2, data) {
t.Error("Expected human bot winning to not be a breakthrough")
}
// Evolved bot beats low-rated opponent = not a breakthrough
m3 := MatchData{
WinnerID: "evo1",
Participants: []ParticipantData{
{BotID: "evo1", Score: 5, Won: true, PreMatchRating: 1400},
{BotID: "human1", Score: 1, Won: false, PreMatchRating: 1200},
},
}
if isEvolutionBreakthrough(m3, data) {
t.Error("Expected evolved bot beating low-rated opponent to not be a breakthrough")
}
}
func TestIsRivalryMatch(t *testing.T) {
_ = time.Now() // ensure time import used
matches := []MatchData{
{ID: "m1", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
{ID: "m2", WinnerID: "bot2", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
{ID: "m3", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
{ID: "m4", WinnerID: "bot3", Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}},
}
data := &IndexData{Matches: matches}
// bot1 vs bot2 has 3 matches = rivalry
m := MatchData{
WinnerID: "bot1",
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}},
}
if !isRivalryMatch(m, data) {
t.Error("Expected 3-match pair to be a rivalry")
}
// bot3 vs bot4 has only 1 match = not a rivalry
m2 := MatchData{
WinnerID: "bot3",
Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}},
}
if isRivalryMatch(m2, data) {
t.Error("Expected 1-match pair to not be a rivalry")
}
}
func TestCurateWeeklyHighlights(t *testing.T) {
now := time.Now()
matches := []MatchData{
// 1. Upset: underdog wins by large rating margin
{
ID: "upset1", WinnerID: "bot2", TurnCount: 250, CompletedAt: now.Add(-time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1800},
{BotID: "bot2", Score: 3, Won: true, PreMatchRating: 1400},
},
},
// 2. Elite clash: very high combined rating
{
ID: "elite1", WinnerID: "bot1", TurnCount: 150, CompletedAt: now.Add(-2 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 4, Won: true, PreMatchRating: 1800},
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1700},
},
},
// 3. Marathon: very long match
{
ID: "marathon1", WinnerID: "bot1", TurnCount: 450, CompletedAt: now.Add(-3 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1200},
{BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1200},
},
},
// 4. Close finish: score diff of 1
{
ID: "close1", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-4 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1200},
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1200},
},
},
// Extra filler matches to fill out the criteria
{
ID: "filler1", WinnerID: "bot1", TurnCount: 100, CompletedAt: now.Add(-5 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1500},
{BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500},
},
},
{
ID: "filler2", WinnerID: "bot2", TurnCount: 150, CompletedAt: now.Add(-6 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1500},
{BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1500},
},
},
// Old match — outside 7 days
{
ID: "old1", WinnerID: "bot1", TurnCount: 400, CompletedAt: now.Add(-8 * 24 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500},
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500},
},
},
// No winner
{
ID: "nowin", WinnerID: "", TurnCount: 300, CompletedAt: now.Add(-time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 2},
{BotID: "bot2", Score: 2},
},
},
}
cutoff := now.AddDate(0, 0, -7)
curated := curateWeeklyHighlights(matches, cutoff)
if len(curated) == 0 {
t.Fatal("Expected curated matches, got 0")
}
if len(curated) > 20 {
t.Errorf("Expected at most 20 curated matches, got %d", len(curated))
}
seenIDs := make(map[string]bool)
for _, c := range curated {
if c.Match.ID == "" {
t.Error("Curated match has empty ID")
}
if c.Tag == "" {
t.Errorf("Curated match %s has empty tag", c.Match.ID)
}
if seenIDs[c.Match.ID] {
t.Errorf("Duplicate match ID in curated results: %s", c.Match.ID)
}
seenIDs[c.Match.ID] = true
}
if seenIDs["old1"] {
t.Error("Old match should not appear in weekly highlights")
}
if seenIDs["nowin"] {
t.Error("No-winner match should not appear in weekly highlights")
}
// Verify at least one tag per criteria type
tagTypes := make(map[string]bool)
for _, c := range curated {
if strings.Contains(c.Tag, "Closest finish") {
tagTypes["closest"] = true
}
if strings.Contains(c.Tag, "Marathon battle") {
tagTypes["marathon"] = true
}
if strings.Contains(c.Tag, "Elite clash") {
tagTypes["elite"] = true
}
if strings.Contains(c.Tag, "Upset victory") {
tagTypes["upset"] = true
}
}
if !tagTypes["closest"] {
t.Error("Expected at least one 'Closest finish' tag")
}
if !tagTypes["marathon"] {
t.Error("Expected at least one 'Marathon battle' tag")
}
if !tagTypes["elite"] {
t.Error("Expected at least one 'Elite clash' tag")
}
if !tagTypes["upset"] {
t.Error("Expected at least one 'Upset victory' tag")
}
}
func TestBestOfWeekPlaylistHasCurationTags(t *testing.T) {
now := time.Now()
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "bot1", Name: "Bot1"},
{ID: "bot2", Name: "Bot2"},
},
Matches: []MatchData{
{
ID: "weekly_close", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500},
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500},
},
},
{
ID: "weekly_marathon", WinnerID: "bot2", TurnCount: 450, CompletedAt: now.Add(-2 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 2, Won: false, PreMatchRating: 1700},
{BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1600},
},
},
},
}
tmpDir := t.TempDir()
playlistsDir := filepath.Join(tmpDir, "data", "playlists")
if err := os.MkdirAll(playlistsDir, 0755); err != nil {
t.Fatalf("Failed to create playlists dir: %v", err)
}
botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"}
if err := generatePlaylists(data, tmpDir, botNameMap); err != nil {
t.Fatalf("generatePlaylists failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(playlistsDir, "best-of-week.json"))
if err != nil {
t.Fatalf("Failed to read best-of-week.json: %v", err)
}
var playlist Playlist
if err := json.Unmarshal(content, &playlist); err != nil {
t.Fatalf("Failed to parse best-of-week.json: %v", err)
}
if playlist.Category != "weekly" {
t.Errorf("best-of-week category: got %q, want %q", playlist.Category, "weekly")
}
tagCount := 0
for _, m := range playlist.Matches {
if m.CurationTag != "" {
tagCount++
}
}
if tagCount == 0 {
t.Error("Expected best-of-week matches to have curation tags, got 0")
}
}
func TestGeneratePlaylistsWithNewTypes(t *testing.T) {
now := time.Now()
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "human1", Name: "HumanBot", Evolved: false},
{ID: "evo1", Name: "EvoBot", Evolved: true},
},
Matches: []MatchData{
// Comeback: close upset
{
ID: "comeback1", WinnerID: "human1", TurnCount: 400, CompletedAt: now,
Participants: []ParticipantData{
{BotID: "human1", Score: 3, Won: true, PreMatchRating: 1500},
{BotID: "evo1", Score: 2, Won: false, PreMatchRating: 1700},
},
},
// Evolution breakthrough: evolved bot beats rated opponent
{
ID: "evo1", WinnerID: "evo1", TurnCount: 300, CompletedAt: now,
Participants: []ParticipantData{
{BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400},
{BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650},
},
},
},
}
tmpDir := t.TempDir()
playlistsDir := filepath.Join(tmpDir, "data", "playlists")
if err := os.MkdirAll(playlistsDir, 0755); err != nil {
t.Fatalf("Failed to create playlists dir: %v", err)
}
botNameMap := map[string]string{"human1": "HumanBot", "evo1": "EvoBot"}
if err := generatePlaylists(data, tmpDir, botNameMap); err != nil {
t.Fatalf("generatePlaylists failed: %v", err)
}
// Verify best-comebacks.json
cb, err := os.ReadFile(filepath.Join(playlistsDir, "best-comebacks.json"))
if err != nil {
t.Fatalf("Failed to read best-comebacks.json: %v", err)
}
var cbPlaylist Playlist
if err := json.Unmarshal(cb, &cbPlaylist); err != nil {
t.Fatalf("Failed to parse best-comebacks.json: %v", err)
}
if cbPlaylist.Category != "comebacks" {
t.Errorf("comebacks category: got %q", cbPlaylist.Category)
}
// Verify evolution-breakthroughs.json
eb, err := os.ReadFile(filepath.Join(playlistsDir, "evolution-breakthroughs.json"))
if err != nil {
t.Fatalf("Failed to read evolution-breakthroughs.json: %v", err)
}
var ebPlaylist Playlist
if err := json.Unmarshal(eb, &ebPlaylist); err != nil {
t.Fatalf("Failed to parse evolution-breakthroughs.json: %v", err)
}
if len(ebPlaylist.Matches) < 1 {
t.Errorf("Expected at least 1 evolution breakthrough, got %d", len(ebPlaylist.Matches))
}
// Verify index.json includes new playlist types
idx, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
if err != nil {
t.Fatalf("Failed to read index.json: %v", err)
}
var index PlaylistIndex
if err := json.Unmarshal(idx, &index); err != nil {
t.Fatalf("Failed to parse index.json: %v", err)
}
slugs := make(map[string]bool)
for _, p := range index.Playlists {
slugs[p.Slug] = true
}
for _, required := range []string{"best-comebacks", "evolution-breakthroughs", "rivalry-classics"} {
if !slugs[required] {
t.Errorf("Missing playlist %q in index", required)
}
}
}
func TestSortSlice(t *testing.T) {
matches := []MatchData{
{ID: "m1", TurnCount: 100},
{ID: "m2", TurnCount: 300},
{ID: "m3", TurnCount: 200},
}
sortSlice(matches, func(i, j int) bool {
return matches[i].TurnCount > matches[j].TurnCount
})
if matches[0].ID != "m2" || matches[1].ID != "m3" || matches[2].ID != "m1" {
t.Errorf("sortSlice: unexpected order: %v", matches)
}
}

View file

@ -43,8 +43,23 @@ func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) {
// updateSeriesGameResults finds completed series matches that haven't had their
// winner recorded yet. It updates series_games.winner_id and increments
// a_wins or b_wins on the series table.
// a_wins or b_wins on the series table. Drawn games (m.winner IS NULL) are
// marked so the series can continue to the next game.
func (m *Matchmaker) updateSeriesGameResults(ctx context.Context) error {
// First, handle draws: completed matches with no winner
_, err := m.db.ExecContext(ctx, `
UPDATE series_games SET winner_id = 'draw'
FROM matches m
WHERE series_games.match_id = m.match_id
AND series_games.winner_id IS NULL
AND m.status = 'completed'
AND m.winner IS NULL
`)
if err != nil {
log.Printf("series-scheduler: failed to process drawn games: %v", err)
}
// Then, process games with a winner
rows, err := m.db.QueryContext(ctx, `
SELECT sg.series_id, sg.game_num, sg.match_id, m.winner
FROM series_games sg
@ -173,6 +188,52 @@ func (m *Matchmaker) finalizeCompletedSeries(ctx context.Context) error {
log.Printf("series-scheduler: finalized series %d, winner=%s (%d-%d)", s.ID, winnerID, s.AWins, s.BWins)
}
// Also finalize series where all games are played but neither side reached
// the threshold (can happen with draws). Winner is whoever has more wins,
// or NULL if equal.
allPlayed, err := m.db.QueryContext(ctx, `
SELECT s.id, s.bot_a_id, s.bot_b_id, s.a_wins, s.b_wins, s.format
FROM series s
WHERE s.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM series_games sg
WHERE sg.series_id = s.id AND sg.winner_id IS NULL
)
AND EXISTS (SELECT 1 FROM series_games sg WHERE sg.series_id = s.id)
`)
if err != nil {
return fmt.Errorf("query all-played series: %w", err)
}
defer allPlayed.Close()
for allPlayed.Next() {
var apID int64
var apBotA, apBotB string
var apAWins, apBWins, apFormat int
if err := allPlayed.Scan(&apID, &apBotA, &apBotB, &apAWins, &apBWins, &apFormat); err != nil {
continue
}
winsNeeded := (apFormat + 1) / 2
if apAWins >= winsNeeded || apBWins >= winsNeeded {
continue // already handled above
}
var winnerID *string
if apAWins > apBWins {
winnerID = &apBotA
} else if apBWins > apAWins {
winnerID = &apBotB
}
_, err := m.db.ExecContext(ctx, `
UPDATE series SET status = 'completed', winner_id = $1, updated_at = NOW()
WHERE id = $2 AND status = 'active'
`, winnerID, apID)
if err != nil {
log.Printf("series-scheduler: failed to finalize all-played series %d: %v", apID, err)
continue
}
log.Printf("series-scheduler: finalized all-played series %d (%d-%d), winner=%v", apID, apAWins, apBWins, winnerID)
}
return nil
}

View file

@ -528,6 +528,83 @@ func TestChampionshipBracketRequiresEightBots(t *testing.T) {
}
}
func TestDrawGameProcessing(t *testing.T) {
// Drawn games should be handled: winner_id set to 'draw' so the series
// can continue scheduling the next game.
//
// Conditions for identifying a drawn series game:
// - series_games.winner_id IS NULL (not yet processed)
// - matches.status = 'completed'
// - matches.winner IS NULL (draw)
tests := []struct {
name string
winnerID string // empty = NULL
matchStat string
matchWin string // empty = NULL
shouldMark bool
}{
{"completed draw marks game", "", "completed", "", true},
{"winner game not marked by draw handler", "", "completed", "0", false},
{"pending game not marked", "", "pending", "", false},
{"already processed not marked", "draw", "completed", "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
winnerIDNull := tc.winnerID == ""
matchCompleted := tc.matchStat == "completed"
matchHasNoWinner := tc.matchWin == ""
shouldMark := winnerIDNull && matchCompleted && matchHasNoWinner
if shouldMark != tc.shouldMark {
t.Errorf("winnerID=%q matchStat=%q matchWin=%q: shouldMark=%v, want %v",
tc.winnerID, tc.matchStat, tc.matchWin, shouldMark, tc.shouldMark)
}
})
}
}
func TestAllPlayedFinalization(t *testing.T) {
// When all games in a series are played but neither side reached the
// winning threshold (possible with draws), the series should be finalized
// with the bot that has more wins, or NULL if equal.
tests := []struct {
name string
format int
aWins int
bWins int
winner string // "a", "b", or "" for draw
}{
{"bo3 with 1-1 and 1 draw", 3, 1, 1, ""},
{"bo5 with 2-1 and 2 draws", 5, 2, 1, "a"},
{"bo7 with 2-3 and 2 draws", 7, 2, 3, "b"},
{"bo3 with 1-0 and 2 draws", 3, 1, 0, "a"},
{"bo5 with 0-0 and 5 draws", 5, 0, 0, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
winsNeeded := (tc.format + 1) / 2
// Neither side reached threshold
if tc.aWins >= winsNeeded || tc.bWins >= winsNeeded {
t.Fatalf("test case has side reaching threshold (a=%d, b=%d, needed=%d)",
tc.aWins, tc.bWins, winsNeeded)
}
var winner string
if tc.aWins > tc.bWins {
winner = "a"
} else if tc.bWins > tc.aWins {
winner = "b"
}
if winner != tc.winner {
t.Errorf("all-played finalization a=%d b=%d: winner=%q, want %q",
tc.aWins, tc.bWins, winner, tc.winner)
}
})
}
}
// itoa is a simple int-to-string helper for tests.
func itoa(n int) string {
if n == 0 {

View file

@ -33,7 +33,7 @@ GOOS=js GOARCH=wasm go build \
./cmd/acb-wasm/
echo "Copying wasm_exec.js…"
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" "$OUT/"
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$OUT/"
echo "Building bot WASM modules…"
for bot in random gatherer rusher guardian swarm hunter; do

View file

@ -918,6 +918,8 @@
</div>
<div id="app"></div>
<!-- Go WASM runtime — needed for uploaded Go-compiled .wasm bots in sandbox -->
<script src="/wasm/wasm_exec.js"></script>
<script type="module" src="/src/app.ts"></script>
</body>
</html>

View file

@ -251,6 +251,35 @@
color: rgba(255, 255, 255, 0.3);
text-decoration: none;
}
/* Commentary subtitle bar */
.commentary-bar {
display: none;
padding: 6px 14px;
background-color: rgba(30, 41, 59, 0.95);
border-top: 1px solid #334155;
min-height: 32px;
align-items: center;
justify-content: center;
}
.commentary-bar.visible {
display: flex;
}
.commentary-bar .commentary-text {
color: #e2e8f0;
font-size: 13px;
text-align: center;
line-height: 1.4;
max-width: 100%;
}
.commentary-bar .commentary-text.type-setup { color: #94a3b8; }
.commentary-bar .commentary-text.type-action { color: #e2e8f0; }
.commentary-bar .commentary-text.type-reaction { color: #fbbf24; }
.commentary-bar .commentary-text.type-climax { color: #f97316; font-weight: 600; }
.commentary-bar .commentary-text.type-denouement { color: #94a3b8; font-style: italic; }
</style>
</head>
<body>
@ -278,6 +307,10 @@
</div>
</div>
<div class="commentary-bar" id="commentary-bar">
<span class="commentary-text" id="commentary-text"></span>
</div>
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<span>Loading replay...</span>

Binary file not shown.

Binary file not shown.

BIN
web/public/wasm/bots/hunter.wasm Executable file

Binary file not shown.

BIN
web/public/wasm/bots/random.wasm Executable file

Binary file not shown.

BIN
web/public/wasm/bots/rusher.wasm Executable file

Binary file not shown.

BIN
web/public/wasm/bots/swarm.wasm Executable file

Binary file not shown.

View file

@ -253,10 +253,10 @@ export interface BlogPost {
slug: string;
title: string;
published_at: string;
week_start: string;
type: 'meta-report' | 'chronicle';
summary: string;
body_html: string;
stats: BlogWeekStats;
body_markdown: string;
tags: string[];
}
export interface BlogIndex {
@ -395,6 +395,12 @@ export interface PlaylistMatch {
order: number;
title?: string;
thumbnail_url?: string;
curation_tag?: string;
participants?: { bot_id: string; name: string; score: number; won: boolean }[];
score?: string;
turns?: number;
end_reason?: string;
completed_at?: string;
}
export interface Playlist {
@ -564,3 +570,48 @@ export async function fetchSeasonIndex(): Promise<SeasonIndex> {
return response.json();
});
}
// Commentary / Enrichment types (§13.3)
export interface CommentaryEntry {
turn: number;
text: string;
type: 'setup' | 'action' | 'reaction' | 'climax' | 'denouement';
}
export interface EnrichedCommentary {
match_id: string;
generated_at: string;
criteria: string[];
entries: CommentaryEntry[];
}
export interface EnrichedMatchEntry {
match_id: string;
criteria: string[];
}
export interface EnrichedIndex {
updated_at: string;
entries: EnrichedMatchEntry[];
}
const R2_COMMENTARY_BASE = 'https://r2.aicodebattle.com';
export async function fetchCommentary(matchId: string): Promise<EnrichedCommentary | null> {
try {
const response = await fetch(`${R2_COMMENTARY_BASE}/commentary/${matchId}.json`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
export async function fetchEnrichedIndex(): Promise<EnrichedIndex> {
return swr('enriched-index', async () => {
const response = await fetch('/data/commentary/index.json');
if (!response.ok) return { updated_at: '', entries: [] };
return response.json();
});
}

View file

@ -14,6 +14,7 @@ const loadLeaderboardPage = () => import('./pages/leaderboard').then(m => m.rend
// Watch section - replay viewer and related pages
const loadMatchesPage = () => import('./pages/matches').then(m => m.renderMatchesPage);
const loadPlaylistsPage = () => import('./pages/playlists').then(m => m.renderPlaylistsPage);
const loadSeriesPage = () => import('./pages/series').then(m => m.renderSeriesPage);
const loadPredictionsPage = () => import('./pages/predictions').then(m => m.renderPredictionsPage);
const loadReplayPage = () => import('./pages/replay').then(m => m.renderReplayPage);
@ -127,6 +128,8 @@ router
.on('/', lazyRoute(loadHomePage))
.on('/watch', lazyRoute(loadWatchHubPage))
.on('/watch/replays', lazyRoute(loadMatchesPage))
.on('/watch/playlists', lazyRoute(loadPlaylistsPage))
.on('/watch/playlists/:slug', lazyRoute(loadPlaylistsPage))
.on('/watch/replay/:id', lazyRoute(loadReplayPage))
.on('/watch/series/:id', lazyRoute(loadSeriesPage))
.on('/watch/predictions', lazyRoute(loadPredictionsPage))
@ -145,7 +148,7 @@ router
.on('/bot/:id', lazyRoute(loadBotProfilePage))
// Backwards compatibility redirects
.on('/matches', redirect('/watch/replays'))
.on('/playlists', redirect('/watch/replays'))
.on('/playlists', redirect('/watch/playlists'))
.on('/replay', redirect('/watch/replay'))
.on('/predictions', redirect('/watch/predictions'))
.on('/series', redirect('/watch/series'))

View file

@ -1,6 +1,7 @@
// Embeddable replay viewer - minimal, auto-playing widget
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
import { fetchCommentary } from './api-types';
// Player colors matching replay-viewer.ts
const PLAYER_COLORS = [
@ -46,6 +47,8 @@ class EmbedViewer {
private endTitle: HTMLElement;
private endSubtitle: HTMLElement;
private scoreOverlay: HTMLElement;
private commentaryBar: HTMLElement;
private commentaryText: HTMLElement;
constructor() {
this.canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
@ -63,6 +66,8 @@ class EmbedViewer {
this.endTitle = document.getElementById('end-title') as HTMLElement;
this.endSubtitle = document.getElementById('end-subtitle') as HTMLElement;
this.scoreOverlay = document.getElementById('score-overlay') as HTMLElement;
this.commentaryBar = document.getElementById('commentary-bar') as HTMLElement;
this.commentaryText = document.getElementById('commentary-text') as HTMLElement;
// Parse config from URL
this.config = this.parseConfig();
@ -138,6 +143,10 @@ class EmbedViewer {
// Wire viewer callbacks
this.viewer.onTurnChange = (turn) => this.onTurnChange(turn);
this.viewer.onPlayStateChange = (playing) => this.onPlayStateChange(playing);
this.viewer.onCommentaryChange = (entry) => this.onCommentaryChange(entry);
// Load AI commentary if available (non-blocking)
this.loadCommentary(this.config.matchId);
// Hide loading, enable controls
this.hideLoading();
@ -369,6 +378,23 @@ class EmbedViewer {
this.endOverlay.classList.remove('visible');
this.endOverlay.onclick = null;
}
private async loadCommentary(matchId: string): Promise<void> {
const commentary = await fetchCommentary(matchId);
if (commentary && commentary.entries.length > 0) {
this.viewer?.setCommentary(commentary);
this.commentaryBar.classList.add('visible');
}
}
private onCommentaryChange(entry: { turn: number; text: string; type: string } | null): void {
if (!entry) {
this.commentaryText.textContent = '';
return;
}
this.commentaryText.textContent = entry.text;
this.commentaryText.className = `commentary-text type-${entry.type}`;
}
}
// Initialize on load

View file

@ -105,36 +105,48 @@ const DIRS: Direction[] = ['N', 'E', 'S', 'W'];
// Map generation (simplified cellular-automata)
// ────────────────────────────────────────────────────────────────────────────
export function generateMap(cfg: Config, seed?: number): { walls: Set<string>; cores: Core[]; energyNodes: EnergyNode[] } {
export function generateMap(cfg: Config, seed?: number, numPlayers = 2): { walls: Set<string>; cores: Core[]; energyNodes: EnergyNode[] } {
// Simple deterministic map using linear congruential generator
let s = seed ?? 42;
const lcg = () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0x100000000; };
const walls = new Set<string>();
const numPlayers = 2;
const rows = cfg.rows;
const cols = cfg.cols;
// Generate wall clusters avoiding cores & centres
const wallProb = 0.15;
// Generate wall clusters with rotational symmetry for all players
const wallProb = 0.12;
const symmetryDiv = numPlayers;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (lcg() < wallProb) {
// Rotation symmetry: place wall + 180° mirror
walls.add(posKey({ row: r, col: c }));
walls.add(posKey(wrap(rows - r - 1, cols - c - 1, cfg)));
for (let i = 0; i < symmetryDiv; i++) {
const angle = (2 * Math.PI * i) / symmetryDiv;
const cr = rows / 2, cc = cols / 2;
const dr = r - cr, dc = c - cc;
const nr = Math.round(cr + dr * Math.cos(angle) - dc * Math.sin(angle));
const nc = Math.round(cc + dr * Math.sin(angle) + dc * Math.cos(angle));
const wp = wrap(nr, nc, cfg);
walls.add(posKey(wp));
}
}
}
}
// Player cores placed symmetrically
const cores: Core[] = [];
const corePositions: Position[] = [
{ row: Math.floor(rows * 0.25), col: Math.floor(cols * 0.25) },
{ row: Math.floor(rows * 0.75), col: Math.floor(cols * 0.75) },
];
const corePositions: Position[] = [];
const cx = rows / 2, cy = cols / 2;
const coreRadius = Math.min(rows, cols) * 0.35;
for (let i = 0; i < numPlayers; i++) {
const p = corePositions[i] ?? wrap(i * Math.floor(rows / numPlayers), Math.floor(cols / 2), cfg);
const angle = (2 * Math.PI * i) / numPlayers - Math.PI / 2;
corePositions.push({
row: Math.round(cx + coreRadius * Math.cos(angle)),
col: Math.round(cy + coreRadius * Math.sin(angle)),
});
}
for (let i = 0; i < numPlayers; i++) {
const p = wrap(corePositions[i].row, corePositions[i].col, cfg);
walls.delete(posKey(p)); // ensure core tile is clear
cores.push({ position: p, owner: i, active: true });
}
@ -158,13 +170,12 @@ export function generateMap(cfg: Config, seed?: number): { walls: Set<string>; c
// Game state initialization
// ────────────────────────────────────────────────────────────────────────────
export function newGame(cfg: Config, seed?: number): GameState {
const { walls, cores, energyNodes } = generateMap(cfg, seed);
export function newGame(cfg: Config, seed?: number, numPlayers = 2): GameState {
const { walls, cores, energyNodes } = generateMap(cfg, seed, numPlayers);
const players: Player[] = [
{ id: 0, energy: 0, score: 0, botCount: 1 },
{ id: 1, energy: 0, score: 0, botCount: 1 },
];
const players: Player[] = Array.from({ length: numPlayers }, (_, i) => ({
id: i, energy: 0, score: 0, botCount: 1,
}));
// Initial bots at each core
const bots: Bot[] = cores.map((c, i) => ({
@ -610,8 +621,11 @@ export interface ReplayTurn {
}
export interface Replay {
format_version?: string;
match_id: string;
config: Config;
start_time: string;
end_time: string;
result: MatchResult;
players: { id: number; name: string }[];
map: { rows: number; cols: number; walls: Position[]; cores: { position: Position; owner: number }[]; energy_nodes: Position[] };
@ -626,8 +640,19 @@ export function runMatch(
): { replay: Replay; result: MatchResult } {
const s1 = typeof strategy1 === 'string' ? BUILTIN_STRATEGIES[strategy1] ?? randomStrategy : strategy1;
const s2 = typeof strategy2 === 'string' ? BUILTIN_STRATEGIES[strategy2] ?? randomStrategy : strategy2;
return runMultiMatch(cfg, [s1, s2], seed);
}
const gs = newGame(cfg, seed);
export function runMultiMatch(
cfg: Config,
strategies: (BotStrategy | string)[],
seed?: number,
): { replay: Replay; result: MatchResult } {
const resolved = strategies.map(s =>
typeof s === 'string' ? BUILTIN_STRATEGIES[s] ?? randomStrategy : s
);
const numPlayers = resolved.length;
const gs = newGame(cfg, seed, numPlayers);
const wallPositions: Position[] = [];
for (const k of gs.walls) {
@ -635,6 +660,7 @@ export function runMatch(
wallPositions.push({ row: r, col: c });
}
const startTime = new Date().toISOString();
const turns: ReplayTurn[] = [];
function recordTurn(): ReplayTurn {
@ -656,7 +682,7 @@ export function runMatch(
const allMoves = new Map<number, Move[]>();
for (const p of gs.players) {
const visible = getVisibleState(gs, p.id);
const strategy = p.id === 0 ? s1 : s2;
const strategy = resolved[p.id];
try {
allMoves.set(p.id, strategy(visible));
} catch {
@ -667,12 +693,19 @@ export function runMatch(
turns.push(recordTurn());
}
const endTime = new Date().toISOString();
const names = strategies.map((s, i) =>
typeof s === 'string' ? s : (i === 0 ? 'Your Bot' : `Opponent ${i}`)
);
const replay: Replay = {
format_version: '1.0',
match_id: gs.matchId,
config: cfg,
start_time: startTime,
end_time: endTime,
result,
players: [{ id: 0, name: typeof strategy1 === 'string' ? strategy1 : 'custom' },
{ id: 1, name: typeof strategy2 === 'string' ? strategy2 : 'opponent' }],
players: names.map((name, i) => ({ id: i, name })),
map: {
rows: cfg.rows,
cols: cfg.cols,

View file

@ -4,14 +4,14 @@ import { router } from '../router';
interface BlogEntry {
slug: string;
title: string;
date: string;
published_at: string;
type: 'meta-report' | 'chronicle';
summary: string;
tags: string[];
}
interface BlogPost extends BlogEntry {
content_md: string;
body_markdown: string;
}
interface BlogIndex {
@ -310,7 +310,7 @@ function renderBlogList(posts: BlogEntry[], filter: string = 'all'): void {
<div class="blog-card" data-slug="${post.slug}">
<div class="blog-card-meta">
<span class="blog-card-type ${post.type}">${formatPostType(post.type)}</span>
<span class="blog-card-date">${formatDate(post.date)}</span>
<span class="blog-card-date">${formatDate(post.published_at)}</span>
</div>
<h3 class="blog-card-title">${escapeHtml(post.title)}</h3>
<p class="blog-card-summary">${escapeHtml(post.summary)}</p>
@ -581,10 +581,10 @@ function renderPost(post: BlogPost): void {
<div class="post-header">
<span class="post-type-badge ${post.type}">${formatPostType(post.type)}</span>
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<div class="post-date">${formatDate(post.date)}</div>
<div class="post-date">${formatDate(post.published_at)}</div>
</div>
<div class="post-body">
${markdownToHtml(post.content_md)}
${markdownToHtml(post.body_markdown)}
</div>
<div class="post-tags">
${post.tags.map(tag => `<span class="post-tag">${escapeHtml(tag)}</span>`).join('')}

View file

@ -176,7 +176,7 @@ export async function renderHomePage(): Promise<void> {
${latestStories.length > 0 ? latestStories.map((post: any) => `
<a href="#/blog/${post.slug}" class="story-link">
<div class="story-title">${escapeHtml(post.title)}</div>
<div class="story-meta">${post.date}</div>
<div class="story-meta">${post.published_at || post.date || ''}</div>
</a>
`).join('') : '<p class="empty">No stories yet</p>'}
</div>
@ -190,7 +190,7 @@ export async function renderHomePage(): Promise<void> {
<h2>Playlists</h2>
<div class="playlists-carousel">
${featuredPlaylists.map((playlist: any) => `
<a href="#/watch/replays" class="playlist-card">
<a href="#/watch/playlists/${playlist.slug}" class="playlist-card">
<div class="playlist-thumbnail">
${playlist.thumbnail_match_id
? `<img src="/replays/${playlist.thumbnail_match_id}.jpg" alt="${escapeHtml(playlist.title)}" loading="lazy">`

View file

@ -1,6 +1,6 @@
// Match history page - displays recent matches
// Match history page - displays recent matches with featured playlists
import { fetchMatchIndex, type MatchSummary } from '../api-types';
import { fetchMatchIndex, fetchPlaylistIndex, type MatchSummary, type PlaylistIndex } from '../api-types';
export async function renderMatchesPage(): Promise<void> {
const app = document.getElementById('app');
@ -9,26 +9,136 @@ export async function renderMatchesPage(): Promise<void> {
app.innerHTML = `
<div class="matches-page">
<h1>Match History</h1>
<div id="playlists-section"></div>
<div id="matches-content" class="loading">Loading...</div>
</div>
<style>
.playlists-section { margin-bottom: 32px; }
.playlists-section h2 { color: var(--text-primary); margin-bottom: 12px; font-size: 1.25rem; }
.playlists-row {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
}
.playlists-row::-webkit-scrollbar { height: 6px; }
.playlists-row::-webkit-scrollbar-thumb { background: var(--border, #333); border-radius: 3px; }
.playlist-card {
flex: 0 0 240px;
scroll-snap-align: start;
background-color: var(--bg-secondary);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.playlist-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.playlist-card h3 {
color: var(--text-primary);
font-size: 0.95rem;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.playlist-card p {
color: var(--text-muted);
font-size: 0.8rem;
margin: 0;
line-height: 1.4;
flex: 1;
}
.playlist-card .meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--text-muted);
}
.category-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.65rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.category-badge.featured { background-color: #3b82f6; color: white; }
.category-badge.upsets { background-color: #ef4444; color: white; }
.category-badge.comebacks { background-color: #f59e0b; color: white; }
.category-badge.domination { background-color: #8b5cf6; color: white; }
.category-badge.close_games { background-color: #22c55e; color: white; }
.category-badge.long_games { background-color: #06b6d4; color: white; }
.category-badge.weekly { background-color: #ec4899; color: white; }
.category-badge.rivalry { background-color: #f97316; color: white; }
.category-badge.season { background-color: #8b5cf6; color: white; }
.category-badge.tutorial { background-color: #64748b; color: white; }
.playlist-empty { color: var(--text-muted); font-size: 0.875rem; }
@media (max-width: 640px) {
.playlist-card { flex: 0 0 200px; padding: 12px; }
}
</style>
`;
const content = document.getElementById('matches-content');
if (!content) return;
const playlistsSection = document.getElementById('playlists-section');
if (!content || !playlistsSection) return;
try {
const data = await fetchMatchIndex();
renderMatchesList(content, data.matches, data.updated_at);
} catch (error) {
// Load playlists in parallel with matches
const [matchResult, playlistResult] = await Promise.allSettled([
fetchMatchIndex(),
fetchPlaylistIndex(),
]);
// Render playlists section
if (playlistResult.status === 'fulfilled' && playlistResult.value.playlists.length > 0) {
renderPlaylistCards(playlistsSection, playlistResult.value);
}
// Render matches
if (matchResult.status === 'fulfilled') {
renderMatchesList(content, matchResult.value.matches, matchResult.value.updated_at);
} else {
content.innerHTML = `
<div class="error">
<p>Failed to load match history: ${error}</p>
<p>Failed to load match history: ${matchResult.reason}</p>
<p class="hint">Match data may not be available yet.</p>
</div>
`;
}
}
function renderPlaylistCards(container: HTMLElement, index: PlaylistIndex): void {
container.innerHTML = `
<h2>Featured Playlists</h2>
<div class="playlists-row">
${index.playlists.map(p => `
<a href="#/watch/playlists/${p.slug}" class="playlist-card">
<h3>
${escapeHtml(p.title)}
<span class="category-badge ${p.category}">${formatCategory(p.category)}</span>
</h3>
<p>${escapeHtml(p.description)}</p>
<div class="meta">
<span>${p.match_count} matches</span>
<span>${formatRelativeTime(p.updated_at)}</span>
</div>
</a>
`).join('')}
</div>
`;
}
function renderMatchesList(
container: HTMLElement,
matches: MatchSummary[],
@ -82,6 +192,37 @@ function renderMatchCard(match: MatchSummary): string {
`;
}
function formatCategory(category: string): string {
const labels: Record<string, string> = {
featured: 'Featured',
rivalry: 'Rivalry',
upsets: 'Upsets',
comebacks: 'Comebacks',
domination: 'Domination',
close_games: 'Close',
long_games: 'Marathon',
tutorial: 'Tutorial',
season: 'Season',
weekly: 'Weekly',
};
return labels[category] || category;
}
function formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();

View file

@ -1,12 +1,12 @@
// Playlists Page - Browse curated replay collections
import type { Playlist, PlaylistIndex } from '../api-types';
import { fetchPlaylistIndex, fetchPlaylist, type Playlist, type PlaylistIndex } from '../api-types';
const PAGES_BASE = '';
export async function renderPlaylistsPage(): Promise<void> {
export async function renderPlaylistsPage(params?: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
const slug = params?.slug;
app.innerHTML = `
<div class="playlists-page">
<h1 class="page-title">Replay Playlists</h1>
@ -224,11 +224,33 @@ export async function renderPlaylistsPage(): Promise<void> {
.category-badge.close_games { background-color: #22c55e; color: white; }
.category-badge.long_games { background-color: #06b6d4; color: white; }
.category-badge.weekly { background-color: #ec4899; color: white; }
.category-badge.rivalry { background-color: #f97316; color: white; }
.category-badge.season { background-color: #8b5cf6; color: white; }
.category-badge.tutorial { background-color: #64748b; color: white; }
.curation-tag {
display: inline-block;
font-size: 0.7rem;
color: var(--text-muted);
font-style: italic;
margin-top: 2px;
}
</style>
`;
// Load playlists
await loadPlaylists();
if (slug) {
await showPlaylistDetail(slug);
document.getElementById('playlists-grid')!.style.display = 'none';
(document.getElementById('playlist-detail') as HTMLElement).style.display = 'block';
const backBtn = document.getElementById('back-btn');
if (backBtn) {
backBtn.onclick = () => {
window.location.hash = '/watch/playlists';
};
}
} else {
await loadPlaylists();
}
}
async function loadPlaylists(): Promise<void> {
@ -236,9 +258,7 @@ async function loadPlaylists(): Promise<void> {
if (!grid) return;
try {
const response = await fetch(`${PAGES_BASE}/data/playlists/index.json`);
if (!response.ok) throw new Error('Failed to load playlists');
const index: PlaylistIndex = await response.json();
const index: PlaylistIndex = await fetchPlaylistIndex();
if (index.playlists.length === 0) {
grid.innerHTML = '<div class="empty-message">No playlists available yet</div>';
@ -256,7 +276,6 @@ async function loadPlaylists(): Promise<void> {
</div>
`).join('');
// Wire up click handlers
grid.querySelectorAll('.playlist-card').forEach(card => {
card.addEventListener('click', () => {
const slug = (card as HTMLElement).dataset.slug;
@ -280,28 +299,33 @@ async function showPlaylistDetail(slug: string): Promise<void> {
if (!grid || !detail || !titleEl || !descEl || !matchesEl) return;
try {
const response = await fetch(`${PAGES_BASE}/data/playlists/${slug}.json`);
if (!response.ok) throw new Error('Playlist not found');
const playlist: Playlist = await response.json();
const playlist: Playlist = await fetchPlaylist(slug);
titleEl.textContent = playlist.title;
descEl.textContent = playlist.description;
matchesEl.innerHTML = playlist.matches.map(m => `
<div class="playlist-match" data-match-id="${m.match_id}">
<span class="match-order">${m.order + 1}</span>
<div class="match-info">
<div class="match-title">${m.title || `Match ${m.order + 1}`}</div>
<div class="match-meta">ID: ${m.match_id}</div>
matchesEl.innerHTML = playlist.matches.map(m => {
const metaParts: string[] = [];
if (m.turns) metaParts.push(`${m.turns} turns`);
if (m.end_reason) metaParts.push(m.end_reason);
if (m.completed_at) metaParts.push(formatRelativeTime(m.completed_at));
const tag = m.curation_tag ? `<span class="curation-tag">${m.curation_tag}</span>` : '';
return `
<div class="playlist-match" data-match-id="${m.match_id}">
<span class="match-order">${m.order + 1}</span>
<div class="match-info">
<div class="match-title">${m.title || `Match ${m.order + 1}`}</div>
${tag}
<div class="match-meta">${metaParts.join(' · ')}</div>
</div>
<div class="match-actions">
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
</div>
</div>
<div class="match-actions">
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
</div>
</div>
`).join('');
`;
}).join('');
// Wire up buttons
matchesEl.querySelectorAll('.watch-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
@ -318,11 +342,9 @@ async function showPlaylistDetail(slug: string): Promise<void> {
});
});
// Show detail, hide grid
grid.style.display = 'none';
detail.style.display = 'block';
// Back button handler
backBtn!.onclick = () => {
detail.style.display = 'none';
grid.style.display = 'grid';

View file

@ -1,5 +1,6 @@
// Standalone replay viewer page - lazy loaded from app.ts
import type { Replay, GameEvent } from '../types';
import { fetchCommentary } from '../api-types';
const loadReplayViewer = () => import('../replay-viewer');
@ -52,6 +53,13 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
</div>
</div>
<div id="commentary-bar" class="commentary-bar" style="display:none">
<div class="commentary-content">
<span id="commentary-text" class="commentary-text"></span>
</div>
<button id="commentary-toggle" class="btn small secondary" title="Toggle AI commentary">💬</button>
</div>
<div class="replay-sidebar">
<div class="panel">
<h2>Load Replay</h2>
@ -198,6 +206,16 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
.win-prob-legend { display: flex; gap: 16px; margin-top: 6px; font-size: 0.75rem; }
.wp-legend-p0 { color: #3b82f6; }
.wp-legend-p1 { color: #ef4444; }
.commentary-bar { background-color: var(--bg-secondary); border-radius: 8px; padding: 8px 12px; margin-top: 10px; display: flex; align-items: center; gap: 10px; min-height: 40px; }
.commentary-content { flex: 1; min-width: 0; }
.commentary-text { color: var(--text-secondary); font-size: 0.875rem; line-height: 1.4; display: block; }
.commentary-text.type-setup { color: #94a3b8; }
.commentary-text.type-action { color: #e2e8f0; }
.commentary-text.type-reaction { color: #fbbf24; }
.commentary-text.type-climax { color: #f97316; font-weight: 600; }
.commentary-text.type-denouement { color: #94a3b8; font-style: italic; }
.commentary-toggle { flex-shrink: 0; opacity: 0.6; transition: opacity 0.2s; }
.commentary-toggle.active { opacity: 1; background-color: var(--accent); color: white; }
@media (max-width: 900px) {
.replay-layout { flex-direction: column; }
.replay-sidebar { width: 100%; }
@ -237,9 +255,13 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement;
const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement;
const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement;
const commentaryBar = document.getElementById('commentary-bar') as HTMLDivElement;
const commentaryText = document.getElementById('commentary-text') as HTMLSpanElement;
const commentaryToggle = document.getElementById('commentary-toggle') as HTMLButtonElement;
let viewer = new ReplayViewerClass(canvas, { cellSize: 10 });
let criticalMoments: Array<{turn: number; delta: number; description: string}> = [];
let commentaryEnabled = true;
function enableControls(): void {
playBtn.disabled = false;
@ -302,6 +324,20 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
updateUI();
updateEventLog();
initWinProb(replay);
loadCommentary(replay.match_id);
}
async function loadCommentary(matchId: string): Promise<void> {
const commentary = await fetchCommentary(matchId);
if (commentary && commentary.entries.length > 0) {
viewer.setCommentary(commentary);
commentaryBar.style.display = 'flex';
commentaryToggle.classList.add('active');
commentaryEnabled = true;
} else {
viewer.setCommentary(null);
commentaryBar.style.display = 'none';
}
}
function initWinProb(replay: Replay): void {
@ -469,6 +505,24 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
if (criticalMoments.length > 0) updateCriticalMomentNav();
};
viewer.onPlayStateChange = (playing: boolean) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => {
if (!entry || !commentaryEnabled) {
commentaryText.textContent = '';
return;
}
commentaryText.textContent = entry.text;
commentaryText.className = `commentary-text type-${entry.type}`;
};
// Commentary toggle
commentaryToggle.addEventListener('click', () => {
commentaryEnabled = !commentaryEnabled;
viewer.setCommentaryEnabled(commentaryEnabled);
commentaryToggle.classList.toggle('active', commentaryEnabled);
if (!commentaryEnabled) {
commentaryText.textContent = '';
}
});
document.addEventListener('keydown', (e) => {
if (!viewer.getReplay()) return;

View file

@ -1,5 +1,5 @@
// Seasons Page - Browse seasonal competitions with per-season rankings
import type { Season, SeasonIndex, SeasonSnapshot } from '../types';
import type { Season, SeasonIndex, SeasonSnapshot, ChampionshipBracketSeries } from '../types';
const PAGES_BASE = '';
@ -354,6 +354,99 @@ export async function renderSeasonsPage(): Promise<void> {
color: var(--text-muted);
margin-top: 4px;
}
/* Championship bracket */
.championship-bracket {
margin-top: 24px;
padding: 16px;
background-color: var(--bg-secondary);
border-radius: 8px;
}
.championship-bracket h3 {
margin-bottom: 16px;
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.bracket-round {
margin-bottom: 16px;
}
.bracket-round-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 8px;
font-weight: 600;
}
.bracket-series-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
margin-bottom: 4px;
border-left: 3px solid var(--border);
transition: background-color 0.15s;
}
.bracket-series-row:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.bracket-series-row.completed {
border-left-color: #3b82f6;
}
.bracket-series-row.active {
border-left-color: #22c55e;
}
.bracket-series-row .seed {
font-size: 0.65rem;
color: var(--text-muted);
font-weight: 700;
min-width: 20px;
}
.bracket-series-row .team {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
}
.bracket-series-row .team.winner { color: #22c55e; }
.bracket-series-row .team.loser { color: var(--text-muted); text-decoration: line-through; }
.bracket-series-row .series-score {
font-family: monospace;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-primary);
min-width: 30px;
text-align: center;
}
.bracket-series-row .series-status {
font-size: 0.6rem;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 3px;
}
.bracket-series-row .series-status.active { background-color: #22c55e; color: white; }
.bracket-series-row .series-status.completed { background-color: #3b82f6; color: white; }
.bracket-series-row .series-status.pending { background-color: #6b7280; color: white; }
.bracket-spacer {
height: 8px;
}
</style>
`;
@ -375,7 +468,35 @@ async function loadSeasons(): Promise<void> {
// Show active season if present
if (index.active_season && activeSeasonContainer && activeSeasonContent) {
activeSeasonContainer.style.display = 'block';
activeSeasonContent.innerHTML = renderActiveSeason(index.active_season);
// For active seasons, also load the live leaderboard to show current standings
let liveSnapshot: SeasonSnapshot[] | null = index.active_season.final_snapshot;
if (!liveSnapshot || liveSnapshot.length === 0) {
try {
const lbResponse = await fetch(`${PAGES_BASE}/data/leaderboard.json`);
if (lbResponse.ok) {
const lb = await lbResponse.json();
if (lb.entries && lb.entries.length > 0) {
// Convert leaderboard entries to snapshot format for the active season
liveSnapshot = lb.entries.slice(0, 20).map((entry: any, idx: number) => ({
bot_id: entry.bot_id,
bot_name: entry.name,
rating: entry.rating,
rank: idx + 1,
wins: entry.wins || 0,
losses: entry.losses || 0,
}));
}
}
} catch {
// Live leaderboard fetch failed — show season without leaderboard
}
}
activeSeasonContent.innerHTML = renderActiveSeason({
...index.active_season,
final_snapshot: liveSnapshot,
});
activeSeasonContent.querySelector('.season-card')?.addEventListener('click', () => {
showSeasonDetail(index.active_season!.id);
@ -480,6 +601,62 @@ function renderActiveSeason(season: Season): string {
`;
}
function renderChampionshipBracket(bracket: ChampionshipBracketSeries[]): string {
if (!bracket || bracket.length === 0) return '';
// Group by round
const rounds = new Map<string, ChampionshipBracketSeries[]>();
const roundOrder = ['quarterfinal', 'semifinal', 'final'];
for (const s of bracket) {
const round = s.round || 'quarterfinal';
if (!rounds.has(round)) rounds.set(round, []);
rounds.get(round)!.push(s);
}
// Sort each round by bracket_position
for (const [, series] of rounds) {
series.sort((a, b) => a.bracket_position - b.bracket_position);
}
const roundLabels: Record<string, string> = {
quarterfinal: 'Quarterfinals',
semifinal: 'Semifinals',
final: 'Final',
};
const html = roundOrder
.filter(r => rounds.has(r))
.map(round => {
const series = rounds.get(round)!;
return `
<div class="bracket-round">
<div class="bracket-round-title">${roundLabels[round] || round}</div>
${series.map(s => {
const isCompleted = s.status === 'completed';
const isActive = s.status === 'active';
const bot1Won = s.winner_id === s.bot1_id;
const bot2Won = s.winner_id === s.bot2_id;
return `
<div class="bracket-series-row ${isCompleted ? 'completed' : isActive ? 'active' : ''}">
<span class="team ${bot1Won ? 'winner' : isCompleted && !bot1Won ? 'loser' : ''}">${escapeHtml(s.bot1_name)}</span>
<span class="series-score">${s.bot1_wins}-${s.bot2_wins}</span>
<span class="team ${bot2Won ? 'winner' : isCompleted && !bot2Won ? 'loser' : ''}">${escapeHtml(s.bot2_name)}</span>
<span class="series-status ${s.status}">${s.status}</span>
</div>
`;
}).join('')}
</div>
`;
}).join('<div class="bracket-spacer"></div>');
return `
<div class="championship-bracket">
<h3>Championship Bracket</h3>
${html}
</div>
`;
}
async function showSeasonDetail(seasonId: string): Promise<void> {
const listSection = document.querySelector('.seasons-list-section') as HTMLElement;
const activeSeason = document.getElementById('active-season');
@ -495,11 +672,37 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
const season: Season = await response.json();
// Compute max wins/losses for bar scaling
const maxGames = season.final_snapshot?.reduce((max: number, e: SeasonSnapshot) => {
const snapshotForScale = season.final_snapshot || [];
const maxGames = snapshotForScale.reduce((max: number, e: SeasonSnapshot) => {
return Math.max(max, e.wins, e.losses);
}, 1) || 1;
const leaderboardHtml = season.final_snapshot && season.final_snapshot.length > 0
// For active seasons without snapshot, load live leaderboard
let leaderboardData = season.final_snapshot;
let isLiveData = false;
if ((!leaderboardData || leaderboardData.length === 0) && season.status === 'active') {
try {
const lbResponse = await fetch(`${PAGES_BASE}/data/leaderboard.json`);
if (lbResponse.ok) {
const lb = await lbResponse.json();
if (lb.entries && lb.entries.length > 0) {
leaderboardData = lb.entries.slice(0, 30).map((entry: any, idx: number) => ({
bot_id: entry.bot_id,
bot_name: entry.name,
rating: entry.rating,
rank: idx + 1,
wins: entry.wins || 0,
losses: entry.losses || 0,
}));
isLiveData = true;
}
}
} catch {
// Live leaderboard fetch failed
}
}
const leaderboardHtml = leaderboardData && leaderboardData.length > 0
? `
<table class="leaderboard-table">
<thead>
@ -512,7 +715,7 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
</tr>
</thead>
<tbody>
${season.final_snapshot.map((entry: SeasonSnapshot) => {
${leaderboardData.map((entry: SeasonSnapshot) => {
const total = entry.wins + entry.losses;
const winRate = total > 0 ? (entry.wins / total * 100).toFixed(0) : '-';
const winWidth = maxGames > 0 ? (entry.wins / maxGames * 60) : 0;
@ -534,6 +737,7 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
`}).join('')}
</tbody>
</table>
${isLiveData ? '<p style="color: var(--text-muted); font-size: 0.7rem; text-align: center; margin-top: 8px;">Live ratings from current season</p>' : ''}
`
: '<p style="color: var(--text-muted); text-align: center; padding: 24px;">No leaderboard data available yet.</p>';
@ -565,21 +769,25 @@ async function showSeasonDetail(seasonId: string): Promise<void> {
<div class="stat-value">${season.total_matches}</div>
<div class="stat-label">Matches Played</div>
</div>
${season.final_snapshot ? `
${leaderboardData ? `
<div class="stat-card">
<div class="stat-value">${season.final_snapshot.length}</div>
<div class="stat-value">${leaderboardData.length}</div>
<div class="stat-label">Ranked Bots</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
${leaderboardData && leaderboardData.length > 0 ? `
<div class="stat-card">
<div class="stat-value">${Math.round(season.final_snapshot[0].rating)}</div>
<div class="stat-value">${Math.round(leaderboardData[0].rating)}</div>
<div class="stat-label">Highest Rating</div>
</div>
` : ''}
</div>
<h3 style="margin-bottom: 16px;">Season Leaderboard</h3>
${season.championship_bracket && season.championship_bracket.length > 0
? renderChampionshipBracket(season.championship_bracket)
: ''}
<h3 style="margin-bottom: 16px;">${isLiveData ? 'Live Standings' : 'Season Leaderboard'}</h3>
${leaderboardHtml}
<div class="season-rules">

View file

@ -1,6 +1,14 @@
// Series Page - Browse multi-game series between bots with bracket visualization
import type { Series, SeriesIndex, SeriesGame } from '../types';
import type { BotProfile } from '../api-types';
function bracketRoundLabel(round?: string): string {
switch (round) {
case 'quarterfinal': return 'Quarterfinals';
case 'semifinal': return 'Semifinals';
case 'final': return 'Final';
default: return '';
}
}
const PAGES_BASE = '';
@ -35,7 +43,7 @@ export async function renderSeriesPage(): Promise<void> {
</div>
<div class="series-detail" id="series-detail" style="display: none;">
<button class="back-btn" id="back-btn"> Back to Series</button>
<button class="back-btn" id="back-btn">&larr; Back to Series</button>
<div id="series-detail-content"></div>
</div>
</div>
@ -119,6 +127,7 @@ export async function renderSeriesPage(): Promise<void> {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.series-bot {
@ -255,6 +264,116 @@ export async function renderSeriesPage(): Promise<void> {
.detail-score .score-b { color: #ef4444; }
.detail-score .score-dash { color: var(--text-muted); }
/* Bracket tree visualization */
.bracket-tree {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 24px;
padding: 16px;
background-color: var(--bg-secondary);
border-radius: 8px;
}
.bracket-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.bracket-tree-header h3 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0;
}
.bracket-round-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 600;
padding: 4px 0 2px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.bracket-matchup {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
gap: 8px;
border-left: 3px solid var(--border);
}
.bracket-matchup.team-a-win { border-left-color: #3b82f6; }
.bracket-matchup.team-b-win { border-left-color: #ef4444; }
.bracket-seed {
font-size: 0.65rem;
color: var(--text-muted);
font-weight: 700;
min-width: 20px;
}
.bracket-team {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
}
.bracket-team.winner { color: var(--accent); }
.bracket-team.loser { color: var(--text-muted); text-decoration: line-through; }
.bracket-score {
font-family: monospace;
font-size: 0.8rem;
font-weight: 700;
min-width: 16px;
text-align: center;
}
.bracket-score.won { color: #22c55e; }
.bracket-score.lost { color: #ef4444; }
.bracket-vs {
font-size: 0.65rem;
color: var(--text-muted);
}
.map-type-label {
font-size: 0.6rem;
color: var(--text-muted);
opacity: 0.7;
min-width: 60px;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Progress bar for series */
.series-progress-bar {
height: 6px;
background-color: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.series-progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.series-progress-fill.bot-a-fill { background-color: #3b82f6; }
.series-progress-fill.bot-b-fill { background-color: #ef4444; }
/* Large bracket for detail view */
.detail-bracket {
display: flex;
@ -383,25 +502,16 @@ async function loadSeries(): Promise<void> {
return;
}
// Populate bot filter
// Build bot name map from series data (names are already included in the JSON)
const botNames = new Map<string, string>();
const bots = new Set<string>();
index.series.forEach((s: Series) => {
bots.add(s.bot1_id);
bots.add(s.bot2_id);
if (s.bot1_name) botNames.set(s.bot1_id, s.bot1_name);
if (s.bot2_name) botNames.set(s.bot2_id, s.bot2_name);
});
// Fetch bot names
const botNames = new Map<string, string>();
for (const botId of bots) {
try {
const botRes = await fetch(`${PAGES_BASE}/data/bots/${botId}.json`);
if (botRes.ok) {
const bot: BotProfile = await botRes.json();
botNames.set(botId, bot.name);
}
} catch {}
}
// Update filter options
bots.forEach(botId => {
const option = document.createElement('option');
@ -411,7 +521,7 @@ async function loadSeries(): Promise<void> {
});
// Render series cards with bracket visualization
renderSeriesList(index.series, list, botNames);
renderSeriesList(index.series, list);
// Filter handlers
const applyFilters = () => {
@ -422,7 +532,7 @@ async function loadSeries(): Promise<void> {
if (botVal && s.bot1_id !== botVal && s.bot2_id !== botVal) return false;
return true;
});
renderSeriesList(filtered, list, botNames);
renderSeriesList(filtered, list);
};
statusFilter.addEventListener('change', applyFilters);
@ -462,8 +572,15 @@ function renderBracketProgress(bot1Name: string, bot2Name: string, games: Series
`;
}
function renderSeriesList(series: Series[], container: HTMLElement, _botNames: Map<string, string>): void {
container.innerHTML = series.map(s => `
function renderSeriesList(series: Series[], container: HTMLElement): void {
container.innerHTML = series.map(s => {
const winsNeeded = Math.ceil(s.best_of / 2);
const totalProgress = s.bot1_wins + s.bot2_wins;
const aPct = (s.bot1_wins / winsNeeded) * 100;
const bPct = (s.bot2_wins / winsNeeded) * 100;
const roundLabel = bracketRoundLabel(s.bracket_round);
return `
<div class="series-card" data-series-id="${s.id}">
<div class="series-header">
<div class="series-matchup">
@ -475,15 +592,21 @@ function renderSeriesList(series: Series[], container: HTMLElement, _botNames: M
<span class="series-bot-name">${s.bot2_name}</span>
</div>
</div>
${roundLabel ? `<span class="bracket-round-badge">${roundLabel}</span>` : ''}
</div>
${renderBracketProgress(s.bot1_name, s.bot2_name, s.games || [], s.best_of)}
<div class="series-progress-bar">
<div class="series-progress-fill bot-a-fill" style="width: ${Math.min(aPct, 100)}%; float: left;"></div>
<div class="series-progress-fill bot-b-fill" style="width: ${Math.min(bPct, 100)}%; float: right;"></div>
</div>
<div class="series-meta">
<span class="status-badge ${s.status}">${s.status}</span>
<span>Best of ${s.best_of} &middot; ${s.bot1_wins}-${s.bot2_wins}</span>
<span>Best of ${s.best_of} &middot; ${s.bot1_wins}-${s.bot2_wins} ${totalProgress < s.best_of ? `(${s.best_of - totalProgress} games left)` : ''}</span>
<span>${s.completed_at ? new Date(s.completed_at).toLocaleDateString() : 'In progress'}</span>
</div>
</div>
`).join('');
`;
}).join('');
// Wire click handlers
container.querySelectorAll('.series-card').forEach(card => {
@ -494,6 +617,86 @@ function renderSeriesList(series: Series[], container: HTMLElement, _botNames: M
});
}
function renderBracketTree(series: Series): string {
const games = series.games || [];
const winsNeeded = Math.ceil(series.best_of / 2);
// Map types per §14.7: game 1 = classic, 2 = corridors, 3 = open, 4+ = untested
function mapTypeLabel(gameNum: number): string {
switch (gameNum) {
case 1: return 'Classic';
case 2: return 'Corridors';
case 3: return 'Open Field';
case 4: return 'New Terrain';
default: return 'Random';
}
}
// Render each game as a matchup row in the bracket tree
const gameRows = games.map((g, idx) => {
const isAWin = g.winner_slot === 0;
const isBWin = g.winner_slot === 1;
const isDecider = series.bot1_wins === winsNeeded - 1 && series.bot2_wins === winsNeeded - 1 && !g.winner_id;
const rowClass = isAWin ? 'team-a-win' : isBWin ? 'team-b-win' : '';
const mapLabel = mapTypeLabel(idx + 1);
return `
<div class="bracket-matchup ${rowClass}">
<span class="bracket-seed">${idx + 1}</span>
<span class="bracket-team ${isAWin ? 'winner' : isBWin ? 'loser' : ''}">${series.bot1_name}</span>
<span class="bracket-score ${isAWin ? 'won' : isBWin ? 'lost' : ''}">${isAWin ? 'W' : isBWin ? '' : '-'}</span>
<span class="bracket-vs">vs</span>
<span class="bracket-score ${isBWin ? 'won' : isAWin ? 'lost' : ''}">${isBWin ? 'W' : isAWin ? '' : '-'}</span>
<span class="bracket-team ${isBWin ? 'winner' : isAWin ? 'loser' : ''}">${series.bot2_name}</span>
<span class="map-type-label">${mapLabel}</span>
${g.match_id ? `<button class="watch-btn" data-match-id="${g.match_id}" style="margin-left: auto; font-size: 0.7rem; padding: 2px 8px;">Watch</button>` : ''}
${isDecider ? '<span style="color: gold; font-size: 0.7rem; font-weight: 600; margin-left: 4px;">DECIDER</span>' : ''}
</div>
`;
});
// Add remaining games if series is still active
const remainingGames = series.best_of - games.length;
if (remainingGames > 0 && series.status !== 'completed') {
for (let i = games.length; i < series.best_of; i++) {
const isDecider = series.bot1_wins === winsNeeded - 1 && series.bot2_wins === winsNeeded - 1;
const mapLabel = mapTypeLabel(i + 1);
gameRows.push(`
<div class="bracket-matchup" style="opacity: 0.5;">
<span class="bracket-seed">${i + 1}</span>
<span class="bracket-team">${series.bot1_name}</span>
<span class="bracket-score">-</span>
<span class="bracket-vs">vs</span>
<span class="bracket-score">-</span>
<span class="bracket-team">${series.bot2_name}</span>
<span class="map-type-label">${mapLabel}</span>
${isDecider ? '<span style="color: gold; font-size: 0.7rem; font-weight: 600; margin-left: 4px;">DECIDER</span>' : ''}
</div>
`);
}
}
// Group games into rounds for longer series
const rounds: string[][] = [];
if (series.best_of <= 5) {
rounds.push(gameRows);
} else {
// For bo7: group games 1-4, 5-7
rounds.push(gameRows.slice(0, 4));
if (gameRows.length > 4) rounds.push(gameRows.slice(4));
}
return rounds.map((round, ri) => {
const roundLabel = rounds.length > 1
? (ri === 0 ? 'Games 1-4' : `Games 5-${series.best_of}`)
: `Games 1-${series.best_of}`;
return `
<div class="bracket-round-label">${roundLabel}</div>
${round.join('')}
`;
}).join('');
}
async function showSeriesDetail(seriesId: string): Promise<void> {
const list = document.getElementById('series-list');
const detail = document.getElementById('series-detail');
@ -548,6 +751,14 @@ async function showSeriesDetail(seriesId: string): Promise<void> {
${renderBracketProgress(series.bot1_name, series.bot2_name, series.games || [], series.best_of)}
<div class="bracket-tree">
<div class="bracket-tree-header">
<h3>Bracket</h3>
<span style="font-size: 0.75rem; color: var(--text-muted);">Best of ${series.best_of}</span>
</div>
${renderBracketTree(series)}
</div>
<div class="detail-bracket">
<h3 style="margin-bottom: 12px; font-size: 0.875rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;">Game Results</h3>
${gamesHtml}

View file

@ -79,11 +79,11 @@ async function loadFeaturedPlaylists(): Promise<void> {
return;
}
const featured = data.playlists.slice(0, 4);
const featured = data.playlists.slice(0, 6);
container.innerHTML = featured.map((p: any) => `
<a href="#/watch/replays" class="playlist-preview-card">
<a href="#/watch/playlists/${p.slug}" class="playlist-preview-card">
<h3>${escapeHtml(p.title)}</h3>
<p>${p.match_count} matches</p>
<p>${p.match_count} matches · ${escapeHtml(p.description || '').substring(0, 60)}</p>
</a>
`).join('');
} catch {

View file

@ -1,4 +1,4 @@
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode } from './types';
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode, EnrichedCommentary } from './types';
// ── Particle System (pooled, 100 objects, zero GC) ──────────────────────────────
interface Particle {
@ -402,6 +402,11 @@ export class ReplayViewer {
public onTurnChange?: (turn: number) => void;
public onPlayStateChange?: (playing: boolean) => void;
public onReplayLoad?: (replay: Replay) => void;
public onCommentaryChange?: (entry: { turn: number; text: string; type: string } | null) => void;
// Enriched commentary state (§13.3)
private commentary: EnrichedCommentary | null = null;
private commentaryEnabled: boolean = true;
constructor(canvas: HTMLCanvasElement, options: ViewerOptions = {}) {
this.canvas = canvas;
@ -577,6 +582,47 @@ export class ReplayViewer {
return this.showDebug;
}
// ── Enriched Commentary Controls (§13.3) ──────────────────────────────────────
setCommentary(commentary: EnrichedCommentary | null): void {
this.commentary = commentary;
this.fireCommentaryForTurn(this.currentTurn);
}
getCommentary(): EnrichedCommentary | null {
return this.commentary;
}
setCommentaryEnabled(enabled: boolean): void {
this.commentaryEnabled = enabled;
this.fireCommentaryForTurn(this.currentTurn);
}
getCommentaryEnabled(): boolean {
return this.commentaryEnabled;
}
// Get the active commentary entry for a given turn
getCommentaryForTurn(turn: number): { turn: number; text: string; type: string } | null {
if (!this.commentary || !this.commentaryEnabled) return null;
// Find the most recent entry at or before this turn
let best: { turn: number; text: string; type: string } | null = null;
for (const entry of this.commentary.entries) {
if (entry.turn <= turn) {
best = entry;
} else {
break;
}
}
return best;
}
private fireCommentaryForTurn(turn: number): void {
if (this.onCommentaryChange) {
this.onCommentaryChange(this.getCommentaryForTurn(turn));
}
}
destroy(): void {
this.stopRenderLoop();
this.isPlaying = false;
@ -807,6 +853,7 @@ export class ReplayViewer {
}
if (this.onTurnChange) this.onTurnChange(this.currentTurn);
this.fireCommentaryForTurn(this.currentTurn);
}
private fireTurnAnimations(turnData: ReplayTurn): void {

View file

@ -150,6 +150,20 @@ export interface ReplayTurnWithDebug extends ReplayTurn {
// View mode types for replay viewer
export type ViewMode = 'standard' | 'dots' | 'voronoi' | 'influence';
// Enriched commentary types (§13.3)
export interface CommentaryEntry {
turn: number;
text: string;
type: 'setup' | 'action' | 'reaction' | 'climax' | 'denouement';
}
export interface EnrichedCommentary {
match_id: string;
generated_at: string;
criteria: string[];
entries: CommentaryEntry[];
}
// Series types
export interface SeriesGame {
match_id: string;
@ -171,6 +185,8 @@ export interface Series {
bot1_wins: number;
bot2_wins: number;
winner_id: string | null;
bracket_round?: string;
bracket_position?: number;
scheduled_at: string | null;
completed_at: string | null;
games: SeriesGame[];
@ -199,6 +215,22 @@ export interface SeasonSnapshot {
losses: number;
}
export interface ChampionshipBracketSeries {
id: string;
bot1_id: string;
bot2_id: string;
bot1_name: string;
bot2_name: string;
best_of: number;
bot1_wins: number;
bot2_wins: number;
status: string;
winner_id: string | null;
round: string;
bracket_position: number;
games: SeriesGame[];
}
export interface Season {
id: string;
name: string;
@ -211,6 +243,7 @@ export interface Season {
champion_name: string | null;
total_matches: number;
final_snapshot: SeasonSnapshot[] | null;
championship_bracket?: ChampionshipBracketSeries[];
}
export interface SeasonIndex {