The serving capture's bundle now absorbs the other sources' pins instead of shadowing them. Each donor map aligns into the serving map's world by the same shared-name affine the raster variants stand on, and every donor pin is resolved against what the map already holds: the same name near where the alignment predicts is the same place, recorded as a matched pair; a pin only the donor knows arrives under its own source-named group with its own icons, carried under prefixed names so they displace nothing; and a pin the resolution cannot be sure about -- the same name suspiciously far, a nameless neighbour in the same category -- is held back with its reason written down, because proximity alone never merges. A matched serving pin with nothing to say takes the donor's description. The payload carries the whole account: alignment quality, matched pairs, additions, and the held ledger, so the merge is a documented derivation over immutable captures. A gate audits every donor pin as accounted for and every identifier as unique before the bundle writes, and the merged version is as fresh as its freshest ingredient, so a newer donor capture re-versions the merged bundle on its own. Night City: 368 wiki pins resolve to 231 matched, 62 added, 75 held -- the serving map now draws 885 places under 47 dressed categories.
- commit
- fec215ae1b69d11e07611189e52b3f77794ca12e
- parent
- 58fe99834d99b705e93c911f4064477aab1250d5
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 10 | +- |
| M | internal/ | 4 | +- |
| M | tools/ | 10 | + |
| A | tools/ | 504 | ++++++++++++++++++++++++++++++ |
| M | tools/ | 6 | +- |
| M | tools/ | 10 | +- |
@@ -171,7 +171,7 @@ func uniqueByName(anchors []Anchor) map[string]Anchor {
171171 counts := make(map[string]int, len(anchors))172172 held := make(map[string]Anchor, len(anchors))173173 for _, anchor := range anchors {174- name := normalizeTitle(anchor.Title)174+ name := NormalizeTitle(anchor.Title)175175 if name == "" {176176 continue177177 }@@ -191,10 +191,12 @@ var (
191191 nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)192192 )193193 194-// normalizeTitle folds the spellings apart sources give one place. The fast194+// NormalizeTitle folds the spellings apart sources give one place. The fast195195 // travel suffix goes because one source titles the place and another titles196-// the service at it, and they are the same anchor.197-func normalizeTitle(title string) string {196+// the service at it, and they are the same anchor. Anything comparing pins197+// across sources should compare through this, so a name means the same thing198+// to the fit and to whoever consumes it.199+func NormalizeTitle(title string) string {198200 title = strings.ToLower(title)199201 title = fastTravelSuffix.ReplaceAllString(title, "")200202 return strings.TrimSpace(nonAlphanumeric.ReplaceAllString(title, " "))@@ -115,8 +115,8 @@ func TestNormalizeTitleFoldsFastTravelSpellings(t *testing.T) {
115115 "Skippy, The Talking Gun ": "skippy the talking gun",116116 }117117 for given, want := range cases {118- if got := normalizeTitle(given); got != want {119- t.Errorf("normalizeTitle(%q) = %q, want %q", given, got, want)118+ if got := NormalizeTitle(given); got != want {119+ t.Errorf("NormalizeTitle(%q) = %q, want %q", given, got, want)120120 }121121 }122122 }@@ -162,6 +162,10 @@ type catalogMap struct {
162162 Parent string `json:"parent,omitempty"`163163 PinCount int `json:"pinCount"`164164 UpdatedAt string `json:"updatedAt"`165+ // Merged is the account of every other source folded into this map:166+ // what matched, what was added, what was held back and why. It rides in167+ // the payload as provenance.168+ Merged []mergedSource `json:"merged,omitempty"`165169 }166170 167171 type coordinate struct {@@ -383,6 +387,12 @@ func run(source, tileManifestPath, bundleDir string) error {
383387 }384388 }385389 390+ merged, err := mergeAcrossSources(out.Games, out.TileGrid)391+ if err != nil {392+ return err393+ }394+ out.Games = merged395+386396 sort.Slice(out.Games, func(i, j int) bool { return out.Games[i].Title < out.Games[j].Title })387397 return writeBundles(out, tiles, filepath.Dir(tileManifestPath), bundleDir)388398 }@@ -0,0 +1,504 @@
1+package main2+3+// Merging keeps every source's pins. When two sources capture one game, the4+// newest capture's bundle is the one the registry serves, and until now the5+// other source's pins simply rode along unseen in a shadowed bundle. Now the6+// serving game absorbs them: each other source's map is aligned into the7+// serving map's world by the transformation their shared named places8+// determine -- the same alignment the raster variants stand on -- and every9+// pin is resolved against what the serving map already holds. A pin both10+// sources place is recorded as the same place, never drawn twice; a pin only11+// the other source knows is added under its own source-named group, icons12+// and all; a pin the resolution cannot be sure about is held back and said13+// so. The payload carries the whole account -- what matched, what was added,14+// what was held -- so the merge is a documented derivation over immutable15+// captures, not an edit.16+17+import (18+ "fmt"19+ "math"20+ "sort"21+22+ "github.com/FelineStateMachine/atlas/internal/blend"23+)24+25+// Resolution distances, in world pixels of an 8192-pixel square. A confirmed26+// match needs the same name near where the alignment predicts; farther than27+// twice the radius the same name is treated as a different place bearing it.28+// Between the two, and for nameless proximity inside a shared category, the29+// resolution declines to decide: a pin held back with its reason recorded30+// costs a curiosity, where a wrong merge costs a place.31+const (32+ matchRadiusPx = 16033+ separateRadiusPx = 32034+ nearbyRadiusPx = 4835+)36+37+// categoryEquivalents maps one source's category slugs onto another's where38+// the concepts are known to be the same thing. Slugs equal after39+// normalization pair automatically; everything else stays source-specific40+// rather than being guessed together.41+var categoryEquivalents = map[string]map[string]string{42+ "cyberpunk-2077": {43+ "ripper-doc": "ripperdoc",44+ "tarot-card": "tarot-graffiti",45+ "gun-shop": "weapon-shop",46+ "clothes-shop": "clothing-vendor",47+ "medicine-shop": "medpoint",48+ "melee-shop": "melee-vendor",49+ "netrunner-shop": "netrunner",50+ },51+}52+53+// mergedSource is one source's account of its merge, carried in the map's54+// payload: the alignment it stood on, what became of every donor pin, and55+// the ledger a later pass -- or a curious reader -- can audit the decisions56+// by.57+type mergedSource struct {58+ Source string `json:"source"`59+ DonorPins int `json:"donorPins"`60+ Matched []mergedPair `json:"matched,omitempty"`61+ Added int `json:"added"`62+ Held []heldPin `json:"held,omitempty"`63+ Rejected []heldPin `json:"rejected,omitempty"`64+ Alignment string `json:"alignment"`65+}66+67+// mergedPair records one place both sources pin: the donor pin, the serving68+// pin it resolved to, and how far apart the alignment put them.69+type mergedPair struct {70+ Donor int64 `json:"d"`71+ Winner int64 `json:"w"`72+ DistancePx int `json:"px"`73+ // Enriched marks a pair whose serving pin had nothing to say and took74+ // the donor's description.75+ Enriched bool `json:"e,omitempty"`76+}77+78+// heldPin is a donor pin the merge did not carry, with the reason.79+type heldPin struct {80+ Donor int64 `json:"d"`81+ Title string `json:"t"`82+ Reason string `json:"why"`83+}84+85+// mergeAcrossSources folds every game slug's catalog entries into one served86+// account: the newest capture absorbs the others' pins, and every source87+// still emits its own bundle untouched, so nothing is lost however the merge88+// policy evolves.89+func mergeAcrossSources(games []catalogGame, shared tileGrid) ([]catalogGame, error) {90+ bySlug := make(map[string][]int)91+ var order []string92+ for index, game := range games {93+ if _, seen := bySlug[game.Slug]; !seen {94+ order = append(order, game.Slug)95+ }96+ bySlug[game.Slug] = append(bySlug[game.Slug], index)97+ }98+ sort.Strings(order)99+100+ for _, slug := range order {101+ group := bySlug[slug]102+ if len(group) < 2 {103+ continue104+ }105+ winner := group[0]106+ for _, candidate := range group[1:] {107+ if newestCapture(games[candidate]) > newestCapture(games[winner]) {108+ winner = candidate109+ }110+ }111+ for _, donor := range group {112+ if donor == winner {113+ continue114+ }115+ if err := mergeGame(&games[winner], &games[donor], shared); err != nil {116+ return nil, fmt.Errorf("merge %s: %w", slug, err)117+ }118+ }119+ }120+ return games, nil121+}122+123+func newestCapture(game catalogGame) string {124+ newest := ""125+ for _, m := range game.Maps {126+ if m.UpdatedAt > newest {127+ newest = m.UpdatedAt128+ }129+ }130+ return newest131+}132+133+func mergeGame(winner, donor *catalogGame, shared tileGrid) error {134+ winnerMaps := make(map[string]*catalogMap, len(winner.Maps))135+ for index := range winner.Maps {136+ winnerMaps[winner.Maps[index].Slug] = &winner.Maps[index]137+ }138+ for index := range donor.Maps {139+ donorMap := &donor.Maps[index]140+ target := winnerMaps[donorMap.Slug]141+ if target == nil {142+ fmt.Printf("merge %s: %s has no counterpart in the serving capture; its pins stay in their own bundle\n",143+ winner.Slug, donorMap.Slug)144+ continue145+ }146+ if target.Parent != "" || donorMap.Parent != "" {147+ fmt.Printf("merge %s: %s is a split sheet; merging across shards is not attempted\n",148+ winner.Slug, donorMap.Slug)149+ continue150+ }151+ if err := mergeMap(winner, target, donor, donorMap, shared); err != nil {152+ return err153+ }154+ }155+ return nil156+}157+158+func mergeMap(159+ winnerGame *catalogGame,160+ winner *catalogMap,161+ donorGame *catalogGame,162+ donor *catalogMap,163+ shared tileGrid,164+) error {165+ winnerGrid := gridOf(winner, shared)166+ donorGrid := gridOf(donor, shared)167+168+ affine, report, err := blend.Fit(anchorsOfMap(donor, donorGrid), anchorsOfMap(winner, winnerGrid))169+ if err != nil {170+ fmt.Printf("merge %s/%s: pins stay apart: %v\n", winnerGame.Slug, winner.Slug, err)171+ return nil172+ }173+174+ sourceLabel := sourceLabelOf(donor, donorGame)175+ merge := &mergedSource{Source: sourceLabel, Alignment: report.String()}176+177+ // What the serving map already holds, by name and by place.178+ winnerByName := make(map[string][]*catalogLocation)179+ var winnerPlaced []placedPin180+ winnerIDs := make(map[int64]string)181+ winnerCategoryByKey := make(map[string]bool)182+ for groupIndex := range winner.Groups {183+ for categoryIndex := range winner.Groups[groupIndex].Categories {184+ category := &winner.Groups[groupIndex].Categories[categoryIndex]185+ winnerCategoryByKey[category.Icon] = true186+ for locationIndex := range category.Locations {187+ location := &category.Locations[locationIndex]188+ name := blend.NormalizeTitle(location.Title)189+ winnerByName[name] = append(winnerByName[name], location)190+ winnerPlaced = append(winnerPlaced, placedPin{191+ location: location,192+ category: category.Icon,193+ x: projectX(location.Longitude, winnerGrid),194+ y: projectY(location.Latitude, winnerGrid),195+ })196+ winnerIDs[location.ID] = location.Title197+ }198+ }199+ }200+201+ equivalents := categoryEquivalents[winnerGame.Slug]202+203+ // Donor categories survive with the pins that stay distinct; everything204+ // else lands in the ledger.205+ var keptCategories []catalogCategory206+ for _, group := range donor.Groups {207+ for _, category := range group.Categories {208+ kept := category209+ kept.Locations = nil210+ donorKey := category.Icon211+ mappedKey := donorKey212+ if equivalent, ok := equivalents[donorKey]; ok {213+ mappedKey = equivalent214+ }215+ for _, location := range category.Locations {216+ merge.DonorPins++217+ x, y := affine.Apply(218+ projectX(location.Longitude, donorGrid),219+ projectY(location.Latitude, donorGrid),220+ )221+ outcome := resolvePin(location, x, y, winnerByName, winnerPlaced, mappedKey, winnerCategoryByKey[mappedKey])222+ switch outcome.kind {223+ case pinMatched:224+ pair := mergedPair{225+ Donor: location.ID,226+ Winner: outcome.match.ID,227+ DistancePx: int(outcome.distance + 0.5),228+ }229+ // Field-level: a serving pin with nothing to say takes230+ // the donor's words rather than silencing them.231+ if outcome.match.Description == "" && location.Description != "" {232+ outcome.match.Description = location.Description233+ pair.Enriched = true234+ }235+ merge.Matched = append(merge.Matched, pair)236+ case pinHeld:237+ merge.Held = append(merge.Held, heldPin{238+ Donor: location.ID, Title: location.Title, Reason: outcome.reason,239+ })240+ case pinDistinct:241+ if x < 0 || y < 0 || x > float64(shared.Size) || y > float64(shared.Size) {242+ merge.Rejected = append(merge.Rejected, heldPin{243+ Donor: location.ID, Title: location.Title,244+ Reason: "outside the shared world",245+ })246+ continue247+ }248+ if holder, taken := winnerIDs[location.ID]; taken {249+ return fmt.Errorf("pin id %d (%s) collides with serving pin %q",250+ location.ID, location.Title, holder)251+ }252+ winnerIDs[location.ID] = location.Title253+ moved := location254+ moved.Longitude = unprojectLongitude(x, winnerGrid)255+ moved.Latitude = unprojectLatitude(y, winnerGrid)256+ moved.RegionID = nil257+ moved.Shard = 0258+ kept.Locations = append(kept.Locations, moved)259+ merge.Added++260+ }261+ }262+ if len(kept.Locations) == 0 {263+ continue264+ }265+ if err := carryIcon(winnerGame, donorGame, &kept, sourceLabel); err != nil {266+ return err267+ }268+ keptCategories = append(keptCategories, kept)269+ }270+ }271+272+ if len(keptCategories) > 0 {273+ winner.Groups = append(winner.Groups, catalogGroup{274+ ID: mergedGroupID(winner),275+ Title: sourceLabel,276+ Categories: keptCategories,277+ })278+ }279+ winner.Merged = append(winner.Merged, *merge)280+ winner.PinCount += merge.Added281+ // The merged account is as fresh as its freshest ingredient, so a newer282+ // donor capture re-versions the merged bundle even when the serving283+ // capture stood still.284+ if donor.UpdatedAt > winner.UpdatedAt {285+ winner.UpdatedAt = donor.UpdatedAt286+ }287+288+ if err := mergeGate(merge, winner); err != nil {289+ return err290+ }291+ fmt.Printf("merge %s/%s: %s: %d donor pins → %d matched (%d enriched) · %d added · %d held · %d outside\n",292+ winnerGame.Slug, winner.Slug, sourceLabel, merge.DonorPins, len(merge.Matched),293+ enrichedCount(merge), merge.Added, len(merge.Held), len(merge.Rejected))294+ return nil295+}296+297+type placedPin struct {298+ location *catalogLocation299+ category string300+ x, y float64301+}302+303+type pinOutcome struct {304+ kind int305+ match *catalogLocation306+ distance float64307+ reason string308+}309+310+const (311+ pinDistinct = iota312+ pinMatched313+ pinHeld314+)315+316+// resolvePin decides what one donor pin is, against everything the serving317+// map holds. The same name near where the alignment predicts is the same318+// place; the same name far beyond it is a different place bearing it; the319+// stretch between is left undecided. A nameless neighbour inside the same320+// mapped category is likewise held rather than guessed -- proximity alone321+// never merges -- and only a pin resembling nothing at all is added.322+func resolvePin(323+ donor catalogLocation,324+ x, y float64,325+ byName map[string][]*catalogLocation,326+ placed []placedPin,327+ mappedKey string,328+ categoryShared bool,329+) pinOutcome {330+ name := blend.NormalizeTitle(donor.Title)331+ if name != "" {332+ var nearest *catalogLocation333+ nearestDistance := 0.0334+ for _, candidate := range byName[name] {335+ distance := pinDistance(x, y, candidate, placed)336+ if nearest == nil || distance < nearestDistance {337+ nearest, nearestDistance = candidate, distance338+ }339+ }340+ if nearest != nil {341+ switch {342+ case nearestDistance <= matchRadiusPx:343+ return pinOutcome{kind: pinMatched, match: nearest, distance: nearestDistance}344+ case nearestDistance <= separateRadiusPx:345+ return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(346+ "same name %.0fpx away; too far to merge, too near to double", nearestDistance)}347+ }348+ }349+ }350+ if categoryShared {351+ for _, candidate := range placed {352+ if candidate.category != mappedKey {353+ continue354+ }355+ dx, dy := candidate.x-x, candidate.y-y356+ if dx*dx+dy*dy <= nearbyRadiusPx*nearbyRadiusPx {357+ return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(358+ "beside %q in the same category; names disagree", candidate.location.Title)}359+ }360+ }361+ }362+ return pinOutcome{kind: pinDistinct}363+}364+365+// pinDistance finds how far a point sits from a serving pin. The placed list366+// is small enough to scan; identity is by pointer.367+func pinDistance(x, y float64, location *catalogLocation, placed []placedPin) float64 {368+ for _, candidate := range placed {369+ if candidate.location == location {370+ return math.Hypot(candidate.x-x, candidate.y-y)371+ }372+ }373+ return math.Inf(1)374+}375+376+// carryIcon brings a merged category's icon across from the donor's archive377+// under a source-prefixed name, so it cannot displace a serving icon that378+// shares its key.379+func carryIcon(winnerGame, donorGame *catalogGame, category *catalogCategory, sourceLabel string) error {380+ if category.IconAsset == "" {381+ return nil382+ }383+ data, held := donorGame.Icons[category.IconAsset]384+ if !held {385+ category.IconAsset = ""386+ category.IconPicture = false387+ return nil388+ }389+ carried := slugifyLabel(sourceLabel) + "--" + category.IconAsset390+ if winnerGame.Icons == nil {391+ winnerGame.Icons = make(map[string][]byte)392+ }393+ winnerGame.Icons[carried] = data394+ category.IconAsset = carried395+ return nil396+}397+398+// mergedGroupID numbers the source's group away from every id the map399+// already uses.400+func mergedGroupID(winner *catalogMap) int64 {401+ used := make(map[int64]bool)402+ for _, group := range winner.Groups {403+ used[group.ID] = true404+ }405+ id := int64(1)406+ for used[id] {407+ id++408+ }409+ return id410+}411+412+func sourceLabelOf(donor *catalogMap, donorGame *catalogGame) string {413+ for _, v := range donor.Variants {414+ if v.Name != "" && v.Name != "Default" {415+ return v.Name416+ }417+ }418+ return donorGame.Title419+}420+421+func slugifyLabel(label string) string {422+ out := make([]rune, 0, len(label))423+ for _, r := range label {424+ switch {425+ case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':426+ out = append(out, r)427+ case r >= 'A' && r <= 'Z':428+ out = append(out, r+'a'-'A')429+ default:430+ if len(out) > 0 && out[len(out)-1] != '-' {431+ out = append(out, '-')432+ }433+ }434+ }435+ for len(out) > 0 && out[len(out)-1] == '-' {436+ out = out[:len(out)-1]437+ }438+ return string(out)439+}440+441+func gridOf(m *catalogMap, shared tileGrid) tileGrid {442+ if m.Grid == nil {443+ return shared444+ }445+ return tileGrid{446+ SourceZoom: m.Grid.SourceZoom,447+ FirstTile: m.Grid.FirstTile,448+ TileSize: shared.TileSize,449+ Size: shared.Size,450+ }451+}452+453+func anchorsOfMap(m *catalogMap, grid tileGrid) []blend.Anchor {454+ var anchors []blend.Anchor455+ for _, group := range m.Groups {456+ for _, category := range group.Categories {457+ for _, location := range category.Locations {458+ anchors = append(anchors, blend.Anchor{459+ Title: location.Title,460+ X: projectX(location.Longitude, grid),461+ Y: projectY(location.Latitude, grid),462+ })463+ }464+ }465+ }466+ return anchors467+}468+469+func enrichedCount(merge *mergedSource) int {470+ count := 0471+ for _, pair := range merge.Matched {472+ if pair.Enriched {473+ count++474+ }475+ }476+ return count477+}478+479+// mergeGate is the merge's own audit: every donor pin accounted for, no480+// identifier doubled, nothing the serving map held made worse. It fails the481+// build rather than writing a bundle that quietly lost something.482+func mergeGate(merge *mergedSource, winner *catalogMap) error {483+ accounted := len(merge.Matched) + merge.Added + len(merge.Held) + len(merge.Rejected)484+ if accounted != merge.DonorPins {485+ return fmt.Errorf("merge accounts for %d of %d donor pins", accounted, merge.DonorPins)486+ }487+ seen := make(map[int64]string)488+ counted := 0489+ for _, group := range winner.Groups {490+ for _, category := range group.Categories {491+ for _, location := range category.Locations {492+ if holder, taken := seen[location.ID]; taken {493+ return fmt.Errorf("pin id %d held by both %q and %q", location.ID, holder, location.Title)494+ }495+ seen[location.ID] = location.Title496+ counted++497+ }498+ }499+ }500+ if counted != winner.PinCount {501+ return fmt.Errorf("map holds %d pins but claims %d", counted, winner.PinCount)502+ }503+ return nil504+}@@ -31,6 +31,10 @@ type mapDetail struct {
3131 Variants []variant `json:"variants"`3232 Groups []catalogGroup `json:"groups"`3333 Zones []zone `json:"zones,omitempty"`34+ // Merged is the provenance of any other sources folded into this map.35+ // The viewer ignores it; it exists so the merged payload carries its own36+ // account.37+ Merged []mergedSource `json:"merged,omitempty"`3438 }3539 3640 // locationText holds what only a selected pin needs.@@ -45,7 +49,7 @@ type locationText struct {
4549 func buildPayload(m catalogMap) (mapDetail, []byte, map[string]locationText) {4650 // Categories keep their identity; their locations travel packed, each4751 // carrying the position of its category in this same flattened order.48- detail := mapDetail{Grid: m.Grid, Variants: m.Variants, Zones: m.Zones}52+ detail := mapDetail{Grid: m.Grid, Variants: m.Variants, Zones: m.Zones, Merged: m.Merged}4953 var locations []bundle.Location5054 text := make(map[string]locationText)5155 var ordinal uint16@@ -53,6 +53,7 @@ type measure struct {
5353 File string5454 Stamp string5555 CreatedAt string56+ Revision int5657 5758 Pins int5859 Described int@@ -116,12 +117,16 @@ func run(dir string) error {
116117 sort.Strings(slugs)117118 for _, slug := range slugs {118119 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.120+ // The registry's own order: newest capture first, then the newer121+ // policy revision, then stamp, so the first line of every game is122+ // the build the reader sees.121123 sort.Slice(builds, func(a, b int) bool {122124 if builds[a].CreatedAt != builds[b].CreatedAt {123125 return builds[a].CreatedAt > builds[b].CreatedAt124126 }127+ if builds[a].Revision != builds[b].Revision {128+ return builds[a].Revision > builds[b].Revision129+ }125130 return builds[a].Stamp > builds[b].Stamp126131 })127132 fmt.Printf("%s (%s)\n", titles[slug], slug)@@ -171,6 +176,7 @@ func measureBundle(path string) (string, string, measure, error) {
171176 File: filepath.Base(path),172177 Stamp: manifest.Version.Stamp,173178 CreatedAt: manifest.Version.CreatedAt,179+ Revision: manifest.Version.Revision,174180 }175181 176182 // Annotation: how thoroughly the locations are explained in writing.