clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: report every build's maturity along the axes that judge it

Dami committed bbb4170
With sources mixing, the library holds several accounts of one game,
and judging their evolution needs figures rather than impressions.
tools/maturity reads every build and reports the three axes -- how
thoroughly locations are explained in writing, how much visual map
information the rasters hold once repeated filler is counted once, and
how much machine-readable structure rides beyond the pixels -- with the
icons the legend draws counted alongside, because a build that dressed
its categories is more mature than the same build in fallback glyphs.

Every number is an absolute measurement of one build, never a rank
within the collection: ranks move when the collection does, and a merge
gate needs figures that stand still. Night City already tells the
story: the IGN build reads shallow but dressed, the first Piggyback
build read deeper but bare, and the build now serving carries both.
commit
bbb4170440aa6e4aecc71984cb25c3f0da17d31b
parent
05f326058ec8ecd423980fd97390f809b909966e
author
Dami <dami.etoile@gmail.com>
1 file changed, +298 -0
Atools/maturity/main.go298++++++++++++++++++++++++++++++
A tools/maturity/main.go +298 -0
@@ -0,0 +1,298 @@
1+// Command maturity reads the library and reports how mature each build of2+// each game is, along the three axes that judge a game's evolution as its3+// sources mix: how thoroughly its locations are explained in writing, how4+// much visual map information its rasters hold, and how much machine-readable5+// structure it carries beyond the pixels -- with the icons its legend draws6+// counted alongside.7+//8+// Every number is an absolute measurement of one build, never a rank within9+// the collection: ranks move when the collection does, and a merge gate needs10+// figures that stand still. A build that adds icons, or descriptions, or a11+// deeper pyramid, reads higher than the build before it, and a build that12+// lost something reads lower -- which is the judgement the library exists to13+// make visible.14+package main15+16+import (17+ "archive/zip"18+ "encoding/json"19+ "flag"20+ "fmt"21+ "os"22+ "path/filepath"23+ "sort"24+ "strconv"25+ "strings"26+27+ "github.com/FelineStateMachine/atlas/internal/bundle"28+)29+30+type mapDetail struct {31+ Groups []struct {32+ Categories []struct {33+ IconAsset string `json:"iconAsset"`34+ DisplayType string `json:"displayType"`35+ Locations []struct {36+ ID int64 `json:"id"`37+ } `json:"locations"`38+ } `json:"categories"`39+ } `json:"groups"`40+ Zones []struct {41+ Features []struct {42+ Coordinates json.RawMessage `json:"coordinates"`43+ } `json:"features"`44+ } `json:"zones"`45+ Variants []struct {46+ Name string `json:"name"`47+ MaxZoom int `json:"maxZoom"`48+ } `json:"variants"`49+}50+51+// measure is one build's absolute account of itself.52+type measure struct {53+ File string54+ Stamp string55+ CreatedAt string56+57+ Pins int58+ Described int59+ MedianLength int60+ TileCount int61+ RasterBytes int6462+ Depth int63+ DepthTiles int64+ Categories int65+ TextSets int66+ Groups int67+ Zones int68+ Vertices int69+ Variants int70+ IconsCarried int71+ IconsWanted int72+}73+74+func main() {75+ bundles := flag.String("bundles", "",76+ "registry directory holding .atlas bundles (default: the application's own library)")77+ flag.Parse()78+ if *bundles == "" {79+ library, err := bundle.DefaultDir()80+ if err != nil {81+ fmt.Fprintln(os.Stderr, "maturity: resolving the bundle library:", err)82+ os.Exit(1)83+ }84+ *bundles = library85+ }86+ if err := run(*bundles); err != nil {87+ fmt.Fprintln(os.Stderr, "maturity:", err)88+ os.Exit(1)89+ }90+}91+92+func run(dir string) error {93+ paths, err := filepath.Glob(filepath.Join(dir, "*.atlas"))94+ if err != nil {95+ return err96+ }97+ if len(paths) == 0 {98+ return fmt.Errorf("no bundles in %s", dir)99+ }100+101+ byGame := make(map[string][]measure)102+ titles := make(map[string]string)103+ for _, path := range paths {104+ slug, title, m, err := measureBundle(path)105+ if err != nil {106+ return fmt.Errorf("%s: %w", filepath.Base(path), err)107+ }108+ byGame[slug] = append(byGame[slug], m)109+ titles[slug] = title110+ }111+112+ slugs := make([]string, 0, len(byGame))113+ for slug := range byGame {114+ slugs = append(slugs, slug)115+ }116+ sort.Strings(slugs)117+ for _, slug := range slugs {118+ builds := byGame[slug]119+ // The registry's own order: newest capture first, then stamp, so the120+ // first line of every game is the build the reader sees.121+ sort.Slice(builds, func(a, b int) bool {122+ if builds[a].CreatedAt != builds[b].CreatedAt {123+ return builds[a].CreatedAt > builds[b].CreatedAt124+ }125+ return builds[a].Stamp > builds[b].Stamp126+ })127+ fmt.Printf("%s (%s)\n", titles[slug], slug)128+ for at, m := range builds {129+ marker := " "130+ if at == 0 {131+ marker = "serving "132+ }133+ fmt.Printf(" %s%s\n", marker, m.File)134+ fmt.Printf(" annotation %5d pins · %d described (%s) · median %d chars\n",135+ m.Pins, m.Described, percent(m.Described, m.Pins), m.MedianLength)136+ fmt.Printf(" cartography %5d tiles · %.1f MB unique raster · depth z%d holds %d tiles\n",137+ m.TileCount, float64(m.RasterBytes)/1e6, m.Depth, m.DepthTiles)138+ fmt.Printf(" structure %5d categories in %d groups · %d text label sets · %d zones (%d vertices) · %d layers\n",139+ m.Categories, m.Groups, m.TextSets, m.Zones, m.Vertices, m.Variants)140+ fmt.Printf(" icons %5d of %d marker categories carry one (%s)\n",141+ m.IconsCarried, m.IconsWanted, percent(m.IconsCarried, m.IconsWanted))142+ }143+ }144+ return nil145+}146+147+func percent(part, whole int) string {148+ if whole == 0 {149+ return "—"150+ }151+ return strconv.Itoa(part*100/whole) + "%"152+}153+154+func measureBundle(path string) (string, string, measure, error) {155+ reader, err := zip.OpenReader(path)156+ if err != nil {157+ return "", "", measure{}, err158+ }159+ defer reader.Close()160+161+ entries := make(map[string]*zip.File, len(reader.File))162+ for _, file := range reader.File {163+ entries[file.Name] = file164+ }165+ var manifest bundle.Manifest166+ if err := readEntry(entries, "atlas.json", &manifest); err != nil {167+ return "", "", measure{}, err168+ }169+170+ m := measure{171+ File: filepath.Base(path),172+ Stamp: manifest.Version.Stamp,173+ CreatedAt: manifest.Version.CreatedAt,174+ }175+176+ // Annotation: how thoroughly the locations are explained in writing.177+ var lengths []int178+ for _, entry := range manifest.Maps {179+ m.Pins += entry.PinCount180+ var text map[string]struct {181+ Description string `json:"d"`182+ }183+ if err := readEntry(entries, "maps/"+entry.Slug+".text", &text); err != nil {184+ return "", "", measure{}, err185+ }186+ for _, held := range text {187+ cleaned := strings.Join(strings.Fields(held.Description), " ")188+ if cleaned == "" {189+ continue190+ }191+ m.Described++192+ lengths = append(lengths, len(cleaned))193+ }194+195+ var detail mapDetail196+ if err := readEntry(entries, "maps/"+entry.Slug+".json", &detail); err != nil {197+ return "", "", measure{}, err198+ }199+ m.Variants += len(detail.Variants)200+ for _, variant := range detail.Variants {201+ m.Depth = max(m.Depth, variant.MaxZoom)202+ }203+ m.Zones += len(detail.Zones)204+ for _, zone := range detail.Zones {205+ for _, feature := range zone.Features {206+ m.Vertices += countVertices(feature.Coordinates)207+ }208+ }209+ for _, group := range detail.Groups {210+ m.Groups++211+ for _, category := range group.Categories {212+ m.Categories++213+ if category.DisplayType == "text" {214+ m.TextSets++215+ continue216+ }217+ m.IconsWanted++218+ if category.IconAsset != "" {219+ m.IconsCarried++220+ }221+ }222+ }223+ }224+ sort.Ints(lengths)225+ if len(lengths) > 0 {226+ m.MedianLength = lengths[len(lengths)/2]227+ }228+229+ // Cartography: the stored representation, de-duplicated -- a border of230+ // repeated filler tiles is one tile's worth of information however many231+ // times it appears.232+ seen := make(map[[2]uint64]bool)233+ for name, file := range entries {234+ if !strings.HasPrefix(name, "tiles/") {235+ continue236+ }237+ m.TileCount++238+ key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}239+ if !seen[key] {240+ seen[key] = true241+ m.RasterBytes += int64(file.UncompressedSize64)242+ }243+ if zoom := tileZoom(name); zoom == m.Depth {244+ m.DepthTiles++245+ }246+ }247+248+ return manifest.Game.Slug, manifest.Game.Title, m, nil249+}250+251+func readEntry(entries map[string]*zip.File, name string, into any) error {252+ file, ok := entries[name]253+ if !ok {254+ return fmt.Errorf("%s is missing", name)255+ }256+ opened, err := file.Open()257+ if err != nil {258+ return err259+ }260+ defer opened.Close()261+ return json.NewDecoder(opened).Decode(into)262+}263+264+// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.265+func tileZoom(name string) int {266+ parts := strings.Split(name, "/")267+ if len(parts) < 5 {268+ return -1269+ }270+ zoom, err := strconv.Atoi(parts[len(parts)-3])271+ if err != nil {272+ return -1273+ }274+ return zoom275+}276+277+// countVertices counts coordinate pairs through however many rings and278+// nestings a geometry holds.279+func countVertices(raw json.RawMessage) int {280+ var value any281+ if err := json.Unmarshal(raw, &value); err != nil {282+ return 0283+ }284+ numbers := 0285+ var walk func(any)286+ walk = func(node any) {287+ switch typed := node.(type) {288+ case []any:289+ for _, child := range typed {290+ walk(child)291+ }292+ case float64:293+ numbers++294+ }295+ }296+ walk(value)297+ return numbers / 2298+}