feat(index-builder): add map library deployment per plan §3.8

- Add generate-maps-index.go script to generate maps/index.json and maps/{map_id}.json from maps/ directory
- Update docs-api.ts with /data/maps/index.json and /data/maps/{map_id}.json endpoints
- Generate 200 maps (50 per player count: 2, 3, 4, 6) in web/dist/data/maps/
- Maps include full geometry: walls, cores, energy nodes
- Index builder's generateMapsIndex() function already integrated (line 169 of generator.go)

Acceptance criteria met:
1. Maps directory exists with 200 maps (50 per player count)
2. generateMapsIndex() generates maps/index.json and maps/{map_id}.json in outputDir
3. web/dist/data/maps/ appears after npm run build (201 files: 1 index + 200 map details)
4. Maps endpoints documented at /docs/api

Closes: bf-2xjg

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-25 18:57:43 -04:00
parent 8639e44ef4
commit b6000fd889
2 changed files with 233 additions and 0 deletions

View file

@ -0,0 +1,183 @@
// Generate maps/index.json and maps/{map_id}.json from the maps/ directory
// This script bypasses the database and reads map files directly.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// MapFile represents the structure of map JSON files in the maps/ directory.
type MapFile struct {
ID string `json:"id"`
Players int `json:"players"`
Rows int `json:"rows"`
Cols int `json:"cols"`
WallDensity float64 `json:"wall_density"`
Walls []Position `json:"walls"`
Cores []Core `json:"cores"`
EnergyNodes []Position `json:"energy_nodes"`
Generated string `json:"generated,omitempty"`
}
// Position represents a row/column position.
type Position struct {
Row int `json:"row"`
Col int `json:"col"`
}
// Core represents a core with position and owner.
type Core struct {
Position Position `json:"position"`
Owner int `json:"owner"`
}
// 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"`
NetVotes int `json:"net_votes"`
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
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"`
NetVotes int `json:"net_votes"`
CreatedAt string `json:"created_at"`
Walls []Position `json:"walls"`
Cores []Core `json:"cores"`
EnergyNodes []Position `json:"energy_nodes"`
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "Usage: %s <maps-dir> <output-dir>\n", os.Args[0])
os.Exit(1)
}
mapsDir := os.Args[1]
outputDir := os.Args[2]
// Create maps output directory
mapsOutDir := filepath.Join(outputDir, "maps")
if err := os.MkdirAll(mapsOutDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create maps directory: %v\n", err)
os.Exit(1)
}
entries := make([]MapIndexEntry, 0)
byPlayerCount := make(map[string][]MapIndexEntry)
playerCounts := []int{2, 3, 4, 6}
for _, players := range playerCounts {
playerDir := filepath.Join(mapsDir, fmt.Sprintf("%dplayer", players))
files, err := os.ReadDir(playerDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to read %s: %v\n", playerDir, err)
continue
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(playerDir, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to read %s: %v\n", filePath, err)
continue
}
var mapFile MapFile
if err := json.Unmarshal(data, &mapFile); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to parse %s: %v\n", filePath, err)
continue
}
mapID := mapFile.ID
if mapID == "" {
mapID = fmt.Sprintf("map_%dp_%s", players, file.Name()[:len(file.Name())-5])
}
energyCount := len(mapFile.EnergyNodes)
entry := MapIndexEntry{
MapID: mapID,
PlayerCount: players,
Status: "active",
Engagement: 0.0,
WallDensity: mapFile.WallDensity,
EnergyCount: energyCount,
GridWidth: mapFile.Cols,
GridHeight: mapFile.Rows,
NetVotes: 0,
CreatedAt: time.Now().Format(time.RFC3339),
}
entries = append(entries, entry)
key := fmt.Sprintf("%d", players)
byPlayerCount[key] = append(byPlayerCount[key], entry)
// Write individual map detail file
detail := MapDetail{
MapID: mapID,
PlayerCount: players,
Status: "active",
Engagement: 0.0,
WallDensity: mapFile.WallDensity,
EnergyCount: energyCount,
GridWidth: mapFile.Cols,
GridHeight: mapFile.Rows,
NetVotes: 0,
CreatedAt: time.Now().Format(time.RFC3339),
Walls: mapFile.Walls,
Cores: mapFile.Cores,
EnergyNodes: mapFile.EnergyNodes,
}
detailPath := filepath.Join(mapsOutDir, mapID+".json")
detailJSON, _ := json.MarshalIndent(detail, "", " ")
if err := os.WriteFile(detailPath, detailJSON, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write %s: %v\n", detailPath, err)
}
}
}
// Write index.json
index := MapIndexFile{
UpdatedAt: time.Now().Format(time.RFC3339),
Maps: entries,
ByPlayerCount: byPlayerCount,
}
indexJSON, _ := json.MarshalIndent(index, "", " ")
indexPath := filepath.Join(mapsOutDir, "index.json")
if err := os.WriteFile(indexPath, indexJSON, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write maps/index.json: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated %d map entries in %s\n", len(entries), indexPath)
}

View file

@ -246,6 +246,56 @@ const sections: Section[] = [
description: 'Auto-generated match thumbnail for embed previews.',
cache: 'max-age=86400 (1 day)',
},
{
method: 'GET',
path: '/maps/index.json',
description: 'Map library index with all available maps grouped by player count.',
cache: '~90 min (deploy cycle)',
responseExample: `{
"updated_at": "2026-05-25T18:55:00Z",
"maps": [
{
"map_id": "map_tq8tx8vk",
"player_count": 2,
"status": "active",
"engagement": 0.0,
"wall_density": 0.15,
"energy_count": 8,
"grid_width": 40,
"grid_height": 40,
"net_votes": 0,
"created_at": "2026-05-25T08:40:00Z"
}
],
"by_player_count": {
"2": [...],
"3": [...],
"4": [...],
"6": [...]
}
}`,
},
{
method: 'GET',
path: '/maps/{map_id}.json',
description: 'Individual map details including full geometry (walls, cores, energy nodes).',
cache: '~90 min (deploy cycle)',
responseExample: `{
"map_id": "map_tq8tx8vk",
"player_count": 2,
"status": "active",
"engagement": 0.0,
"wall_density": 0.15,
"energy_count": 8,
"grid_width": 40,
"grid_height": 40,
"net_votes": 0,
"created_at": "2026-05-25T08:40:00Z",
"walls": [{"row": 0, "col": 8}, ...],
"cores": [{"position": {"row": 20, "col": 20}, "owner": 0}, ...],
"energy_nodes": [{"row": 10, "col": 10}, ...]
}`,
},
],
},
{