clone

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

feat: read IGN wikimap captures in the pipeline's own tongue

Dami committed 50a20a7
IGN's wikimaps are a second source of pins, and the pipeline should not
have to learn a second schema to carry them. The crawler will store what
IGN publishes as one canonical document, and internal/ignmap translates
that capture into the MapGenie-shaped JSON tools/tiles and tools/generate
already read -- at read time, so every decision about what a bundle keeps
or leaves can change later and re-apply to captures already archived.

Markers arrive with linear coordinates on the map image; they leave as
synthetic latitudes and longitudes that the viewer's own projection lands
back on the same pixel, inside the map's own 32-tile window. Identifiers
are hashed from IGN's stable names, so an unchanged capture always
translates to byte-identical payloads and the bundle stamp stands still.
commit
50a20a7dcb51941af0f783d9bd674f75761679e8
parent
e351fbd9685b90c82002c4f2061eccced358ad12
author
Dami <dami.etoile@gmail.com>
2 files changed, +678 -0
Ainternal/ignmap/ignmap.go420++++++++++++++++++++++++++++++
Ainternal/ignmap/ignmap_test.go258++++++++++++++++++
A internal/ignmap/ignmap.go +420 -0
@@ -0,0 +1,420 @@
1+// Package ignmap reads a captured IGN wikimap and speaks MapGenie for it.2+//3+// The crawler stores what IGN publishes -- a map's configuration, its marker4+// types, and every marker -- as one canonical document, and nothing downstream5+// ever sees that document directly. Translate turns it into the MapGenie-shaped6+// JSON that tools/tiles and tools/generate already decode, so a second source7+// flows through the pipeline without the pipeline learning a second schema.8+//9+// Translation happens when a capture is read, not when it is taken. The archive10+// keeps IGN's data as IGN published it, and every decision about what a bundle11+// keeps or leaves -- dropping empty categories, holding back wiki links the12+// offline app cannot follow -- lives here, where changing it re-applies to13+// every capture already on disk.14+package ignmap15+16+import (17+ "encoding/json"18+ "fmt"19+ "hash/fnv"20+ "math"21+ "sort"22+ "strconv"23+ "strings"24+)25+26+// Kind names IGN captures in a map's snapshot index, alongside the "map"27+// records MapGenie crawls write.28+const Kind = "ign-map"29+30+// Source is the value a well-formed capture carries in its source field.31+const Source = "ign-wikimaps"32+33+// Capture is the canonical document the crawler stores. Its fields are structs34+// and slices only, marshaled in declaration order, so the same data always35+// produces the same bytes and an unchanged re-crawl hashes to the capture36+// already archived. Anything volatile on the page -- build ids, trace headers,37+// timestamps -- stays out; when the capture was taken is the snapshot index's38+// business.39+type Capture struct {40+ Source string `json:"source"`41+ ObjectSlug string `json:"objectSlug"`42+ MapSlug string `json:"mapSlug"`43+ GameTitle string `json:"gameTitle"`44+ MapTitle string `json:"mapTitle"`45+ Map MapConfig `json:"map"`46+ Types []Type `json:"types"`47+ Markers []Marker `json:"markers"`48+}49+50+// MapConfig is the slice of IGN's page config a bundle needs. Width and height51+// are normalized to the map image: the taller dimension spans 1, so a marker's52+// coordinates and the tile pyramid describe the same square world.53+type MapConfig struct {54+ Width float64 `json:"width"`55+ Height float64 `json:"height"`56+ MinZoom int `json:"minZoom"`57+ MaxZoom int `json:"maxZoom"`58+ InitialLat float64 `json:"initialLat"`59+ InitialLng float64 `json:"initialLng"`60+ BackgroundColor string `json:"backgroundColor"`61+ Tileset string `json:"tileset"`62+}63+64+type Type struct {65+ TypeSlug string `json:"typeSlug"`66+ TypeName string `json:"typeName"`67+ ParentTypeSlug string `json:"parentTypeSlug,omitempty"`68+ IconURL string `json:"iconUrl,omitempty"`69+ IconWidth int `json:"iconWidth,omitempty"`70+ IconHeight int `json:"iconHeight,omitempty"`71+}72+73+// Marker keeps every field IGN serves for a pin, including the ones a bundle74+// holds back today. WikiPage names an article on IGN's wiki -- the offline app75+// cannot follow it, so it stays archived here until something can be made of76+// it; the raw messages pass through whatever shape IGN used, which their77+// endpoint has not settled.78+type Marker struct {79+ ID string `json:"id"`80+ Lat float64 `json:"lat"`81+ Lng float64 `json:"lng"`82+ MarkerName string `json:"markerName"`83+ MarkerSlug string `json:"markerSlug"`84+ TypeSlug string `json:"typeSlug"`85+ WikiPage string `json:"wikiPage,omitempty"`86+ IconSlug json.RawMessage `json:"iconSlug,omitempty"`87+ RegionID json.RawMessage `json:"regionId,omitempty"`88+ ChecklistTaskID json.RawMessage `json:"checklistTaskId,omitempty"`89+}90+91+// Normalize puts the capture into its canonical order. The crawler calls this92+// before marshaling so that IGN returning the same markers in a different93+// order does not masquerade as a new version.94+func (c *Capture) Normalize() {95+ sort.Slice(c.Types, func(a, b int) bool { return c.Types[a].TypeSlug < c.Types[b].TypeSlug })96+ sort.Slice(c.Markers, func(a, b int) bool { return c.Markers[a].ID < c.Markers[b].ID })97+}98+99+// The world every IGN map is translated into: a 32-tile square, 8192 pixels100+// across, whose tile pyramid starts at a single tile. Pins are stored as101+// latitude and longitude and projected by the viewer, so a marker's linear102+// position on the image becomes the synthetic coordinate that projects back103+// onto that exact pixel.104+const (105+ sourceZoom = 5106+ tileSize = 256107+ worldSize = 8192108+)109+110+// MaybeTranslate hands MapGenie snapshots through untouched and translates IGN111+// captures, so the tools that read snapshots stay one call away from either.112+func MaybeTranslate(kind string, doc []byte) ([]byte, error) {113+ if kind != Kind {114+ return doc, nil115+ }116+ return Translate(doc)117+}118+119+// Translate turns a capture into the MapGenie-shaped document the pipeline120+// reads. The output is deterministic for a given capture, so re-generating an121+// unchanged archive reproduces byte-identical payloads and the bundle stamp122+// stands still.123+func Translate(doc []byte) ([]byte, error) {124+ var capture Capture125+ if err := json.Unmarshal(doc, &capture); err != nil {126+ return nil, fmt.Errorf("decode ign capture: %w", err)127+ }128+ if capture.Source != Source {129+ return nil, fmt.Errorf("capture source is %q, not %q", capture.Source, Source)130+ }131+ if capture.ObjectSlug == "" || capture.MapSlug == "" {132+ return nil, fmt.Errorf("capture names no map")133+ }134+ if capture.Map.Width <= 0 || capture.Map.Height <= 0 {135+ return nil, fmt.Errorf("capture map has no size")136+ }137+ capture.Normalize()138+139+ ids := newIDSpace()140+ scope := capture.ObjectSlug + "/" + capture.MapSlug141+ mapID, err := ids.claim("ign:map:" + scope)142+ if err != nil {143+ return nil, err144+ }145+ gameID, err := ids.claim("ign:game:" + capture.ObjectSlug)146+ if err != nil {147+ return nil, err148+ }149+150+ groups, err := buildGroups(&capture, ids, scope)151+ if err != nil {152+ return nil, err153+ }154+155+ out := mgMap{156+ ID: mapID,157+ Title: mapTitle(&capture),158+ Slug: capture.MapSlug,159+ InitialLatitude: syntheticLatitude(-capture.Map.InitialLat * worldSize),160+ InitialLongitude: syntheticLongitude(capture.Map.InitialLng * worldSize),161+ Config: mgConfig{TileSets: []mgTileSet{tileSet(&capture, scope)}},162+ Game: mgGame{163+ ID: gameID,164+ // The game keeps its plain slug, so captures of the same game from165+ // different sources build bundles that answer for one library entry166+ // and the newest capture is the one the reader sees.167+ Title: capture.GameTitle,168+ Slug: capture.ObjectSlug,169+ },170+ Groups: groups,171+ Regions: []struct{}{},172+ }173+ return json.Marshal(out)174+}175+176+// mapTitle prefers the name IGN gives the map and falls back to its slug,177+// spelled out, when the page offers none.178+func mapTitle(capture *Capture) string {179+ if capture.MapTitle != "" {180+ return capture.MapTitle181+ }182+ return spellOut(capture.MapSlug)183+}184+185+// tileSet describes the pyramid the crawler captured in the shape tools/tiles186+// expects. Bounds are declared for every level: a level without them would be187+// measured against the MapGenie window this map does not sit in.188+func tileSet(capture *Capture, scope string) mgTileSet {189+ set := mgTileSet{190+ Name: "Default",191+ Path: scope,192+ MinZoom: 0,193+ MaxZoom: capture.Map.MaxZoom,194+ Extension: tileExtension(capture.Map.Tileset),195+ Bounds: make(map[string]mgBound, capture.Map.MaxZoom+1),196+ }197+ for zoom := 0; zoom <= capture.Map.MaxZoom; zoom++ {198+ across := float64(int(1) << zoom)199+ set.Bounds[strconv.Itoa(zoom)] = mgBound{200+ X: mgRange{Min: 0, Max: lastTile(capture.Map.Width, across)},201+ Y: mgRange{Min: 0, Max: lastTile(capture.Map.Height, across)},202+ }203+ }204+ return set205+}206+207+func lastTile(extent, across float64) int {208+ last := int(math.Ceil(extent*across)) - 1209+ if last < 0 {210+ return 0211+ }212+ return last213+}214+215+func tileExtension(template string) string {216+ if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {217+ return strings.ToLower(template[dot+1:])218+ }219+ return "jpg"220+}221+222+// buildGroups arranges IGN's flat marker types the way the pipeline expects223+// them: categories inside groups. Types naming a parent gather under it, the224+// rest share one group. A type no marker uses is left out -- an empty category225+// would only dim the legend -- but it stays in the capture, so a marker226+// appearing under it later revives the category without a policy change.227+func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error) {228+ markersByType := make(map[string][]Marker, len(capture.Types))229+ known := make(map[string]bool, len(capture.Types))230+ for _, t := range capture.Types {231+ if known[t.TypeSlug] {232+ return nil, fmt.Errorf("type %q appears twice", t.TypeSlug)233+ }234+ known[t.TypeSlug] = true235+ }236+ for _, marker := range capture.Markers {237+ if !known[marker.TypeSlug] {238+ return nil, fmt.Errorf("marker %s uses undeclared type %q", marker.ID, marker.TypeSlug)239+ }240+ markersByType[marker.TypeSlug] = append(markersByType[marker.TypeSlug], marker)241+ }242+243+ grouped := make(map[string][]mgCategory)244+ var order []string245+ for _, t := range capture.Types {246+ markers := markersByType[t.TypeSlug]247+ if len(markers) == 0 {248+ continue249+ }250+ categoryID, err := ids.claim("ign:type:" + scope + ":" + t.TypeSlug)251+ if err != nil {252+ return nil, err253+ }254+ category := mgCategory{255+ ID: categoryID,256+ Title: t.TypeName,257+ Icon: t.TypeSlug,258+ DisplayType: "markers",259+ Visible: true,260+ Locations: make([]mgLocation, 0, len(markers)),261+ }262+ for _, marker := range markers {263+ locationID, err := ids.claim("ign:marker:" + marker.ID)264+ if err != nil {265+ return nil, err266+ }267+ category.Locations = append(category.Locations, mgLocation{268+ ID: locationID,269+ Title: marker.MarkerName,270+ Latitude: syntheticLatitude(-marker.Lat * worldSize),271+ Longitude: syntheticLongitude(marker.Lng * worldSize),272+ })273+ }274+ key := t.ParentTypeSlug275+ if _, seen := grouped[key]; !seen {276+ order = append(order, key)277+ }278+ grouped[key] = append(grouped[key], category)279+ }280+281+ groups := make([]mgGroup, 0, len(order))282+ for _, key := range order {283+ title := "Markers"284+ seed := "ign:group:" + scope285+ if key != "" {286+ title = spellOut(key)287+ seed += ":" + key288+ }289+ groupID, err := ids.claim(seed)290+ if err != nil {291+ return nil, err292+ }293+ groups = append(groups, mgGroup{ID: groupID, Title: title, Categories: grouped[key]})294+ }295+ return groups, nil296+}297+298+// spellOut turns a slug into a title, which is all the naming a group or an299+// unnamed map gets from IGN.300+func spellOut(slug string) string {301+ words := strings.Split(slug, "-")302+ for index, word := range words {303+ if word != "" {304+ words[index] = strings.ToUpper(word[:1]) + word[1:]305+ }306+ }307+ return strings.Join(words, " ")308+}309+310+// Synthetic coordinates invert the viewer's projection, so a marker's linear311+// position on IGN's image, run through the same spherical Mercator every other312+// map uses, lands on the same pixel it was captured at. The world here is the313+// map's own window -- a 32-tile square starting at tile zero -- which the314+// bundle carries as this map's grid.315+func syntheticLongitude(x float64) float64 {316+ worldTiles := math.Pow(2, sourceZoom)317+ xTile := x / tileSize318+ return (xTile/worldTiles)*360 - 180319+}320+321+func syntheticLatitude(y float64) float64 {322+ worldTiles := math.Pow(2, sourceZoom)323+ yTile := y / tileSize324+ return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi325+}326+327+// idSpace hands out the numeric identifiers the pipeline expects, derived from328+// the stable names IGN publishes, so the same capture always numbers itself the329+// same way. Identifiers stay within a positive int31 -- the packed location330+// format and the viewer both read them as signed 32-bit -- and zero is avoided331+// because the viewer reads zero as absence. A collision between two names is332+// vanishingly unlikely at this scale and loudly fatal rather than silently333+// merging two pins.334+type idSpace struct {335+ seen map[int64]string336+}337+338+func newIDSpace() *idSpace {339+ return &idSpace{seen: make(map[int64]string)}340+}341+342+func (s *idSpace) claim(seed string) (int64, error) {343+ hash := fnv.New32a()344+ _, _ = hash.Write([]byte(seed))345+ id := int64(hash.Sum32() & 0x7fffffff)346+ if id == 0 {347+ id = 1348+ }349+ if holder, taken := s.seen[id]; taken {350+ return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)351+ }352+ s.seen[id] = seed353+ return id, nil354+}355+356+// The MapGenie shapes Translate emits: only the fields tools/tiles and357+// tools/generate actually read, spelled with the snake_case tags their own358+// decoders name.359+type mgMap struct {360+ ID int64 `json:"id"`361+ Title string `json:"title"`362+ Slug string `json:"slug"`363+ InitialLatitude float64 `json:"initial_latitude"`364+ InitialLongitude float64 `json:"initial_longitude"`365+ Config mgConfig `json:"config"`366+ Game mgGame `json:"game"`367+ Groups []mgGroup `json:"groups"`368+ Regions []struct{} `json:"regions"`369+}370+371+type mgGame struct {372+ ID int64 `json:"id"`373+ Title string `json:"title"`374+ Slug string `json:"slug"`375+}376+377+type mgConfig struct {378+ TileSets []mgTileSet `json:"tile_sets"`379+}380+381+type mgTileSet struct {382+ Name string `json:"name"`383+ Path string `json:"path"`384+ MinZoom int `json:"min_zoom"`385+ MaxZoom int `json:"max_zoom"`386+ Extension string `json:"extension"`387+ Bounds map[string]mgBound `json:"bounds"`388+}389+390+type mgBound struct {391+ X mgRange `json:"x"`392+ Y mgRange `json:"y"`393+}394+395+type mgRange struct {396+ Min int `json:"min"`397+ Max int `json:"max"`398+}399+400+type mgGroup struct {401+ ID int64 `json:"id"`402+ Title string `json:"title"`403+ Categories []mgCategory `json:"categories"`404+}405+406+type mgCategory struct {407+ ID int64 `json:"id"`408+ Title string `json:"title"`409+ Icon string `json:"icon"`410+ DisplayType string `json:"display_type"`411+ Visible bool `json:"visible"`412+ Locations []mgLocation `json:"locations"`413+}414+415+type mgLocation struct {416+ ID int64 `json:"id"`417+ Title string `json:"title"`418+ Latitude float64 `json:"latitude"`419+ Longitude float64 `json:"longitude"`420+}
A internal/ignmap/ignmap_test.go +258 -0
@@ -0,0 +1,258 @@
1+package ignmap2+3+import (4+ "bytes"5+ "encoding/json"6+ "hash/fnv"7+ "math"8+ "strings"9+ "testing"10+)11+12+// fixture is a small capture exercising everything Translate decides: a type13+// with markers, a type without, a parented type, and coordinates at the14+// corners and centre of the image.15+func fixture() Capture {16+ return Capture{17+ Source: Source,18+ ObjectSlug: "cyberpunk-2077",19+ MapSlug: "night-city",20+ GameTitle: "Cyberpunk 2077",21+ MapTitle: "Night City",22+ Map: MapConfig{23+ Width: 0.8822243, Height: 1,24+ MinZoom: 2, MaxZoom: 5,25+ InitialLat: -0.5, InitialLng: 0.44111216,26+ BackgroundColor: "#110b0b",27+ Tileset: "https://oyster.ignimgs.com/ignmedia/wikimaps/cyberpunk-2077/night-city/{z}/{x}-{y}.jpg",28+ },29+ Types: []Type{30+ {TypeSlug: "fast-travel", TypeName: "Fast Travel"},31+ {TypeSlug: "elevator", TypeName: "Elevator"},32+ {TypeSlug: "tarot-card", TypeName: "Tarot Card", ParentTypeSlug: "collectibles"},33+ },34+ Markers: []Marker{35+ {ID: "cp:nc:b", Lat: -1, Lng: 0.8822243, MarkerName: "Corner", TypeSlug: "fast-travel"},36+ {ID: "cp:nc:a", Lat: -0.5, Lng: 0.4411, MarkerName: "Centre", TypeSlug: "fast-travel", WikiPage: "Centre_Page"},37+ {ID: "cp:nc:c", Lat: 0, Lng: 0, MarkerName: "Origin", TypeSlug: "tarot-card"},38+ },39+ }40+}41+42+func translate(t *testing.T, capture Capture) mgMap {43+ t.Helper()44+ doc, err := json.Marshal(capture)45+ if err != nil {46+ t.Fatal(err)47+ }48+ out, err := Translate(doc)49+ if err != nil {50+ t.Fatal(err)51+ }52+ var m mgMap53+ if err := json.Unmarshal(out, &m); err != nil {54+ t.Fatal(err)55+ }56+ return m57+}58+59+func TestTranslateIsDeterministic(t *testing.T) {60+ capture := fixture()61+ doc, _ := json.Marshal(capture)62+ first, err := Translate(doc)63+ if err != nil {64+ t.Fatal(err)65+ }66+67+ // The same markers arriving in a different order are the same capture.68+ shuffled := fixture()69+ shuffled.Markers[0], shuffled.Markers[2] = shuffled.Markers[2], shuffled.Markers[0]70+ shuffled.Types[0], shuffled.Types[1] = shuffled.Types[1], shuffled.Types[0]71+ reordered, _ := json.Marshal(shuffled)72+ second, err := Translate(reordered)73+ if err != nil {74+ t.Fatal(err)75+ }76+ if !bytes.Equal(first, second) {77+ t.Fatal("the same capture translated to different bytes")78+ }79+}80+81+func TestTranslateShape(t *testing.T) {82+ m := translate(t, fixture())83+84+ if m.Slug != "night-city" || m.Title != "Night City" {85+ t.Fatalf("map identity: %q %q", m.Slug, m.Title)86+ }87+ if m.Game.Slug != "cyberpunk-2077" || m.Game.Title != "Cyberpunk 2077" {88+ t.Fatalf("game identity: %q %q", m.Game.Slug, m.Game.Title)89+ }90+91+ if len(m.Config.TileSets) != 1 {92+ t.Fatalf("tile sets: %d", len(m.Config.TileSets))93+ }94+ set := m.Config.TileSets[0]95+ if set.Path != "cyberpunk-2077/night-city" || set.Extension != "jpg" {96+ t.Fatalf("tile set: %+v", set)97+ }98+ if set.MinZoom != 0 || set.MaxZoom != 5 {99+ t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)100+ }101+ for zoom := 0; zoom <= 5; zoom++ {102+ if _, ok := set.Bounds[itoa(zoom)]; !ok {103+ t.Fatalf("no bounds declared for zoom %d", zoom)104+ }105+ }106+ // The image is narrower than tall: at zoom 5 the last column holding any107+ // content is tile 28 of 32, while rows fill the square.108+ deepest := set.Bounds["5"]109+ if deepest.X.Max != 28 || deepest.Y.Max != 31 {110+ t.Fatalf("deepest bounds: x..%d y..%d", deepest.X.Max, deepest.Y.Max)111+ }112+ top := set.Bounds["0"]113+ if top.X.Max != 0 || top.Y.Max != 0 {114+ t.Fatalf("top of the pyramid is not a single tile: %+v", top)115+ }116+117+ // Two groups: the unparented types, and the collectibles parent. The118+ // elevator type has no markers and no category.119+ if len(m.Groups) != 2 {120+ t.Fatalf("groups: %d", len(m.Groups))121+ }122+ if len(m.Groups[0].Categories) != 1 || m.Groups[0].Categories[0].Icon != "fast-travel" {123+ t.Fatalf("first group: %+v", m.Groups[0])124+ }125+ if m.Groups[1].Title != "Collectibles" {126+ t.Fatalf("parented group title: %q", m.Groups[1].Title)127+ }128+ for _, group := range m.Groups {129+ for _, category := range group.Categories {130+ if category.Icon == "elevator" {131+ t.Fatal("a markerless type kept its category")132+ }133+ }134+ }135+}136+137+// TestSyntheticCoordinatesRoundTrip runs the synthetic latitude and longitude138+// through the viewer's projection and expects the marker's pixel back.139+func TestSyntheticCoordinatesRoundTrip(t *testing.T) {140+ m := translate(t, fixture())141+142+ project := func(latitude, longitude float64) (float64, float64) {143+ worldTiles := math.Pow(2, sourceZoom)144+ x := ((longitude + 180) / 360) * worldTiles * tileSize145+ y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * tileSize146+ return x, y147+ }148+149+ byTitle := map[string][2]float64{150+ "Origin": {0, 0},151+ "Centre": {0.4411 * worldSize, 0.5 * worldSize},152+ "Corner": {0.8822243 * worldSize, worldSize},153+ }154+ seen := 0155+ for _, group := range m.Groups {156+ for _, category := range group.Categories {157+ for _, location := range category.Locations {158+ want, ok := byTitle[location.Title]159+ if !ok {160+ continue161+ }162+ seen++163+ x, y := project(location.Latitude, location.Longitude)164+ if math.Abs(x-want[0]) > 1e-6 || math.Abs(y-want[1]) > 1e-6 {165+ t.Fatalf("%s projects to %.8f,%.8f, want %.1f,%.1f",166+ location.Title, x, y, want[0], want[1])167+ }168+ }169+ }170+ }171+ if seen != len(byTitle) {172+ t.Fatalf("saw %d of %d markers", seen, len(byTitle))173+ }174+}175+176+func TestIdentifiersAreStable(t *testing.T) {177+ first := translate(t, fixture())178+ second := translate(t, fixture())179+ if first.ID != second.ID || first.ID <= 0 || first.ID > math.MaxInt32 {180+ t.Fatalf("map id: %d then %d", first.ID, second.ID)181+ }182+ firstLocations := locationIDs(first)183+ secondLocations := locationIDs(second)184+ for title, id := range firstLocations {185+ if secondLocations[title] != id {186+ t.Fatalf("%s renumbered: %d then %d", title, id, secondLocations[title])187+ }188+ if id <= 0 || id > math.MaxInt32 {189+ t.Fatalf("%s id %d leaves the positive int31 range", title, id)190+ }191+ }192+}193+194+func locationIDs(m mgMap) map[string]int64 {195+ out := make(map[string]int64)196+ for _, group := range m.Groups {197+ for _, category := range group.Categories {198+ for _, location := range category.Locations {199+ out[location.Title] = location.ID200+ }201+ }202+ }203+ return out204+}205+206+func TestIDCollisionIsFatal(t *testing.T) {207+ space := newIDSpace()208+ hash := fnv.New32a()209+ _, _ = hash.Write([]byte("some-seed"))210+ occupied := int64(hash.Sum32() & 0x7fffffff)211+ space.seen[occupied] = "already-here"212+ if _, err := space.claim("some-seed"); err == nil {213+ t.Fatal("claiming an occupied id did not fail")214+ }215+}216+217+func TestTranslateRejectsBadCaptures(t *testing.T) {218+ duplicate := fixture()219+ duplicate.Types = append(duplicate.Types, Type{TypeSlug: "fast-travel", TypeName: "Again"})220+ if _, err := translateErr(duplicate); err == nil || !strings.Contains(err.Error(), "twice") {221+ t.Fatalf("duplicate type: %v", err)222+ }223+224+ stray := fixture()225+ stray.Markers[0].TypeSlug = "never-declared"226+ if _, err := translateErr(stray); err == nil || !strings.Contains(err.Error(), "undeclared") {227+ t.Fatalf("undeclared type: %v", err)228+ }229+230+ foreign := fixture()231+ foreign.Source = "someone-else"232+ if _, err := translateErr(foreign); err == nil {233+ t.Fatal("foreign source accepted")234+ }235+}236+237+func translateErr(capture Capture) ([]byte, error) {238+ doc, err := json.Marshal(capture)239+ if err != nil {240+ return nil, err241+ }242+ return Translate(doc)243+}244+245+func TestMaybeTranslatePassesMapGenieThrough(t *testing.T) {246+ doc := []byte(`{"id": 5, "title": "As Captured"}`)247+ out, err := MaybeTranslate("map", doc)248+ if err != nil {249+ t.Fatal(err)250+ }251+ if !bytes.Equal(out, doc) {252+ t.Fatal("a MapGenie snapshot was rewritten")253+ }254+}255+256+func itoa(n int) string {257+ return string(rune('0' + n))258+}