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:
parent
70b7337867
commit
b1121ed6f8
34 changed files with 3519 additions and 309 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
|||
/acb-api
|
||||
/acb-matchmaker
|
||||
/acb-evolver
|
||||
/acb-index-builder
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
0d887ebeb2f2e3db51f92adc2225646f2b451fe2
|
||||
70b73378678332a405a57342a001fdcec925adc7
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
523
cmd/acb-index-builder/enrichment.go
Normal file
523
cmd/acb-index-builder/enrichment.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
web/public/wasm/bots/gatherer.wasm
Executable file
BIN
web/public/wasm/bots/gatherer.wasm
Executable file
Binary file not shown.
BIN
web/public/wasm/bots/guardian.wasm
Executable file
BIN
web/public/wasm/bots/guardian.wasm
Executable file
Binary file not shown.
BIN
web/public/wasm/bots/hunter.wasm
Executable file
BIN
web/public/wasm/bots/hunter.wasm
Executable file
Binary file not shown.
BIN
web/public/wasm/bots/random.wasm
Executable file
BIN
web/public/wasm/bots/random.wasm
Executable file
Binary file not shown.
BIN
web/public/wasm/bots/rusher.wasm
Executable file
BIN
web/public/wasm/bots/rusher.wasm
Executable file
Binary file not shown.
BIN
web/public/wasm/bots/swarm.wasm
Executable file
BIN
web/public/wasm/bots/swarm.wasm
Executable file
Binary file not shown.
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -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">`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">← 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} · ${s.bot1_wins}-${s.bot2_wins}</span>
|
||||
<span>Best of ${s.best_of} · ${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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue