feat(index): implement §7.2/§15.2 maps/index.json and maps/{map_id}.json generation

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 17:46:27 -04:00
parent 89560e5ec4
commit b4a975f5bf
3 changed files with 466 additions and 21 deletions

View file

@ -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]

View file

@ -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 {

View file

@ -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))
}
}