A bundle's version was the clock at build time, so building the same archive on two machines produced two different versions, and the directory held exactly one file per game with anything else pruned - a mirror of current state, not a record of it. The version is now the capture itself: the newest snapshot capture time across the game's maps. Building the same archive anywhere, any time, yields the same version, the same stamp, and the same file name - <game>-<capture-day>-<stamp>.atlas - so a build already present is recognised by name and left untouched, and dist/bundles stands as a registry. Old versions are never pruned; the newest-wins fold at serving time decides what a reader sees, and index.json beside the bundles lists every game's builds newest first, derived from the files it sits with rather than asserted over them. The importer follows: a chosen file is installed under its own versioned name, so importing an old build can never overwrite a newer one - it arrives already shadowed, and downgrading is the explicit act of deleting the newer file.
- commit
- 8120d30badea2b8c77e645a4c6c05e88cae30cfb
- parent
- 61175aaec4b7c88dc92be5c3265c67a8a1440efd
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 14 | ++- |
| M | internal/ | 11 | ++ |
| M | internal/ | 18 | +++- |
| M | internal/ | 37 | +++++++- |
| M | internal/ | 22 | +++++ |
| M | tools/ | 134 | +++++++++++++++++++++++------- |
@@ -83,8 +83,12 @@ go generate .
8383 2. `tools/generate` writes one `.atlas` bundle per game into `dist/bundles`:8484 the game's manifest, one payload per map, its tile pyramids, and its8585 archive SVG icons, validated as each bundle is written. Maps with missing86- snapshots or incomplete configured layers are omitted. A game whose87- bundle stamp has not changed keeps its existing file untouched.86+ snapshots or incomplete configured layers are omitted. Bundles are named87+ `<game>-<capture-day>-<stamp>.atlas`, versioned by the newest snapshot88+ capture across the game's maps -- building the same archive anywhere89+ yields the same file -- and a build already present is left untouched.90+ Older versions are never pruned: the directory is a registry, and91+ `index.json` beside the bundles lists every game's builds, newest first.8892 3. `npm ci` and the local esbuild/OpenLayers installation produce the8993 self-contained `assets/app.js` and `assets/app.css` bundles.9094 @@ -99,8 +103,10 @@ payloads in three parts, `tiles/<pyramid>/z/x/y` rasters stored uncompressed
99103 for byte-range serving, and `icons/`. The format is Atlas's own and carries100104 nothing specific to any capture source; `internal/bundle` owns reading,101105 writing, and validation. Two bundles naming the same game slug are two102-versions of that game -- the newest by creation time wins, so updating a game103-is dropping in a newer file.106+versions of that game -- the newest by capture time wins, so updating a game107+is dropping in a newer file, and an older file dropped beside a newer one is108+simply shadowed. File names carry the version for people and for cheap109+existence checks; ordering always comes from the manifest inside.104110 105111 ## Renderer architecture106112 @@ -224,6 +224,17 @@ func TestValidateRefusesRuntimeURLsAndWrongCounts(t *testing.T) {
224224 }225225 }226226 227+// The file name is derived from nothing but the manifest, so the same build228+// carries the same name wherever and whenever it is written.229+func TestVersionedFileNameIsDeterministic(t *testing.T) {230+ manifest := validManifest()231+ manifest.Version.CreatedAt = "2026-08-01T09:30:00Z"232+ manifest.Version.Stamp = strings.Repeat("ab", 32)233+ if got, want := bundle.VersionedFileName(manifest), "fixture-20260801-abababababab.atlas"; got != want {234+ t.Errorf("name = %q, want %q", got, want)235+ }236+}237+227238 func TestMoreRecentIsTotalAndPrefersNewer(t *testing.T) {228239 older := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-01-01T00:00:00Z"}))229240 newer := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-06-01T00:00:00Z"}))@@ -237,11 +237,13 @@ func (r *Registry) Release(slug string) {
237237 }238238 }239239 240-// Install copies bundle files into the directory under their canonical241-// names -- <game-slug>.atlas -- validating each before it is let in, and242-// rescans once at the end. It reports the games installed and, separately,243-// what was wrong with anything refused: one bad file does not turn the rest244-// of a multi-selection away.240+// Install copies bundle files into the directory under their versioned241+// names -- <game>-<capture-day>-<stamp>.atlas -- validating each before it242+// is let in, and rescans once at the end. Versions sit side by side and the243+// newest-wins fold serves the right one, so importing an old file can never244+// overwrite a newer build; it just arrives already shadowed. It reports the245+// games installed and, separately, what was wrong with anything refused:246+// one bad file does not turn the rest of a multi-selection away.245247 func (r *Registry) Install(paths []string) (installed []string, refused []string) {246248 for _, path := range paths {247249 slug, err := r.installOne(path)@@ -270,12 +272,16 @@ func (r *Registry) installOne(path string) (string, error) {
270272 }271273 slug := opened.Manifest.Game.Slug272274 273- target := filepath.Join(r.dir, slug+".atlas")275+ target := filepath.Join(r.dir, VersionedFileName(opened.Manifest))274276 if same, err := filepath.Abs(path); err == nil {275277 if resolved, err := filepath.Abs(target); err == nil && same == resolved {276278 return slug, nil277279 }278280 }281+ // This exact build already installed under its own name: nothing to copy.282+ if _, err := os.Stat(target); err == nil {283+ return slug, nil284+ }279285 source, err := os.Open(path)280286 if err != nil {281287 return "", err@@ -242,12 +242,39 @@ func TestInstallCopiesTheSoundAndRefusesTheBroken(t *testing.T) {
242242 if len(refused) != 1 {243243 t.Fatalf("refused = %v, want the one broken file", refused)244244 }245- // The copy lands under the game's canonical name, whatever the source246- // file was called, and the rescan has already made it servable.247- if _, err := os.Stat(filepath.Join(registry.Dir(), "game.atlas")); err != nil {245+ // The copy lands under the build's own versioned name, whatever the246+ // source file was called, and the rescan has already made it servable.247+ held, ok := registry.Snapshot().Games["game"]248+ if !ok {249+ t.Fatal("the installed game is not being served")250+ }251+ want := filepath.Join(registry.Dir(), bundle.VersionedFileName(held.Manifest))252+ if held.Path != want {253+ t.Errorf("installed at %s, want %s", held.Path, want)254+ }255+ if _, err := os.Stat(want); err != nil {248256 t.Error(err)249257 }250- if _, ok := registry.Snapshot().Games["game"]; !ok {251- t.Error("the installed game is not being served")258+}259+260+// Importing a build older than the one installed must not send the library261+// backwards: the old file arrives under its own name and sits shadowed.262+func TestInstallingAnOlderBuildDoesNotDowngrade(t *testing.T) {263+ registry := bundle.NewRegistry(t.TempDir())264+ newer := bundletest.Build(t, t.TempDir(), bundletest.Spec{265+ Slug: "game", Title: "Newer", CreatedAt: "2026-06-01T00:00:00Z",266+ })267+ older := bundletest.Build(t, t.TempDir(), bundletest.Spec{268+ Slug: "game", Title: "Older", CreatedAt: "2026-01-01T00:00:00Z",269+ })270+271+ if installed, refused := registry.Install([]string{newer}); len(installed) != 1 || len(refused) != 0 {272+ t.Fatalf("installing the newer build: %v / %v", installed, refused)273+ }274+ if installed, refused := registry.Install([]string{older}); len(installed) != 1 || len(refused) != 0 {275+ t.Fatalf("installing the older build: %v / %v", installed, refused)276+ }277+ if won := registry.Snapshot().Games["game"].Manifest.Game.Title; won != "Newer" {278+ t.Errorf("the library serves %q after an old import, want the newer build", won)252279 }253280 }@@ -57,3 +57,25 @@ func ShortStamp(stamp string) string {
5757 }5858 return stamp[:12]5959 }60+61+// VersionedFileName is the name a bundle carries in a registry directory:62+// the game, the day its data was captured, and enough of the stamp to tell63+// two builds of the same capture apart. Versions of a game sit side by side64+// under these names, and the newest-wins fold is what serves the right one --65+// the name is for people and for cheap existence checks, never for ordering.66+func VersionedFileName(m Manifest) string {67+ var day strings.Builder68+ for _, r := range m.Version.CreatedAt {69+ if r >= '0' && r <= '9' {70+ day.WriteRune(r)71+ if day.Len() == 8 {72+ break73+ }74+ }75+ }76+ name := m.Game.Slug77+ if day.Len() > 0 {78+ name += "-" + day.String()79+ }80+ return name + "-" + ShortStamp(m.Version.Stamp) + ".atlas"81+}@@ -9,14 +9,15 @@ import (
99 "path/filepath"1010 "sort"1111 "strings"12- "time"1312 1413 "github.com/FelineStateMachine/atlas/internal/bundle"1514 )1615 17-// writeBundles packs each game into its own .atlas file. A bundle is the18-// game complete -- payloads, tiles, icons -- named by nothing but its own19-// slug, so the file can travel on its own and land in any Atlas.16+// writeBundles packs each game into its own .atlas file, named by game,17+// capture day, and stamp. The directory is a registry, not a mirror: a new18+// capture lands beside the builds before it rather than over them, nothing19+// is pruned, and the newest-wins fold at serving time is what decides which20+// version a reader sees.2021 func writeBundles(out catalog, tiles tileManifest, tilesDir, bundleDir string) error {2122 if err := os.MkdirAll(bundleDir, 0o755); err != nil {2223 return fmt.Errorf("create bundle directory: %w", err)@@ -26,31 +27,12 @@ func writeBundles(out catalog, tiles tileManifest, tilesDir, bundleDir string) e
2627 stampByAsset[variant.AssetPath] = variant.Stamp2728 }2829 29- kept := make(map[string]bool, len(out.Games))3030 for _, game := range out.Games {31- kept[game.Slug+".atlas"] = true3231 if err := writeGameBundle(game, out.TileGrid, stampByAsset, tilesDir, bundleDir); err != nil {3332 return fmt.Errorf("%s: %w", game.Title, err)3433 }3534 }36-37- // A game that has left the catalog leaves the directory too, the same way38- // tools/tiles prunes pyramids: what the source no longer names is not39- // kept around to shadow anything.40- entries, err := os.ReadDir(bundleDir)41- if err != nil {42- return err43- }44- for _, entry := range entries {45- name := entry.Name()46- if entry.IsDir() || kept[name] || !strings.HasSuffix(name, ".atlas") {47- continue48- }49- if err := os.Remove(filepath.Join(bundleDir, name)); err != nil {50- return fmt.Errorf("prune %s: %w", name, err)51- }52- }53- return nil35+ return writeRegistryIndex(bundleDir)5436 }5537 5638 // gamePayload is one map's three parts, built once and used for both the@@ -137,17 +119,24 @@ func writeGameBundle(
137119 stamp.Add(bundle.ManifestName, bundle.HashBytes(identity))138120 manifest.Version.Stamp = stamp.Sum()139121 140- // An unchanged game keeps its file, and with it its creation time, so a141- // rebuild of everything does not read as an update of everything.142- target := filepath.Join(bundleDir, game.Slug+".atlas")143- if existing, err := bundle.Open(target); err == nil {144- carried := existing.Manifest.Version.Stamp145- existing.Close()146- if carried == manifest.Version.Stamp {147- return nil122+ // The version is the capture, not the build: the newest capture time123+ // across the game's maps. Building the same archive anywhere, any time,124+ // yields the same version and the same file name, which is what lets a125+ // directory of these files stand as a registry.126+ for _, m := range game.Maps {127+ if m.UpdatedAt > manifest.Version.CreatedAt {128+ manifest.Version.CreatedAt = m.UpdatedAt148129 }149130 }150- manifest.Version.CreatedAt = time.Now().UTC().Format(time.RFC3339)131+ if manifest.Version.CreatedAt == "" {132+ return fmt.Errorf("no map carries a capture time to version the bundle by")133+ }134+135+ // This exact build already in the registry keeps its file untouched.136+ target := filepath.Join(bundleDir, bundle.VersionedFileName(manifest))137+ if _, err := os.Stat(target); err == nil {138+ return nil139+ }151140 152141 file, err := os.CreateTemp(bundleDir, game.Slug+".atlas.tmp-*")153142 if err != nil {@@ -214,6 +203,85 @@ func writeGameBundle(
214203 return nil215204 }216205 206+// registryVersion is one build of one game as the registry index lists it.207+type registryVersion struct {208+ File string `json:"file"`209+ Stamp string `json:"stamp"`210+ CreatedAt string `json:"createdAt"`211+ Size int64 `json:"size"`212+ Maps int `json:"maps"`213+}214+215+type registryEntry struct {216+ Slug string `json:"slug"`217+ Title string `json:"title"`218+ Versions []registryVersion `json:"versions"`219+}220+221+// writeRegistryIndex reads every bundle in the directory back and writes222+// index.json beside them: each game, each of its builds, newest first. The223+// index is derived from the files it sits with, never the other way around,224+// so a hand-copied or hand-deleted bundle is reflected by the next run225+// rather than contradicted by a stale listing.226+func writeRegistryIndex(bundleDir string) error {227+ paths, err := filepath.Glob(filepath.Join(bundleDir, "*.atlas"))228+ if err != nil {229+ return err230+ }231+ entries := make(map[string]*registryEntry)232+ newest := make(map[string]string)233+ for _, path := range paths {234+ opened, err := bundle.Open(path)235+ if err != nil {236+ fmt.Fprintf(os.Stderr, "generate: index: skipping %s: %v\n", filepath.Base(path), err)237+ continue238+ }239+ manifest := opened.Manifest240+ opened.Close()241+ var size int64242+ if info, err := os.Stat(path); err == nil {243+ size = info.Size()244+ }245+ entry := entries[manifest.Game.Slug]246+ if entry == nil {247+ entry = ®istryEntry{Slug: manifest.Game.Slug}248+ entries[manifest.Game.Slug] = entry249+ }250+ // The newest version's title speaks for the game.251+ if manifest.Version.CreatedAt >= newest[manifest.Game.Slug] {252+ newest[manifest.Game.Slug] = manifest.Version.CreatedAt253+ entry.Title = manifest.Game.Title254+ }255+ entry.Versions = append(entry.Versions, registryVersion{256+ File: filepath.Base(path),257+ Stamp: manifest.Version.Stamp,258+ CreatedAt: manifest.Version.CreatedAt,259+ Size: size,260+ Maps: len(manifest.Maps),261+ })262+ }263+264+ listed := make([]registryEntry, 0, len(entries))265+ for _, entry := range entries {266+ sort.Slice(entry.Versions, func(i, j int) bool {267+ if entry.Versions[i].CreatedAt != entry.Versions[j].CreatedAt {268+ return entry.Versions[i].CreatedAt > entry.Versions[j].CreatedAt269+ }270+ return entry.Versions[i].Stamp > entry.Versions[j].Stamp271+ })272+ listed = append(listed, *entry)273+ }274+ sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })275+276+ data, err := json.Marshal(struct {277+ Games []registryEntry `json:"games"`278+ }{Games: listed})279+ if err != nil {280+ return err281+ }282+ return os.WriteFile(filepath.Join(bundleDir, "index.json"), append(data, '\n'), 0o644)283+}284+217285 // localPyramidName strips the game the pyramid belonged to in the shared tile218286 // tree. Inside a bundle the game is the file itself, so its pyramids are219287 // named by map and layer alone.