1package main23import (4 "bytes"5 "encoding/json"6 "fmt"7 "io/fs"8 "os"9 "path/filepath"10 "sort"11 "strings"1213 "github.com/FelineStateMachine/atlas/internal/bundle"14 "github.com/FelineStateMachine/atlas/internal/semconv"15)1617// policyRevision orders builds of the same capture. The data has not moved18// between them, but what this tool makes of a capture has -- a merge rule, a19// kept field, a dropped category -- and among equal captures the registry20// serves the highest revision. Bump it when a policy change should supersede21// the builds already in every library.22//23// 1 first revisioned builds24// 2 merge resolution: subset names, adoption, one-to-one matches25// 3 origin provenance on every map; overlap merges across map slugs26// 4 semantic conventions: attributes ride every payload27// 5 standard icons resolved for categories that declare one28// 6 geometry declared: spheres say so, pins carry true coordinates29// 7 attribute-level merge resolution; ledgers name canonical source slugs30// 8 zone prose defers to the text payload; zones mark hasText31// 9 format v3: one collections array of point, path, and area features32const policyRevision = 93334// writeBundles packs each game into its own .atlas file, named by game,35// capture day, and stamp. The directory is a registry, not a mirror: a new36// capture lands beside the builds before it rather than over them, nothing37// is pruned, and the newest-wins fold at serving time is what decides which38// version a reader sees.39func writeBundles(out catalog, tiles tileManifest, tilesDir, bundleDir string) error {40 if err := os.MkdirAll(bundleDir, 0o755); err != nil {41 return fmt.Errorf("create bundle directory: %w", err)42 }43 stampByAsset := make(map[string]string, len(tiles.Lenses))44 for _, variant := range tiles.Lenses {45 stampByAsset[variant.AssetPath] = variant.Stamp46 }4748 for _, game := range out.Volumes {49 if err := writeVolumeBundle(game, out.TileGrid, stampByAsset, tilesDir, bundleDir); err != nil {50 return fmt.Errorf("%s: %w", game.Title, err)51 }52 }53 return writeRegistryIndex(bundleDir)54}5556// volumePayload is one map's three parts, built once and used for both the57// stamp and the archive.58type volumePayload struct {59 slug string60 detail []byte61 packed []byte62 text []byte63}6465func writeVolumeBundle(66 game catalogVolume,67 grid tileGrid,68 stampByAsset map[string]string,69 tilesDir, bundleDir string,70) error {71 manifest := bundle.Manifest{72 Format: bundle.Format,73 FormatVersion: bundle.FormatVersion,74 // The manifest names the vocabulary the payloads actually speak,75 // which since the unified wire is the registry's own version.76 Conventions: semconv.Version,77 Volume: bundle.Volume{Slug: game.Slug, Title: game.Title},78 Version: bundle.Version{Revision: policyRevision},79 TileGrid: bundle.TileGrid{80 SourceZoom: grid.SourceZoom,81 FirstTile: grid.FirstTile,82 TileSize: grid.TileSize,83 Size: grid.Size,84 },85 }8687 // Pyramids are shared: several pieces of a split sheet draw from the one88 // raster, so tile sets are collected across maps and written once.89 pyramids := make(map[string]string)90 var payloads []volumePayload91 var stamp bundle.Stamp92 for _, m := range game.Worlds {93 detail, packed, text := buildPayload(m)94 detail.Lenses = append([]lens(nil), detail.Lenses...)95 for index := range detail.Lenses {96 local := localPyramidName(game.Slug, detail.Lenses[index].Tiles)97 pyramids[local] = detail.Lenses[index].Tiles98 detail.Lenses[index].Tiles = local99 }100101 detailJSON, err := json.Marshal(detail)102 if err != nil {103 return fmt.Errorf("world %s: marshal payload: %w", m.Slug, err)104 }105 textJSON, err := json.Marshal(text)106 if err != nil {107 return fmt.Errorf("world %s: marshal text: %w", m.Slug, err)108 }109 payloads = append(payloads, volumePayload{slug: m.Slug, detail: detailJSON, packed: packed, text: textJSON})110 stamp.Add("worlds/"+m.Slug+".json", bundle.HashBytes(detailJSON))111 stamp.Add("worlds/"+m.Slug+".bin", bundle.HashBytes(packed))112 stamp.Add("worlds/"+m.Slug+".text", bundle.HashBytes(textJSON))113114 counts := m.featureTally()115 manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{116 Slug: m.Slug,117 Title: m.Title,118 Parent: m.Parent,119 IconOutset: m.IconOutset,120 Center: bundle.Coordinate{Lat: m.Center.Latitude, Lng: m.Center.Longitude},121 Points: counts.Point,122 Paths: counts.Path,123 Areas: counts.Area,124 UpdatedAt: m.UpdatedAt,125 })126 }127128 // A pyramid's own stamp already names the source tiles and the tool that129 // derived them, so the tiles weigh into the bundle's stamp without being130 // read until the bundle is actually written.131 for local, asset := range pyramids {132 stamp.Add("tiles/"+local, stampByAsset[asset])133 }134 iconNames := make([]string, 0, len(game.Icons))135 for name, data := range game.Icons {136 iconNames = append(iconNames, name)137 stamp.Add("icons/"+name, bundle.HashBytes(data))138 }139 sort.Strings(iconNames)140 identity, err := json.Marshal(manifest)141 if err != nil {142 return err143 }144 stamp.Add(bundle.ManifestName, bundle.HashBytes(identity))145 manifest.Version.Stamp = stamp.Sum()146147 // The version is the capture, not the build: the newest capture time148 // across the game's maps. Building the same archive anywhere, any time,149 // yields the same version and the same file name, which is what lets a150 // directory of these files stand as a registry.151 for _, m := range game.Worlds {152 if m.UpdatedAt > manifest.Version.CreatedAt {153 manifest.Version.CreatedAt = m.UpdatedAt154 }155 }156 if manifest.Version.CreatedAt == "" {157 return fmt.Errorf("no world carries a capture time to version the bundle by")158 }159160 // This exact build already in the registry keeps its file untouched.161 target := filepath.Join(bundleDir, bundle.VersionedFileName(manifest))162 if _, err := os.Stat(target); err == nil {163 return nil164 }165166 file, err := os.CreateTemp(bundleDir, game.Slug+".atlas.tmp-*")167 if err != nil {168 return err169 }170 defer os.Remove(file.Name())171 defer file.Close()172173 writer, err := bundle.NewWriter(file, manifest)174 if err != nil {175 return err176 }177 for _, payload := range payloads {178 if err := writer.AddDeflated("worlds/"+payload.slug+".json", payload.detail); err != nil {179 return err180 }181 if err := writer.AddStored("worlds/"+payload.slug+".bin", bytes.NewReader(payload.packed)); err != nil {182 return err183 }184 if err := writer.AddDeflated("worlds/"+payload.slug+".text", payload.text); err != nil {185 return err186 }187 }188 locals := make([]string, 0, len(pyramids))189 for local := range pyramids {190 locals = append(locals, local)191 }192 sort.Strings(locals)193 for _, local := range locals {194 if err := addPyramid(writer, filepath.Join(tilesDir, pyramids[local]), local); err != nil {195 return err196 }197 }198 for _, name := range iconNames {199 if err := writer.AddDeflated("icons/"+name, game.Icons[name]); err != nil {200 return err201 }202 }203 if err := writer.Close(); err != nil {204 return err205 }206 if err := file.Close(); err != nil {207 return err208 }209 // CreateTemp opens private files; a bundle is an artifact anyone may copy.210 if err := os.Chmod(file.Name(), 0o644); err != nil {211 return err212 }213 if err := os.Rename(file.Name(), target); err != nil {214 return err215 }216217 // What the manifest promises is checked the moment the bundle exists, so218 // a bad bundle fails this build rather than someone's import.219 written, err := bundle.Open(target)220 if err != nil {221 return err222 }223 defer written.Close()224 if err := written.Validate(); err != nil {225 return fmt.Errorf("validate %s: %w", filepath.Base(target), err)226 }227 fmt.Printf("bundled %s (%d worlds)\n", filepath.Base(target), len(game.Worlds))228 return nil229}230231// registryVersion is one build of one volume as the registry index lists it.232type registryVersion struct {233 File string `json:"file"`234 Stamp string `json:"stamp"`235 CreatedAt string `json:"createdAt"`236 Revision int `json:"revision,omitempty"`237 Size int64 `json:"size"`238 Maps int `json:"maps"`239}240241type registryEntry struct {242 Slug string `json:"slug"`243 Title string `json:"title"`244 Versions []registryVersion `json:"versions"`245}246247// writeRegistryIndex reads every bundle in the directory back and writes248// index.json beside them: each game, each of its builds, newest first. The249// index is derived from the files it sits with, never the other way around,250// so a hand-copied or hand-deleted bundle is reflected by the next run251// rather than contradicted by a stale listing.252func writeRegistryIndex(bundleDir string) error {253 paths, err := filepath.Glob(filepath.Join(bundleDir, "*.atlas"))254 if err != nil {255 return err256 }257 entries := make(map[string]*registryEntry)258 newest := make(map[string]string)259 for _, path := range paths {260 opened, err := bundle.Open(path)261 if err != nil {262 fmt.Fprintf(os.Stderr, "generate: index: skipping %s: %v\n", filepath.Base(path), err)263 continue264 }265 manifest := opened.Manifest266 opened.Close()267 var size int64268 if info, err := os.Stat(path); err == nil {269 size = info.Size()270 }271 entry := entries[manifest.Volume.Slug]272 if entry == nil {273 entry = ®istryEntry{Slug: manifest.Volume.Slug}274 entries[manifest.Volume.Slug] = entry275 }276 // The newest version's title speaks for the game.277 if manifest.Version.CreatedAt >= newest[manifest.Volume.Slug] {278 newest[manifest.Volume.Slug] = manifest.Version.CreatedAt279 entry.Title = manifest.Volume.Title280 }281 entry.Versions = append(entry.Versions, registryVersion{282 File: filepath.Base(path),283 Stamp: manifest.Version.Stamp,284 CreatedAt: manifest.Version.CreatedAt,285 Revision: manifest.Version.Revision,286 Size: size,287 Maps: len(manifest.Worlds),288 })289 }290291 listed := make([]registryEntry, 0, len(entries))292 for _, entry := range entries {293 sort.Slice(entry.Versions, func(i, j int) bool {294 if entry.Versions[i].CreatedAt != entry.Versions[j].CreatedAt {295 return entry.Versions[i].CreatedAt > entry.Versions[j].CreatedAt296 }297 if entry.Versions[i].Revision != entry.Versions[j].Revision {298 return entry.Versions[i].Revision > entry.Versions[j].Revision299 }300 return entry.Versions[i].Stamp > entry.Versions[j].Stamp301 })302 listed = append(listed, *entry)303 }304 sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })305306 data, err := json.Marshal(struct {307 Games []registryEntry `json:"games"`308 }{Games: listed})309 if err != nil {310 return err311 }312 return os.WriteFile(filepath.Join(bundleDir, "index.json"), append(data, '\n'), 0o644)313}314315// localPyramidName strips the game the pyramid belonged to in the shared tile316// tree. Inside a bundle the game is the file itself, so its pyramids are317// named by map and layer alone.318func localPyramidName(gameSlug, assetPath string) string {319 return strings.TrimPrefix(assetPath, gameSlug+"__")320}321322// addPyramid streams one tile pyramid off disk into the bundle, stored: the323// rasters are already compressed, and a stored entry is served as a straight324// byte range.325func addPyramid(writer *bundle.Writer, pyramidDir, local string) error {326 return filepath.WalkDir(pyramidDir, func(path string, entry fs.DirEntry, err error) error {327 if err != nil {328 return err329 }330 if entry.IsDir() {331 return nil332 }333 relative, err := filepath.Rel(pyramidDir, path)334 if err != nil {335 return err336 }337 file, err := os.Open(path)338 if err != nil {339 return err340 }341 defer file.Close()342 return writer.AddStored("tiles/"+local+"/"+filepath.ToSlash(relative), file)343 })344}