The reader answers one question -- which build serves -- and hides the rest on purpose. Everything else about the collection lived in a terminal report: how mature each build stands, what its merge ledgers say, which builds an old capture left behind. Cartograph gives those answers a room of their own: a plain Go binary, no framework, serving a localhost dashboard whose pages are read straight off the library. The measurement is the maturity report's, absorbed whole -- the same three axes, the icon line beside them, absolute figures only -- but where the report counted the ledgers, the workbench keeps them: every matched pair, every held pin with its reason, every adoption, so a page can show not just how much was agreed but exactly what. Builds group by game in the registry's own order, measured once per file and cached by size and mtime, and the collection view reads each game through its serving build -- the one judgement the reader will act on. Fixtures learn to carry a merged block and to leave a pin unwritten, which is all the dashboard's arithmetic needs to be tested against real bundles rather than mocks.
- commit
- 27acbbeead4a50868056c1bd55e3b1e97d33a7fa
- parent
- fb1f9088aa472a431336866c0e3a52299b348933
- author
- Dami <dami.etoile@gmail.com>
| A | cmd/ | 137 | +++++++++++ |
| A | cmd/ | 49 | ++++ |
| A | cmd/ | 373 | ++++++++++++++++++++++++++++++ |
| A | cmd/ | 198 | ++++++++++++++++ |
| A | cmd/ | 88 | +++++++ |
| A | cmd/ | 82 | +++++++ |
| A | cmd/ | 35 | +++ |
| A | cmd/ | 50 | ++++ |
| A | cmd/ | 21 | ++ |
| A | cmd/ | 268 | ++++++++++++++++++++++ |
| M | internal/ | 35 | ++- |
@@ -0,0 +1,137 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "sort"7+ "sync"8+ "time"9+)10+11+// library reads the bundles directory whole on every ask and answers with12+// games grouped from what it found. Measuring a bundle costs a pass over its13+// table of contents and payload JSON, so measurements are cached by the14+// file's size and modification time -- the same test the reader's registry15+// uses to tell an untouched file from one rewritten under its name -- and a16+// page load after the first is a stat per file.17+type library struct {18+ dir string19+20+ mu sync.Mutex21+ cache map[string]cachedBuild22+}23+24+type cachedBuild struct {25+ size int6426+ modTime time.Time27+ build *build28+}29+30+// game is every build of one slug, newest first: the same order the reader's31+// registry folds by, so the first build of every game is the one the reader32+// serves.33+type game struct {34+ Slug string35+ Title string36+ Builds []*build37+}38+39+// Serving is the build the reader would pick.40+func (g *game) Serving() *build { return g.Builds[0] }41+42+// Build finds one build by file name, which is how diff URLs name them.43+func (g *game) Build(file string) *build {44+ for _, b := range g.Builds {45+ if b.File == file {46+ return b47+ }48+ }49+ return nil50+}51+52+// games measures every bundle in the directory and groups the builds by53+// game. A bundle that fails to measure is reported in skipped rather than54+// failing the scan: one bad download should not take the dashboard down.55+func (l *library) games() (games []*game, skipped []string, err error) {56+ paths, err := filepath.Glob(filepath.Join(l.dir, "*.atlas"))57+ if err != nil {58+ return nil, nil, err59+ }60+ sort.Strings(paths)61+62+ byGame := make(map[string]*game)63+ for _, path := range paths {64+ measured, err := l.measure(path)65+ if err != nil {66+ skipped = append(skipped, filepath.Base(path)+": "+err.Error())67+ continue68+ }69+ held, ok := byGame[measured.GameSlug]70+ if !ok {71+ held = &game{Slug: measured.GameSlug}72+ byGame[measured.GameSlug] = held73+ games = append(games, held)74+ }75+ held.Builds = append(held.Builds, measured)76+ }77+78+ for _, held := range byGame {79+ // The registry's own order: newest capture first, then the newer80+ // policy revision, then stamp, then path -- total, so two scans of81+ // one directory always agree.82+ sort.Slice(held.Builds, func(a, b int) bool {83+ left, right := held.Builds[a], held.Builds[b]84+ if left.CreatedAt != right.CreatedAt {85+ return left.CreatedAt > right.CreatedAt86+ }87+ if left.Revision != right.Revision {88+ return left.Revision > right.Revision89+ }90+ if left.Stamp != right.Stamp {91+ return left.Stamp > right.Stamp92+ }93+ return left.Path > right.Path94+ })95+ held.Title = held.Serving().GameTitle96+ }97+ sort.Slice(games, func(a, b int) bool { return games[a].Title < games[b].Title })98+ return games, skipped, nil99+}100+101+// gameBySlug scans and returns one game, or nil if the slug has no builds.102+func (l *library) gameBySlug(slug string) (*game, error) {103+ games, _, err := l.games()104+ if err != nil {105+ return nil, err106+ }107+ for _, held := range games {108+ if held.Slug == slug {109+ return held, nil110+ }111+ }112+ return nil, nil113+}114+115+func (l *library) measure(path string) (*build, error) {116+ info, err := os.Stat(path)117+ if err != nil {118+ return nil, err119+ }120+ l.mu.Lock()121+ held, ok := l.cache[path]122+ l.mu.Unlock()123+ if ok && held.size == info.Size() && held.modTime.Equal(info.ModTime()) {124+ return held.build, nil125+ }126+ measured, err := measureBundle(path)127+ if err != nil {128+ return nil, err129+ }130+ l.mu.Lock()131+ if l.cache == nil {132+ l.cache = make(map[string]cachedBuild)133+ }134+ l.cache[path] = cachedBuild{size: info.Size(), modTime: info.ModTime(), build: measured}135+ l.mu.Unlock()136+ return measured, nil137+}@@ -0,0 +1,49 @@
1+// Command cartograph is the workbench beside the Atlas reader. Where the2+// reader serves the newest build of every game and asks no questions,3+// cartograph answers for the collection itself: which sources feed each4+// game, how mature every build stands along the axes that judge it, and what5+// moved between any two builds of one game.6+//7+// It serves a dashboard on localhost and touches nothing on its own: reading8+// the library is free, and every operation that fetches or writes runs only9+// when a person submits it from the page.10+package main11+12+import (13+ "flag"14+ "fmt"15+ "net"16+ "net/http"17+ "os"18+19+ "github.com/FelineStateMachine/atlas/internal/bundle"20+)21+22+func main() {23+ bundles := flag.String("bundles", "",24+ "registry directory holding .atlas bundles (default: the application's own library)")25+ addr := flag.String("addr", "127.0.0.1:6180", "address the dashboard listens on")26+ flag.Parse()27+28+ if *bundles == "" {29+ library, err := bundle.DefaultDir()30+ if err != nil {31+ fmt.Fprintln(os.Stderr, "cartograph: resolving the bundle library:", err)32+ os.Exit(1)33+ }34+ *bundles = library35+ }36+37+ server := newServer(&library{dir: *bundles})38+ listener, err := net.Listen("tcp", *addr)39+ if err != nil {40+ fmt.Fprintln(os.Stderr, "cartograph:", err)41+ os.Exit(1)42+ }43+ fmt.Printf("cartograph: reading %s\n", *bundles)44+ fmt.Printf("cartograph: http://%s\n", listener.Addr())45+ if err := http.Serve(listener, server.routes()); err != nil {46+ fmt.Fprintln(os.Stderr, "cartograph:", err)47+ os.Exit(1)48+ }49+}@@ -0,0 +1,373 @@
1+package main2+3+import (4+ "archive/zip"5+ "encoding/json"6+ "fmt"7+ "path/filepath"8+ "sort"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/atlas/internal/bundle"13+)14+15+// The workbench judges a build along the same axes tools/maturity prints:16+// how thoroughly its locations are explained in writing, how much visual map17+// information its rasters hold, and how much machine-readable structure it18+// carries beyond the pixels, with the icons its legend draws counted19+// alongside. Every number is an absolute measurement of one build, never a20+// rank within the collection: ranks move when the collection does, and a21+// judgement worth acting on needs figures that stand still.22+//23+// Where the report stopped at counts, the workbench keeps the ledgers whole:24+// every matched pair, every held pin with its reason, every adoption -- so a25+// page can lay two builds' accounts beside each other and show exactly what26+// a policy change moved.27+28+// build is one .atlas file measured whole: its identity, the axes, and every29+// merge account its payloads carry.30+type build struct {31+ Path string32+ File string33+ GameSlug string34+ GameTitle string35+ Stamp string36+ CreatedAt string37+ Revision int38+ MapSlugs []string39+40+ Pins int41+ Described int42+ MedianLength int43+ TileCount int44+ RasterBytes int6445+ Depth int46+ DepthTiles int47+ Categories int48+ Groups int49+ TextSets int50+ Zones int51+ Vertices int52+ Variants int53+ IconsCarried int54+ IconsWanted int55+ Merges []mergeAccount56+}57+58+// mergeAccount is one source's account of its merge into one map, kept whole59+// rather than counted: the pairs and the held pins are what a diff compares.60+type mergeAccount struct {61+ Map string62+ Source string63+ DonorPins int64+ Matched []matchedPair65+ Added int66+ Adopted []adoptedPin67+ Held []heldPin68+ Rejected []heldPin69+ Alignment string70+}71+72+// matchedPair records one place both sources pin, in the ledger's own words.73+type matchedPair struct {74+ Donor int64 `json:"d"`75+ Winner int64 `json:"w"`76+ DistancePx int `json:"px"`77+ Enriched bool `json:"e"`78+}79+80+// adoptedPin records a donor-only pin that joined one of the serving map's81+// own categories.82+type adoptedPin struct {83+ Donor int64 `json:"d"`84+ Into string `json:"into"`85+}86+87+// heldPin is a donor pin the merge did not carry, with the reason.88+type heldPin struct {89+ Donor int64 `json:"d"`90+ Title string `json:"t"`91+ Reason string `json:"why"`92+}93+94+// mapDetail is the sliver of a map payload measurement reads.95+type mapDetail struct {96+ Groups []struct {97+ Categories []struct {98+ IconAsset string `json:"iconAsset"`99+ DisplayType string `json:"displayType"`100+ } `json:"categories"`101+ } `json:"groups"`102+ Merged []struct {103+ Source string `json:"source"`104+ DonorPins int `json:"donorPins"`105+ Matched []matchedPair `json:"matched"`106+ Added int `json:"added"`107+ Adopted []adoptedPin `json:"adopted"`108+ Held []heldPin `json:"held"`109+ Rejected []heldPin `json:"rejected"`110+ Alignment string `json:"alignment"`111+ } `json:"merged"`112+ Zones []struct {113+ Features []struct {114+ Coordinates json.RawMessage `json:"coordinates"`115+ } `json:"features"`116+ } `json:"zones"`117+ Variants []struct {118+ Name string `json:"name"`119+ MaxZoom int `json:"maxZoom"`120+ } `json:"variants"`121+}122+123+// MedianMatchPx is the middle distance of the account's matched pairs: the124+// figure that says how well the alignment held, unmoved by one outlier.125+func (m mergeAccount) MedianMatchPx() int {126+ if len(m.Matched) == 0 {127+ return 0128+ }129+ distances := make([]int, 0, len(m.Matched))130+ for _, pair := range m.Matched {131+ distances = append(distances, pair.DistancePx)132+ }133+ sort.Ints(distances)134+ return distances[len(distances)/2]135+}136+137+func (m mergeAccount) MatchedN() int { return len(m.Matched) }138+func (m mergeAccount) AdoptedN() int { return len(m.Adopted) }139+func (m mergeAccount) HeldN() int { return len(m.Held) }140+func (m mergeAccount) RejectedN() int { return len(m.Rejected) }141+142+// DescribedPct is the annotation share as pages print it.143+func (b *build) DescribedPct() string { return percent(b.Described, b.Pins) }144+145+// IconPct is the icon coverage as pages print it.146+func (b *build) IconPct() string { return percent(b.IconsCarried, b.IconsWanted) }147+148+// RasterMB is the unique raster weight in megabytes, one decimal.149+func (b *build) RasterMB() string { return fmt.Sprintf("%.1f", float64(b.RasterBytes)/1e6) }150+151+// ShortStamp is the stamp as pages carry it.152+func (b *build) ShortStamp() string { return bundle.ShortStamp(b.Stamp) }153+154+// SourcesSeen lists the distinct sources the build's merge accounts name,155+// sorted. A bundle is source-agnostic by design, so the ledger is the only156+// place a source's name survives into a build.157+func (b *build) SourcesSeen() []string {158+ seen := make(map[string]bool)159+ var names []string160+ for _, account := range b.Merges {161+ if !seen[account.Source] {162+ seen[account.Source] = true163+ names = append(names, account.Source)164+ }165+ }166+ sort.Strings(names)167+ return names168+}169+170+func percent(part, whole int) string {171+ if whole == 0 {172+ return "—"173+ }174+ return strconv.Itoa(part*100/whole) + "%"175+}176+177+// measureBundle reads one .atlas whole and returns its measurements. It reads178+// the zip directly rather than through bundle.Open because the cartography179+// axis needs what only the archive's table of contents knows: every tile180+// entry's checksum and size, for de-duplicating filler.181+func measureBundle(path string) (*build, error) {182+ reader, err := zip.OpenReader(path)183+ if err != nil {184+ return nil, err185+ }186+ defer reader.Close()187+188+ entries := make(map[string]*zip.File, len(reader.File))189+ for _, file := range reader.File {190+ entries[file.Name] = file191+ }192+ var manifest bundle.Manifest193+ if err := readEntry(entries, bundle.ManifestName, &manifest); err != nil {194+ return nil, err195+ }196+ if err := manifest.Validate(); err != nil {197+ return nil, err198+ }199+200+ b := &build{201+ Path: path,202+ File: filepath.Base(path),203+ GameSlug: manifest.Game.Slug,204+ GameTitle: manifest.Game.Title,205+ Stamp: manifest.Version.Stamp,206+ CreatedAt: manifest.Version.CreatedAt,207+ Revision: manifest.Version.Revision,208+ }209+210+ // Annotation: how thoroughly the locations are explained in writing.211+ var lengths []int212+ for _, entry := range manifest.Maps {213+ b.MapSlugs = append(b.MapSlugs, entry.Slug)214+ b.Pins += entry.PinCount215+ var text map[string]struct {216+ Description string `json:"d"`217+ }218+ if err := readEntry(entries, "maps/"+entry.Slug+".text", &text); err != nil {219+ return nil, err220+ }221+ for _, held := range text {222+ cleaned := strings.Join(strings.Fields(held.Description), " ")223+ if cleaned == "" {224+ continue225+ }226+ b.Described++227+ lengths = append(lengths, len(cleaned))228+ }229+230+ var detail mapDetail231+ if err := readEntry(entries, "maps/"+entry.Slug+".json", &detail); err != nil {232+ return nil, err233+ }234+ b.Variants += len(detail.Variants)235+ for _, variant := range detail.Variants {236+ b.Depth = max(b.Depth, variant.MaxZoom)237+ }238+ b.Zones += len(detail.Zones)239+ for _, zone := range detail.Zones {240+ for _, feature := range zone.Features {241+ b.Vertices += countVertices(feature.Coordinates)242+ }243+ }244+ for _, group := range detail.Groups {245+ b.Groups++246+ for _, category := range group.Categories {247+ b.Categories++248+ if category.DisplayType == "text" {249+ b.TextSets++250+ continue251+ }252+ b.IconsWanted++253+ if category.IconAsset != "" {254+ b.IconsCarried++255+ }256+ }257+ }258+ for _, merged := range detail.Merged {259+ b.Merges = append(b.Merges, mergeAccount{260+ Map: entry.Slug,261+ Source: merged.Source,262+ DonorPins: merged.DonorPins,263+ Matched: merged.Matched,264+ Added: merged.Added,265+ Adopted: merged.Adopted,266+ Held: merged.Held,267+ Rejected: merged.Rejected,268+ Alignment: merged.Alignment,269+ })270+ }271+ }272+ sort.Ints(lengths)273+ if len(lengths) > 0 {274+ b.MedianLength = lengths[len(lengths)/2]275+ }276+277+ // Cartography: the stored representation, de-duplicated -- a border of278+ // repeated filler tiles is one tile's worth of information however many279+ // times it appears.280+ seen := make(map[[2]uint64]bool)281+ for name, file := range entries {282+ if !strings.HasPrefix(name, "tiles/") {283+ continue284+ }285+ b.TileCount++286+ key := [2]uint64{uint64(file.CRC32), file.UncompressedSize64}287+ if !seen[key] {288+ seen[key] = true289+ b.RasterBytes += int64(file.UncompressedSize64)290+ }291+ if zoom := tileZoom(name); zoom == b.Depth {292+ b.DepthTiles++293+ }294+ }295+ return b, nil296+}297+298+// loadPins reads the packed locations of every map, keyed the way a diff299+// compares them: by map slug, then pin id.300+func loadPins(path string, mapSlugs []string) (map[string]map[int64]string, error) {301+ opened, err := bundle.Open(path)302+ if err != nil {303+ return nil, err304+ }305+ defer opened.Close()306+307+ pins := make(map[string]map[int64]string, len(mapSlugs))308+ for _, slug := range mapSlugs {309+ packed, err := opened.ReadEntry("maps/" + slug + ".bin")310+ if err != nil {311+ return nil, fmt.Errorf("map %s: %w", slug, err)312+ }313+ locations, err := bundle.UnpackLocations(packed)314+ if err != nil {315+ return nil, fmt.Errorf("map %s: %w", slug, err)316+ }317+ titles := make(map[int64]string, len(locations))318+ for _, location := range locations {319+ titles[location.ID] = location.Title320+ }321+ pins[slug] = titles322+ }323+ return pins, nil324+}325+326+func readEntry(entries map[string]*zip.File, name string, into any) error {327+ file, ok := entries[name]328+ if !ok {329+ return fmt.Errorf("%s is missing", name)330+ }331+ opened, err := file.Open()332+ if err != nil {333+ return err334+ }335+ defer opened.Close()336+ return json.NewDecoder(opened).Decode(into)337+}338+339+// tileZoom reads the level out of tiles/<pyramid>/<zoom>/<x>/<y>.<ext>.340+func tileZoom(name string) int {341+ parts := strings.Split(name, "/")342+ if len(parts) < 5 {343+ return -1344+ }345+ zoom, err := strconv.Atoi(parts[len(parts)-3])346+ if err != nil {347+ return -1348+ }349+ return zoom350+}351+352+// countVertices counts coordinate pairs through however many rings and353+// nestings a geometry holds.354+func countVertices(raw json.RawMessage) int {355+ var value any356+ if err := json.Unmarshal(raw, &value); err != nil {357+ return 0358+ }359+ numbers := 0360+ var walk func(any)361+ walk = func(node any) {362+ switch typed := node.(type) {363+ case []any:364+ for _, child := range typed {365+ walk(child)366+ }367+ case float64:368+ numbers++369+ }370+ }371+ walk(value)372+ return numbers / 2373+}@@ -0,0 +1,198 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+8+ "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"9+)10+11+// fixtureMerged is a merge ledger the shape tools/generate writes, handed to12+// bundletest verbatim so measurement reads it the way it reads a real build.13+func fixtureMerged() []map[string]any {14+ return []map[string]any{{15+ "source": "ign-wiki",16+ "donorPins": 5,17+ "matched": []map[string]any{18+ {"d": 11, "w": 1, "px": 12},19+ {"d": 12, "w": 2, "px": 40, "e": true},20+ },21+ "added": 1,22+ "adopted": []map[string]any{{"d": 13, "into": "markers"}},23+ "held": []map[string]any{{"d": 14, "t": "Old Well", "why": "name 200px away"}},24+ "rejected": []map[string]any{{"d": 15, "t": "Off World", "why": "outside the world"}},25+ "alignment": "affine over 9 anchors, median 26px",26+ }}27+}28+29+func TestMeasureBundle(t *testing.T) {30+ path := bundletest.Build(t, t.TempDir(), bundletest.Spec{31+ Slug: "hollowmere",32+ Title: "Hollowmere",33+ CreatedAt: "2026-03-01T00:00:00Z",34+ Maps: []bundletest.MapSpec{{35+ Slug: "overworld",36+ Pins: []bundletest.Pin{37+ {Title: "Gate", Description: "The way in."},38+ {Title: "Well", Silent: true},39+ {Title: "Shrine"},40+ },41+ Merged: fixtureMerged(),42+ }},43+ })44+45+ measured, err := measureBundle(path)46+ if err != nil {47+ t.Fatal(err)48+ }49+ if measured.GameSlug != "hollowmere" || measured.GameTitle != "Hollowmere" {50+ t.Fatalf("identity read as %s (%s)", measured.GameTitle, measured.GameSlug)51+ }52+53+ // Annotation: three pins, two with words. The medians sort "The way in."54+ // (11 chars) under "About Shrine" (12), so the upper middle is 12.55+ if measured.Pins != 3 || measured.Described != 2 {56+ t.Errorf("annotation: %d pins, %d described; want 3 and 2", measured.Pins, measured.Described)57+ }58+ if measured.MedianLength != 12 {59+ t.Errorf("median description length %d, want 12", measured.MedianLength)60+ }61+ if got := measured.DescribedPct(); got != "66%" {62+ t.Errorf("described share %s, want 66%%", got)63+ }64+65+ // Cartography: the fixture writes two distinct tiles, one at each level.66+ if measured.TileCount != 2 || measured.Depth != 1 || measured.DepthTiles != 1 {67+ t.Errorf("cartography: %d tiles, depth z%d holds %d; want 2, z1, 1",68+ measured.TileCount, measured.Depth, measured.DepthTiles)69+ }70+ if measured.RasterBytes == 0 {71+ t.Error("raster bytes read as zero")72+ }73+74+ // Structure and icons: one group of one iconed marker category, one layer.75+ if measured.Categories != 1 || measured.Groups != 1 || measured.Variants != 1 {76+ t.Errorf("structure: %d categories, %d groups, %d layers; want 1 each",77+ measured.Categories, measured.Groups, measured.Variants)78+ }79+ if measured.IconsCarried != 1 || measured.IconsWanted != 1 {80+ t.Errorf("icons: %d of %d; want 1 of 1", measured.IconsCarried, measured.IconsWanted)81+ }82+83+ // The merge account, kept whole rather than counted.84+ if len(measured.Merges) != 1 {85+ t.Fatalf("read %d merge accounts, want 1", len(measured.Merges))86+ }87+ account := measured.Merges[0]88+ if account.Map != "overworld" || account.Source != "ign-wiki" || account.DonorPins != 5 {89+ t.Errorf("account identity: map %s source %s donors %d", account.Map, account.Source, account.DonorPins)90+ }91+ if account.MatchedN() != 2 || account.MedianMatchPx() != 40 {92+ t.Errorf("matched %d at median %dpx, want 2 at 40px", account.MatchedN(), account.MedianMatchPx())93+ }94+ if account.Added != 1 || account.AdoptedN() != 1 || account.RejectedN() != 1 {95+ t.Errorf("added %d adopted %d rejected %d, want 1 each",96+ account.Added, account.AdoptedN(), account.RejectedN())97+ }98+ if account.HeldN() != 1 || account.Held[0].Title != "Old Well" || account.Held[0].Reason != "name 200px away" {99+ t.Errorf("held ledger read wrong: %+v", account.Held)100+ }101+ if got := measured.SourcesSeen(); len(got) != 1 || got[0] != "ign-wiki" {102+ t.Errorf("sources seen %v, want [ign-wiki]", got)103+ }104+}105+106+// install builds a fixture and moves it into the scanned directory under its107+// own name, the way versioned builds of one game sit side by side.108+func install(t *testing.T, dir string, name string, spec bundletest.Spec) string {109+ t.Helper()110+ built := bundletest.Build(t, t.TempDir(), spec)111+ target := filepath.Join(dir, name)112+ if err := os.Rename(built, target); err != nil {113+ t.Fatal(err)114+ }115+ return target116+}117+118+func TestLibraryOrdersBuilds(t *testing.T) {119+ dir := t.TempDir()120+ install(t, dir, "hollowmere-jan.atlas", bundletest.Spec{121+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-01-01T00:00:00Z",122+ })123+ install(t, dir, "hollowmere-feb.atlas", bundletest.Spec{124+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-02-01T00:00:00Z",125+ })126+ // Same capture as February, newer policy: the revision must win the top.127+ install(t, dir, "hollowmere-feb-r1.atlas", bundletest.Spec{128+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-02-01T00:00:00Z", Revision: 1,129+ })130+ install(t, dir, "briar.atlas", bundletest.Spec{131+ Slug: "briar", Title: "Briar", CreatedAt: "2026-01-15T00:00:00Z",132+ })133+134+ lib := &library{dir: dir}135+ games, skipped, err := lib.games()136+ if err != nil {137+ t.Fatal(err)138+ }139+ if len(skipped) != 0 {140+ t.Fatalf("skipped %v", skipped)141+ }142+ if len(games) != 2 {143+ t.Fatalf("read %d games, want 2", len(games))144+ }145+ // Sorted by title: Briar before Hollowmere.146+ if games[0].Slug != "briar" || games[1].Slug != "hollowmere" {147+ t.Fatalf("order %s, %s", games[0].Slug, games[1].Slug)148+ }149+ builds := games[1].Builds150+ if len(builds) != 3 {151+ t.Fatalf("hollowmere has %d builds, want 3", len(builds))152+ }153+ if builds[0].File != "hollowmere-feb-r1.atlas" {154+ t.Errorf("serving build is %s, want the revised February build", builds[0].File)155+ }156+ if builds[1].File != "hollowmere-feb.atlas" || builds[2].File != "hollowmere-jan.atlas" {157+ t.Errorf("order below serving: %s, %s", builds[1].File, builds[2].File)158+ }159+ if games[1].Serving() != builds[0] {160+ t.Error("Serving is not the first build")161+ }162+ if games[1].Build("hollowmere-jan.atlas") != builds[2] {163+ t.Error("Build by file name missed")164+ }165+}166+167+func TestLibrarySkipsBadBundles(t *testing.T) {168+ dir := t.TempDir()169+ install(t, dir, "briar.atlas", bundletest.Spec{Slug: "briar"})170+ if err := os.WriteFile(filepath.Join(dir, "broken.atlas"), []byte("not a zip"), 0o644); err != nil {171+ t.Fatal(err)172+ }173+ games, skipped, err := (&library{dir: dir}).games()174+ if err != nil {175+ t.Fatal(err)176+ }177+ if len(games) != 1 || len(skipped) != 1 {178+ t.Fatalf("read %d games and %d skipped, want 1 and 1", len(games), len(skipped))179+ }180+}181+182+func TestLoadPins(t *testing.T) {183+ path := bundletest.Build(t, t.TempDir(), bundletest.Spec{184+ Slug: "hollowmere",185+ Maps: []bundletest.MapSpec{{186+ Slug: "overworld",187+ Pins: []bundletest.Pin{{Title: "Gate"}, {Title: "Well"}},188+ }},189+ })190+ pins, err := loadPins(path, []string{"overworld"})191+ if err != nil {192+ t.Fatal(err)193+ }194+ titles := pins["overworld"]195+ if len(titles) != 2 || titles[1] != "Gate" || titles[2] != "Well" {196+ t.Fatalf("pins read as %v", titles)197+ }198+}@@ -0,0 +1,88 @@
1+package main2+3+import (4+ "bytes"5+ "embed"6+ "html/template"7+ "log/slog"8+ "net/http"9+)10+11+//go:embed ui12+var uiFS embed.FS13+14+// server is the dashboard: a handful of read-only views over the library.15+type server struct {16+ library *library17+ pages map[string]*template.Template18+}19+20+func newServer(lib *library) *server {21+ pages := make(map[string]*template.Template)22+ for _, name := range []string{"collection", "game"} {23+ pages[name] = template.Must(template.ParseFS(uiFS, "ui/layout.tmpl", "ui/"+name+".tmpl"))24+ }25+ return &server{library: lib, pages: pages}26+}27+28+func (s *server) routes() http.Handler {29+ mux := http.NewServeMux()30+ mux.HandleFunc("GET /{$}", s.handleCollection)31+ mux.HandleFunc("GET /game/{slug}", s.handleGame)32+ mux.HandleFunc("GET /static/style.css", staticFile("ui/style.css", "text/css; charset=utf-8"))33+ return mux34+}35+36+// render executes a page into a buffer first, so a template error becomes a37+// clean 500 rather than half a page. Every page carries a policy that keeps38+// it self-contained: nothing loads from anywhere but this server.39+func (s *server) render(w http.ResponseWriter, page string, data any) {40+ var body bytes.Buffer41+ if err := s.pages[page].ExecuteTemplate(&body, "layout", data); err != nil {42+ slog.Error("cartograph: render", "page", page, "error", err)43+ http.Error(w, "rendering failed: "+err.Error(), http.StatusInternalServerError)44+ return45+ }46+ w.Header().Set("Content-Type", "text/html; charset=utf-8")47+ w.Header().Set("Content-Security-Policy",48+ "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'")49+ w.Write(body.Bytes())50+}51+52+func (s *server) handleCollection(w http.ResponseWriter, r *http.Request) {53+ games, skipped, err := s.library.games()54+ if err != nil {55+ http.Error(w, err.Error(), http.StatusInternalServerError)56+ return57+ }58+ s.render(w, "collection", struct {59+ Dir string60+ Games []*game61+ Skipped []string62+ }{Dir: s.library.dir, Games: games, Skipped: skipped})63+}64+65+func (s *server) handleGame(w http.ResponseWriter, r *http.Request) {66+ held, err := s.library.gameBySlug(r.PathValue("slug"))67+ if err != nil {68+ http.Error(w, err.Error(), http.StatusInternalServerError)69+ return70+ }71+ if held == nil {72+ http.NotFound(w, r)73+ return74+ }75+ s.render(w, "game", struct{ Game *game }{Game: held})76+}77+78+func staticFile(name, contentType string) http.HandlerFunc {79+ return func(w http.ResponseWriter, r *http.Request) {80+ data, err := uiFS.ReadFile(name)81+ if err != nil {82+ http.NotFound(w, r)83+ return84+ }85+ w.Header().Set("Content-Type", contentType)86+ w.Write(data)87+ }88+}@@ -0,0 +1,82 @@
1+package main2+3+import (4+ "io"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"11+)12+13+func testServer(t *testing.T) (*server, string) {14+ t.Helper()15+ dir := t.TempDir()16+ install(t, dir, "hollowmere-jan.atlas", bundletest.Spec{17+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-01-01T00:00:00Z",18+ })19+ install(t, dir, "hollowmere-feb.atlas", bundletest.Spec{20+ Slug: "hollowmere", Title: "Hollowmere", CreatedAt: "2026-02-01T00:00:00Z",21+ Maps: []bundletest.MapSpec{{22+ Slug: "overworld",23+ Pins: []bundletest.Pin{{Title: "Gate"}, {Title: "Well"}},24+ Merged: fixtureMerged(),25+ }},26+ })27+ return newServer(&library{dir: dir}), dir28+}29+30+func get(t *testing.T, ts *httptest.Server, path string) (int, string) {31+ t.Helper()32+ response, err := ts.Client().Get(ts.URL + path)33+ if err != nil {34+ t.Fatal(err)35+ }36+ defer response.Body.Close()37+ body, err := io.ReadAll(response.Body)38+ if err != nil {39+ t.Fatal(err)40+ }41+ return response.StatusCode, string(body)42+}43+44+func TestPages(t *testing.T) {45+ server, _ := testServer(t)46+ ts := httptest.NewServer(server.routes())47+ defer ts.Close()48+49+ status, body := get(t, ts, "/")50+ if status != http.StatusOK {51+ t.Fatalf("collection answered %d", status)52+ }53+ for _, want := range []string{"Hollowmere", "hollowmere", "ign-wiki"} {54+ if !strings.Contains(body, want) {55+ t.Errorf("collection page misses %q", want)56+ }57+ }58+59+ status, body = get(t, ts, "/game/hollowmere")60+ if status != http.StatusOK {61+ t.Fatalf("game page answered %d", status)62+ }63+ for _, want := range []string{64+ "hollowmere-feb.atlas", "hollowmere-jan.atlas", "serving",65+ "annotation", "cartography", "structure", "Old Well", "name 200px away",66+ } {67+ if !strings.Contains(body, want) {68+ t.Errorf("game page misses %q", want)69+ }70+ }71+ // The serving mark belongs to the newer build alone.72+ if strings.Index(body, "hollowmere-feb.atlas") > strings.Index(body, "hollowmere-jan.atlas") {73+ t.Error("builds are not newest first")74+ }75+76+ if status, _ := get(t, ts, "/game/nowhere"); status != http.StatusNotFound {77+ t.Errorf("unknown game answered %d", status)78+ }79+ if status, _ := get(t, ts, "/static/style.css"); status != http.StatusOK {80+ t.Errorf("stylesheet answered %d", status)81+ }82+}@@ -0,0 +1,35 @@
1+{{define "title"}}Cartograph · collection{{end}}2+{{define "content"}}3+<section class="page-head">4+ <h1>Collection</h1>5+ <p class="dim">{{.Dir}}</p>6+</section>7+{{range .Skipped}}<p class="warn">{{.}}</p>{{end}}8+{{if .Games}}9+<p class="dim note">Figures are the serving build's — the one the reader picks.</p>10+<table>11+ <thead><tr>12+ <th>game</th><th class="num">builds</th><th class="num">pins</th>13+ <th class="num">described</th><th class="num">raster</th><th class="num">depth</th>14+ <th class="num">layers</th><th class="num">icons</th><th>merged sources</th>15+ </tr></thead>16+ <tbody>17+ {{range .Games}}{{$serving := .Serving}}18+ <tr>19+ <td><a href="/game/{{.Slug}}">{{.Title}}</a> <span class="dim">{{.Slug}}</span></td>20+ <td class="num">{{len .Builds}}</td>21+ <td class="num">{{$serving.Pins}}</td>22+ <td class="num">{{$serving.DescribedPct}}</td>23+ <td class="num">{{$serving.RasterMB}} MB</td>24+ <td class="num">z{{$serving.Depth}}</td>25+ <td class="num">{{$serving.Variants}}</td>26+ <td class="num">{{$serving.IconPct}}</td>27+ <td>{{range $serving.SourcesSeen}}<span class="badge">{{.}}</span>{{else}}<span class="dim">—</span>{{end}}</td>28+ </tr>29+ {{end}}30+ </tbody>31+</table>32+{{else}}33+<p class="empty">No bundles in this directory yet.</p>34+{{end}}35+{{end}}@@ -0,0 +1,50 @@
1+{{define "title"}}Cartograph · {{.Game.Title}}{{end}}2+{{define "content"}}3+<section class="page-head">4+ <h1>{{.Game.Title}} <span class="dim">{{.Game.Slug}}</span></h1>5+ <p class="dim">{{len .Game.Builds}} builds, newest first — the first is the one the reader serves.</p>6+</section>7+{{range $at, $b := .Game.Builds}}8+<section class="build{{if eq $at 0}} serving{{end}}">9+ <h2>{{$b.File}}{{if eq $at 0}} <span class="tag">serving</span>{{end}}</h2>10+ <p class="dim">captured {{$b.CreatedAt}} · revision {{$b.Revision}} · stamp {{$b.ShortStamp}}</p>11+ <dl class="axes">12+ <div><dt>annotation</dt><dd><b>{{$b.Pins}}</b> pins · {{$b.Described}} described ({{$b.DescribedPct}}) · median {{$b.MedianLength}} chars</dd></div>13+ <div><dt>cartography</dt><dd><b>{{$b.TileCount}}</b> tiles · {{$b.RasterMB}} MB unique raster · depth z{{$b.Depth}} holds {{$b.DepthTiles}} tiles</dd></div>14+ <div><dt>structure</dt><dd><b>{{$b.Categories}}</b> categories in {{$b.Groups}} groups · {{$b.TextSets}} text label sets · {{$b.Zones}} zones ({{$b.Vertices}} vertices) · {{$b.Variants}} layers</dd></div>15+ <div><dt>icons</dt><dd><b>{{$b.IconsCarried}}</b> of {{$b.IconsWanted}} marker categories carry one ({{$b.IconPct}})</dd></div>16+ </dl>17+ {{if $b.Merges}}18+ <table class="merges">19+ <thead><tr>20+ <th>map</th><th>source</th><th class="num">donor pins</th><th class="num">matched</th>21+ <th class="num">median px</th><th class="num">added</th><th class="num">adopted</th>22+ <th class="num">held</th><th class="num">rejected</th><th>alignment</th>23+ </tr></thead>24+ <tbody>25+ {{range $b.Merges}}26+ <tr>27+ <td>{{.Map}}</td><td>{{.Source}}</td>28+ <td class="num">{{.DonorPins}}</td><td class="num">{{.MatchedN}}</td>29+ <td class="num">{{.MedianMatchPx}}</td><td class="num">{{.Added}}</td>30+ <td class="num">{{.AdoptedN}}</td><td class="num">{{.HeldN}}</td>31+ <td class="num">{{.RejectedN}}</td><td class="dim">{{.Alignment}}</td>32+ </tr>33+ {{end}}34+ </tbody>35+ </table>36+ {{range $b.Merges}}{{if .Held}}37+ <details class="ledger">38+ <summary>{{.Source}} · {{.Map}} — {{.HeldN}} pins held back</summary>39+ <table>40+ <thead><tr><th class="num">donor id</th><th>title</th><th>reason</th></tr></thead>41+ <tbody>42+ {{range .Held}}<tr><td class="num">{{.Donor}}</td><td>{{.Title}}</td><td class="dim">{{.Reason}}</td></tr>{{end}}43+ </tbody>44+ </table>45+ </details>46+ {{end}}{{end}}47+ {{end}}48+</section>49+{{end}}50+{{end}}@@ -0,0 +1,21 @@
1+{{define "layout"}}<!doctype html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>{{block "title" .}}Cartograph{{end}}</title>7+<link rel="stylesheet" href="/static/style.css">8+</head>9+<body>10+<header class="topbar">11+ <a class="brand" href="/">Cartograph</a>12+ <nav>13+ <a href="/">Collection</a>14+ </nav>15+</header>16+<main>17+{{template "content" .}}18+</main>19+</body>20+</html>21+{{end}}@@ -0,0 +1,268 @@
1+/* Cartograph wears Atlas's chrome: a neutral near-black with a faint cool2+ cast, linen text, and one cerulean accent that marks everything3+ interactive and every data readout. No gradients, no noise. */4+:root {5+ color-scheme: dark;6+ --bg: #121417;7+ --panel: #17191d;8+ --panel-raised: #1e2126;9+ --line: rgba(236, 229, 216, 0.1);10+ --line-strong: rgba(236, 229, 216, 0.22);11+ --text: #ece5d8;12+ --text-strong: #f9ebe0;13+ --muted: #8e897c;14+ --accent: #3aa5c9;15+ --accent-wash: rgba(32, 138, 174, 0.13);16+ --accent-wash-strong: rgba(32, 138, 174, 0.24);17+ --data: #7fc5de;18+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;19+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;20+ font-synthesis: none;21+}22+23+* { box-sizing: border-box; }24+25+body {26+ margin: 0;27+ background: var(--bg);28+ color: var(--text);29+ font-size: 14px;30+ line-height: 1.5;31+}32+33+a { color: var(--accent); text-decoration: none; }34+a:hover { text-decoration: underline; }35+36+.topbar {37+ display: flex;38+ align-items: center;39+ gap: 24px;40+ padding: 0 20px;41+ height: 52px;42+ border-bottom: 1px solid var(--line);43+ background: var(--panel);44+}45+46+.brand {47+ color: var(--text-strong);48+ font-weight: 650;49+ letter-spacing: 0.04em;50+}51+.brand:hover { text-decoration: none; }52+53+.topbar nav { display: flex; gap: 16px; }54+.topbar nav a { color: var(--muted); }55+.topbar nav a:hover { color: var(--text); text-decoration: none; }56+57+main {58+ max-width: 1080px;59+ margin: 0 auto;60+ padding: 24px 20px 64px;61+}62+63+h1 {64+ font-size: 20px;65+ font-weight: 650;66+ color: var(--text-strong);67+ margin: 0 0 4px;68+}69+h2 {70+ font-size: 15px;71+ font-weight: 600;72+ color: var(--text-strong);73+ margin: 0 0 4px;74+ font-family: var(--mono);75+}76+.page-head { margin-bottom: 20px; }77+.page-head p { margin: 0; }78+79+.dim { color: var(--muted); font-weight: 400; }80+.note { margin: 0 0 8px; font-size: 13px; }81+.warn {82+ border: 1px solid var(--line-strong);83+ border-left: 3px solid var(--accent);84+ background: var(--panel);85+ padding: 8px 12px;86+ color: var(--muted);87+}88+.empty {89+ border: 1px dashed var(--line-strong);90+ padding: 24px;91+ text-align: center;92+ color: var(--muted);93+}94+95+table {96+ width: 100%;97+ border-collapse: collapse;98+ border: 1px solid var(--line);99+ background: var(--panel);100+}101+th, td {102+ text-align: left;103+ padding: 7px 10px;104+ border-bottom: 1px solid var(--line);105+}106+th {107+ color: var(--muted);108+ font-weight: 500;109+ font-size: 12px;110+ letter-spacing: 0.02em;111+ background: var(--panel-raised);112+}113+tbody tr:last-child td { border-bottom: none; }114+tbody tr:hover td { background: var(--accent-wash); }115+.num {116+ text-align: right;117+ font-family: var(--mono);118+ font-size: 13px;119+ color: var(--data);120+}121+th.num { color: var(--muted); font-family: inherit; }122+123+.badge {124+ display: inline-block;125+ padding: 1px 7px;126+ margin-right: 4px;127+ border: 1px solid var(--line-strong);128+ border-radius: 999px;129+ font-size: 12px;130+ color: var(--data);131+ background: var(--accent-wash);132+}133+134+.build {135+ border: 1px solid var(--line);136+ background: var(--panel);137+ padding: 14px 16px;138+ margin-bottom: 16px;139+}140+.build.serving { border-color: var(--accent); }141+.tag {142+ display: inline-block;143+ vertical-align: 2px;144+ margin-left: 6px;145+ padding: 1px 8px;146+ border-radius: 999px;147+ background: var(--accent-wash-strong);148+ color: var(--accent);149+ font-size: 11px;150+ font-weight: 600;151+ font-family: Inter, ui-sans-serif, sans-serif;152+ letter-spacing: 0.03em;153+}154+.build > p { margin: 0 0 10px; font-size: 13px; }155+156+.axes { margin: 0 0 12px; }157+.axes div {158+ display: flex;159+ gap: 12px;160+ padding: 3px 0;161+ border-bottom: 1px dotted var(--line);162+}163+.axes div:last-child { border-bottom: none; }164+.axes dt {165+ flex: 0 0 100px;166+ color: var(--muted);167+ font-size: 12px;168+ letter-spacing: 0.02em;169+ padding-top: 2px;170+}171+.axes dd { margin: 0; }172+.axes b { color: var(--data); font-family: var(--mono); font-weight: 600; }173+174+.merges { margin-bottom: 10px; }175+176+.ledger { margin: 8px 0; }177+.ledger summary {178+ cursor: pointer;179+ color: var(--muted);180+ font-size: 13px;181+ padding: 4px 0;182+}183+.ledger summary:hover { color: var(--text); }184+.ledger table { margin-top: 6px; }185+186+.compare {187+ display: flex;188+ align-items: center;189+ gap: 8px;190+ flex-wrap: wrap;191+ margin: 0 0 20px;192+ padding: 10px 12px;193+ border: 1px solid var(--line);194+ background: var(--panel);195+}196+.compare label { color: var(--muted); font-size: 13px; }197+198+select, input[type="text"], input:not([type]) {199+ background: var(--panel-raised);200+ color: var(--text);201+ border: 1px solid var(--line-strong);202+ border-radius: 4px;203+ padding: 5px 8px;204+ font: inherit;205+ font-size: 13px;206+}207+select:focus, input:focus { outline: 1px solid var(--accent); border-color: var(--accent); }208+input::placeholder { color: var(--muted); }209+210+button {211+ background: var(--accent-wash-strong);212+ color: var(--accent);213+ border: 1px solid var(--accent);214+ border-radius: 4px;215+ padding: 5px 14px;216+ font: inherit;217+ font-size: 13px;218+ font-weight: 600;219+ cursor: pointer;220+}221+button:hover { background: var(--accent); color: #0c1216; }222+button:disabled {223+ border-color: var(--line-strong);224+ background: var(--panel-raised);225+ color: var(--muted);226+ cursor: default;227+}228+229+.delta-up { color: var(--data); }230+.delta-down { color: var(--warm, #b08a5a); }231+.delta-flat { color: var(--muted); }232+233+.columns { display: flex; gap: 16px; flex-wrap: wrap; }234+.columns > section { flex: 1 1 320px; }235+236+.card {237+ border: 1px solid var(--line);238+ background: var(--panel);239+ padding: 14px 16px;240+ margin-bottom: 16px;241+}242+.card h2 { font-family: Inter, ui-sans-serif, sans-serif; }243+.card p { margin: 6px 0; }244+.card form {245+ display: flex;246+ gap: 8px;247+ align-items: center;248+ flex-wrap: wrap;249+ margin-top: 10px;250+}251+.card form input { flex: 1 1 240px; }252+253+.console {254+ margin-top: 16px;255+ min-height: 120px;256+ max-height: 420px;257+ overflow: auto;258+ padding: 12px 14px;259+ border: 1px solid var(--line-strong);260+ background: #0c0e10;261+ color: var(--text);262+ font-family: var(--mono);263+ font-size: 12px;264+ line-height: 1.45;265+ white-space: pre-wrap;266+ overflow-wrap: anywhere;267+}268+.console:empty::before { content: "operation output arrives here"; color: var(--muted); }@@ -27,18 +27,25 @@ type Spec struct {
2727 }2828 2929 // MapSpec describes one map of a fixture game: a single layer two zoom30-// levels deep, one category, and the given pins.30+// levels deep, one category, and the given pins. Merged, when set, rides31+// into the payload's merged block verbatim, so a test can hand a map the32+// provenance ledger a real composition would have written.3133 type MapSpec struct {32- Slug string33- Title string34- Pins []Pin34+ Slug string35+ Title string36+ Pins []Pin37+ Merged any3538 }3639 37-// Pin is one fixture location.40+// Pin is one fixture location. Description, when set, replaces the41+// placeholder note; Silent leaves the pin with no note at all, which is how42+// a test states that a pin is undescribed.3843 type Pin struct {39- Title string40- Lat float6441- Lng float6444+ Title string45+ Lat float6446+ Lng float6447+ Description string48+ Silent bool4249 }4350 4451 // Build writes <spec.Slug>.atlas under dir and returns its path.@@ -107,6 +114,9 @@ func Build(t testing.TB, dir string, spec Spec) string {
107114 }},108115 }},109116 }117+ if m.Merged != nil {118+ detail["merged"] = m.Merged119+ }110120 payload, err := json.Marshal(detail)111121 if err != nil {112122 t.Fatal(err)@@ -122,7 +132,14 @@ func Build(t testing.TB, dir string, spec Spec) string {
122132 Lat: pin.Lat,123133 Lng: pin.Lng,124134 }125- text[strconv.Itoa(index+1)] = map[string]string{"d": "About " + pin.Title}135+ if pin.Silent {136+ continue137+ }138+ description := pin.Description139+ if description == "" {140+ description = "About " + pin.Title141+ }142+ text[strconv.Itoa(index+1)] = map[string]string{"d": description}126143 }127144 packed := bundle.PackLocations(locations)128145 add(t, writer.AddStored("maps/"+m.Slug+".bin", bytes.NewReader(packed)))