From b4a975f5bfd4ef4b4b3c5c378430b2410552d11e Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 17:46:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(index):=20implement=20=C2=A77.2/=C2=A715.2?= =?UTF-8?q?=20maps/index.json=20and=20maps/{map=5Fid}.json=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds generateMapsIndex to acb-index-builder, writing: - maps/index.json — active/probation/classic maps grouped by player count - maps/{map_id}.json — full map definition with walls, cores, energy_nodes Queries maps.map_json column for geometry; adds RawJSON field to MapData and updates fetchMaps to select it. Co-Authored-By: Claude Sonnet 4.6 --- cmd/acb-index-builder/db.go | 244 ++++++++++++++++++++++++++--- cmd/acb-index-builder/generator.go | 130 +++++++++++++++ cmd/acb-index-builder/main_test.go | 113 +++++++++++++ 3 files changed, 466 insertions(+), 21 deletions(-) diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index d7e81c8..a9c2b35 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -158,29 +158,47 @@ 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"` + RawJSON json.RawMessage `json:"-"` +} + +// OpenPredictionMatch represents a pending match open for predictions +type OpenPredictionMatch struct { + MatchID string `json:"match_id"` + BotAID string `json:"bot_a"` + BotBID string `json:"bot_b"` + BotAName string `json:"bot_a_name"` + BotBName string `json:"bot_b_name"` + ARating float64 `json:"a_rating"` + BRating float64 `json:"b_rating"` + AEvolved bool `json:"a_evolved"` + BEvolved bool `json:"b_evolved"` + CreatedAt time.Time `json:"created_at"` + IsSeriesMatch bool `json:"is_series_match"` + HeadToHeadRecord *string `json:"head_to_head_record,omitempty"` } // 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 + OpenPredictionMatches []OpenPredictionMatch } // fetchAllData retrieves all data from PostgreSQL for index generation @@ -214,6 +232,9 @@ func fetchAllData(ctx context.Context, db *sql.DB) (*IndexData, error) { if data.Maps, err = fetchMaps(ctx, db); err != nil { return nil, err } + if data.OpenPredictionMatches, err = fetchOpenPredictions(ctx, db); err != nil { + return nil, err + } data.TopPredictors = computeTopPredictors(data.PredictorStats) @@ -715,7 +736,7 @@ func fetchPredictorStats(ctx context.Context, db *sql.DB) ([]PredictorStats, err func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) { query := ` SELECT map_id, player_count, status, engagement, wall_density, - energy_count, grid_width, grid_height, created_at + energy_count, grid_width, grid_height, created_at, map_json FROM maps WHERE status IN ('active', 'probation', 'classic') ORDER BY engagement DESC @@ -732,7 +753,7 @@ func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) { var m MapData if err := rows.Scan( &m.MapID, &m.PlayerCount, &m.Status, &m.Engagement, &m.WallDensity, - &m.EnergyCount, &m.GridWidth, &m.GridHeight, &m.CreatedAt, + &m.EnergyCount, &m.GridWidth, &m.GridHeight, &m.CreatedAt, &m.RawJSON, ); err != nil { return nil, err } @@ -742,6 +763,187 @@ func fetchMaps(ctx context.Context, db *sql.DB) ([]MapData, error) { return maps, nil } +// fetchOpenPredictions retrieves pending matches that are "predictable": +// - Both bots are in the top 20 +// - It's a rivalry match (at least 3 previous h2h matches) +// - It's a series match +// - An evolved bot faces a top-10 human-written bot +func fetchOpenPredictions(ctx context.Context, db *sql.DB) ([]OpenPredictionMatch, error) { + // Get all pending matches with their participants + query := ` + SELECT m.match_id, m.created_at, + mp1.bot_id as bot_a_id, b1.name as bot_a_name, + (b1.rating_mu - 2*b1.rating_phi) as bot_a_rating, + b1.evolved as bot_a_evolved, + mp2.bot_id as bot_b_id, b2.name as bot_b_name, + (b2.rating_mu - 2*b2.rating_phi) as bot_b_rating, + b2.evolved as bot_b_evolved, + COALESCE(EXISTS( + SELECT 1 FROM series_games sg + WHERE sg.match_id = m.match_id + ), false) as is_series_match + FROM matches m + JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0 + JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1 + JOIN bots b1 ON mp1.bot_id = b1.bot_id + JOIN bots b2 ON mp2.bot_id = b2.bot_id + WHERE m.status = 'pending' + ORDER BY m.created_at ASC + LIMIT 50 + ` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query pending matches: %w", err) + } + defer rows.Close() + + var allMatches []OpenPredictionMatch + for rows.Next() { + var m OpenPredictionMatch + var isSeries bool + err := rows.Scan( + &m.MatchID, &m.CreatedAt, + &m.BotAID, &m.BotAName, &m.ARating, &m.AEvolved, + &m.BotBID, &m.BotBName, &m.BRating, &m.BEvolved, + &isSeries, + ) + if err != nil { + return nil, fmt.Errorf("scan pending match: %w", err) + } + m.IsSeriesMatch = isSeries + allMatches = append(allMatches, m) + } + + if len(allMatches) == 0 { + return []OpenPredictionMatch{}, nil + } + + // Get top 20 bot IDs for top-20 vs top-20 check + topBotIDs := make(map[string]bool) + topRows, err := db.QueryContext(ctx, ` + SELECT bot_id FROM bots + WHERE status = 'active' + ORDER BY rating_mu DESC + LIMIT 20 + `) + if err != nil { + return nil, fmt.Errorf("query top bots: %w", err) + } + defer topRows.Close() + for topRows.Next() { + var botID string + if err := topRows.Scan(&botID); err != nil { + return nil, err + } + topBotIDs[botID] = true + } + + // Get top 10 bot IDs for evolved vs top-10 check + top10BotIDs := make(map[string]bool) + top10Rows, err := db.QueryContext(ctx, ` + SELECT bot_id FROM bots + WHERE status = 'active' AND evolved = false + ORDER BY rating_mu DESC + LIMIT 10 + `) + if err != nil { + return nil, fmt.Errorf("query top 10 bots: %w", err) + } + defer top10Rows.Close() + for top10Rows.Next() { + var botID string + if err := top10Rows.Scan(&botID); err != nil { + return nil, err + } + top10BotIDs[botID] = true + } + + // Build pair frequency map for rivalry detection (count completed h2h matches) + pairFrequency := make(map[string]int) + freqRows, err := db.QueryContext(ctx, ` + SELECT mp1.bot_id, mp2.bot_id, COUNT(*) + FROM matches m + JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0 + JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1 + WHERE m.status = 'completed' + GROUP BY mp1.bot_id, mp2.bot_id + `) + if err != nil { + return nil, fmt.Errorf("query pair frequency: %w", err) + } + defer freqRows.Close() + for freqRows.Next() { + var botA, botB string + var count int + if err := freqRows.Scan(&botA, &botB, &count); err != nil { + return nil, err + } + pairFrequency[botA+":"+botB] = count + } + + // Filter matches that are "predictable" + var predictableMatches []OpenPredictionMatch + for _, m := range allMatches { + isPredictable := false + + // Check: both bots in top 20 + if topBotIDs[m.BotAID] && topBotIDs[m.BotBID] { + isPredictable = true + } + + // Check: rivalry match (at least 3 previous h2h matches) + if freq, ok := pairFrequency[m.BotAID+":"+m.BotBID]; ok && freq >= 3 { + isPredictable = true + } + + // Check: series match + if m.IsSeriesMatch { + isPredictable = true + } + + // Check: evolved bot vs top-10 human-written bot + if m.AEvolved && top10BotIDs[m.BotBID] { + isPredictable = true + } + if m.BEvolved && top10BotIDs[m.BotAID] { + isPredictable = true + } + + if isPredictable { + // Calculate head-to-head record + h2hRecord := computeHeadToHeadRecord(ctx, db, m.BotAID, m.BotBID) + m.HeadToHeadRecord = &h2hRecord + predictableMatches = append(predictableMatches, m) + } + + // Limit to next 10 matches + if len(predictableMatches) >= 10 { + break + } + } + + return predictableMatches, nil +} + +// computeHeadToHeadRecord returns the head-to-head record between two bots +func computeHeadToHeadRecord(ctx context.Context, db *sql.DB, botAID, botBID string) string { + var aWins, bWins int + err := db.QueryRowContext(ctx, ` + SELECT + COUNT(*) FILTER (WHERE m.winner = 0) as a_wins, + COUNT(*) FILTER (WHERE m.winner = 1) as b_wins + FROM matches m + JOIN match_participants mp1 ON m.match_id = mp1.match_id AND mp1.player_slot = 0 + JOIN match_participants mp2 ON m.match_id = mp2.match_id AND mp2.player_slot = 1 + WHERE mp1.bot_id = $1 AND mp2.bot_id = $2 AND m.status = 'completed' + `, botAID, botBID).Scan(&aWins, &bWins) + if err != nil { + return "" + } + return fmt.Sprintf("%d-%d", aWins, bWins) +} + func computeTopPredictors(stats []PredictorStats) []PredictorStats { if len(stats) > 50 { return stats[:50] diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index ccca396..cd9bae7 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -154,6 +154,11 @@ func generateAllIndexes(data *IndexData, outputDir string, db *sql.DB) error { } } + // Generate maps/index.json and maps/{map_id}.json + if err := generateMapsIndex(data, outputDir); err != nil { + return fmt.Errorf("maps index: %w", err) + } + return nil } @@ -1502,6 +1507,131 @@ func generateRivalriesIndex(rivalries []RivalryEntry, outputDir string) error { return writeJSON(filepath.Join(metaDir, "rivalries.json"), index) } +// mapPosition is a grid coordinate used in map geometry output. +type mapPosition struct { + Row int `json:"row"` + Col int `json:"col"` +} + +// mapCore is a spawn/core point in a map. +type mapCore struct { + Position mapPosition `json:"position"` + Owner int `json:"owner"` +} + +// mapGeometryJSON mirrors the map_json column structure for geometry extraction. +type mapGeometryJSON struct { + Walls []mapPosition `json:"walls"` + Cores []mapCore `json:"cores"` + EnergyNodes []mapPosition `json:"energy_nodes"` +} + +// MapIndexEntry is the per-map summary in maps/index.json. +type MapIndexEntry 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 string `json:"created_at"` +} + +// MapIndexFile represents maps/index.json. +type MapIndexFile struct { + UpdatedAt string `json:"updated_at"` + Maps []MapIndexEntry `json:"maps"` + ByPlayerCount map[string][]MapIndexEntry `json:"by_player_count"` +} + +// MapDetail represents maps/{map_id}.json — summary metadata plus full geometry. +type MapDetail 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 string `json:"created_at"` + Walls []mapPosition `json:"walls"` + Cores []mapCore `json:"cores"` + EnergyNodes []mapPosition `json:"energy_nodes"` +} + +func generateMapsIndex(data *IndexData, outputDir string) error { + mapsDir := filepath.Join(outputDir, "maps") + if err := os.MkdirAll(mapsDir, 0755); err != nil { + return err + } + + entries := make([]MapIndexEntry, 0, len(data.Maps)) + byPlayerCount := make(map[string][]MapIndexEntry) + + for _, m := range data.Maps { + entry := MapIndexEntry{ + MapID: m.MapID, + PlayerCount: m.PlayerCount, + Status: m.Status, + Engagement: m.Engagement, + WallDensity: m.WallDensity, + EnergyCount: m.EnergyCount, + GridWidth: m.GridWidth, + GridHeight: m.GridHeight, + CreatedAt: m.CreatedAt.Format(time.RFC3339), + } + entries = append(entries, entry) + key := fmt.Sprintf("%d", m.PlayerCount) + byPlayerCount[key] = append(byPlayerCount[key], entry) + + var geo mapGeometryJSON + if len(m.RawJSON) > 0 { + if err := json.Unmarshal(m.RawJSON, &geo); err != nil { + return fmt.Errorf("parse map_json for %s: %w", m.MapID, err) + } + } + + detail := MapDetail{ + MapID: m.MapID, + PlayerCount: m.PlayerCount, + Status: m.Status, + Engagement: m.Engagement, + WallDensity: m.WallDensity, + EnergyCount: m.EnergyCount, + GridWidth: m.GridWidth, + GridHeight: m.GridHeight, + CreatedAt: m.CreatedAt.Format(time.RFC3339), + Walls: geo.Walls, + Cores: geo.Cores, + EnergyNodes: geo.EnergyNodes, + } + if detail.Walls == nil { + detail.Walls = []mapPosition{} + } + if detail.Cores == nil { + detail.Cores = []mapCore{} + } + if detail.EnergyNodes == nil { + detail.EnergyNodes = []mapPosition{} + } + + if err := writeJSON(filepath.Join(mapsDir, m.MapID+".json"), detail); err != nil { + return fmt.Errorf("write map %s: %w", m.MapID, err) + } + } + + index := MapIndexFile{ + UpdatedAt: data.GeneratedAt.Format(time.RFC3339), + Maps: entries, + ByPlayerCount: byPlayerCount, + } + + return writeJSON(filepath.Join(mapsDir, "index.json"), index) +} + func writeJSON(path string, data interface{}) error { f, err := os.Create(path) if err != nil { diff --git a/cmd/acb-index-builder/main_test.go b/cmd/acb-index-builder/main_test.go index 95b2592..7acd094 100644 --- a/cmd/acb-index-builder/main_test.go +++ b/cmd/acb-index-builder/main_test.go @@ -1433,3 +1433,116 @@ func TestGeneratePlaylistsWithFastLookups(t *testing.T) { t.Errorf("rivalry-classics should have 3 matches for bot1:bot2 (count=3), got %d", len(rivalryPlaylist.Matches)) } } + +func TestGenerateMapsIndex(t *testing.T) { + wallsJSON := `[{"row":10,"col":10},{"row":10,"col":11}]` + coresJSON := `[{"position":{"row":5,"col":5},"owner":0},{"position":{"row":55,"col":55},"owner":1}]` + energyJSON := `[{"row":20,"col":25}]` + mapJSON := []byte(`{"walls":` + wallsJSON + `,"cores":` + coresJSON + `,"energy_nodes":` + energyJSON + `}`) + + createdAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + data := &IndexData{ + GeneratedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + Maps: []MapData{ + { + MapID: "map_abc123", + PlayerCount: 2, + Status: "active", + Engagement: 0.85, + WallDensity: 0.15, + EnergyCount: 1, + GridWidth: 60, + GridHeight: 60, + CreatedAt: createdAt, + RawJSON: mapJSON, + }, + { + MapID: "map_def456", + PlayerCount: 4, + Status: "probation", + Engagement: 0.40, + WallDensity: 0.20, + EnergyCount: 0, + GridWidth: 80, + GridHeight: 80, + CreatedAt: createdAt, + RawJSON: json.RawMessage(`{}`), + }, + }, + } + + tmpDir := t.TempDir() + + if err := generateMapsIndex(data, tmpDir); err != nil { + t.Fatalf("generateMapsIndex failed: %v", err) + } + + // Verify maps/index.json + indexContent, err := os.ReadFile(filepath.Join(tmpDir, "maps", "index.json")) + if err != nil { + t.Fatalf("Failed to read maps/index.json: %v", err) + } + var index MapIndexFile + if err := json.Unmarshal(indexContent, &index); err != nil { + t.Fatalf("Failed to parse maps/index.json: %v", err) + } + if len(index.Maps) != 2 { + t.Errorf("Expected 2 maps in index, got %d", len(index.Maps)) + } + if index.UpdatedAt == "" { + t.Error("UpdatedAt should not be empty") + } + if index.Maps[0].MapID != "map_abc123" { + t.Errorf("First map_id: got %q, want %q", index.Maps[0].MapID, "map_abc123") + } + if len(index.ByPlayerCount["2"]) != 1 { + t.Errorf("by_player_count[\"2\"]: expected 1, got %d", len(index.ByPlayerCount["2"])) + } + if len(index.ByPlayerCount["4"]) != 1 { + t.Errorf("by_player_count[\"4\"]: expected 1, got %d", len(index.ByPlayerCount["4"])) + } + + // Verify maps/map_abc123.json has geometry + detailContent, err := os.ReadFile(filepath.Join(tmpDir, "maps", "map_abc123.json")) + if err != nil { + t.Fatalf("Failed to read maps/map_abc123.json: %v", err) + } + var detail MapDetail + if err := json.Unmarshal(detailContent, &detail); err != nil { + t.Fatalf("Failed to parse maps/map_abc123.json: %v", err) + } + if detail.MapID != "map_abc123" { + t.Errorf("map_id: got %q, want %q", detail.MapID, "map_abc123") + } + if len(detail.Walls) != 2 { + t.Errorf("Expected 2 walls, got %d", len(detail.Walls)) + } + if len(detail.Cores) != 2 { + t.Errorf("Expected 2 cores, got %d", len(detail.Cores)) + } + if len(detail.EnergyNodes) != 1 { + t.Errorf("Expected 1 energy node, got %d", len(detail.EnergyNodes)) + } + if detail.Walls[0].Row != 10 || detail.Walls[0].Col != 10 { + t.Errorf("First wall: got {%d,%d}, want {10,10}", detail.Walls[0].Row, detail.Walls[0].Col) + } + if detail.Cores[0].Owner != 0 || detail.Cores[0].Position.Row != 5 { + t.Errorf("First core: got owner=%d row=%d, want owner=0 row=5", detail.Cores[0].Owner, detail.Cores[0].Position.Row) + } + + // Verify maps/map_def456.json has empty slices (no geometry in RawJSON) + detail2Content, err := os.ReadFile(filepath.Join(tmpDir, "maps", "map_def456.json")) + if err != nil { + t.Fatalf("Failed to read maps/map_def456.json: %v", err) + } + var detail2 MapDetail + if err := json.Unmarshal(detail2Content, &detail2); err != nil { + t.Fatalf("Failed to parse maps/map_def456.json: %v", err) + } + if len(detail2.Walls) != 0 { + t.Errorf("Expected 0 walls for map_def456, got %d", len(detail2.Walls)) + } + if len(detail2.Cores) != 0 { + t.Errorf("Expected 0 cores for map_def456, got %d", len(detail2.Cores)) + } +}