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>
| A | tools/ | 298 | ++++++++++++++++++++++++++++++ |
@@ -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+}