package main import ( "context" "encoding/json" "fmt" "sort" "strings" "sync" ) var includedMethods = map[string]bool{ "walk": true, "surf": true, "old-rod": true, "good-rod": true, "super-rod": true, "rock-smash": true, "headbutt": true, } // processVersion processes all locations for a game version and returns the route list. func processVersion( ctx context.Context, client *Client, versionName string, vgInfo VersionGroupInfo, vgKey string, routeOrder map[string][]string, specialEnc *SpecialEncountersFile, pokeIDCollector *PokeIDCollector, ) ([]RouteOutput, error) { fmt.Printf("\n--- Processing %s ---\n", versionName) // Fetch region regionData, err := client.Get(ctx, fmt.Sprintf("region/%d", vgInfo.RegionID)) if err != nil { return nil, fmt.Errorf("fetching region %d: %w", vgInfo.RegionID, err) } var region RegionResp if err := json.Unmarshal(regionData, ®ion); err != nil { return nil, fmt.Errorf("parsing region %d: %w", vgInfo.RegionID, err) } locationRefs := make([]NamedRef, len(region.Locations)) copy(locationRefs, region.Locations) // Include extra regions for _, extraRegionID := range vgInfo.ExtraRegions { extraData, err := client.Get(ctx, fmt.Sprintf("region/%d", extraRegionID)) if err != nil { return nil, fmt.Errorf("fetching extra region %d: %w", extraRegionID, err) } var extraRegion RegionResp if err := json.Unmarshal(extraData, &extraRegion); err != nil { return nil, fmt.Errorf("parsing extra region %d: %w", extraRegionID, err) } locationRefs = append(locationRefs, extraRegion.Locations...) } fmt.Printf(" Found %d locations\n", len(locationRefs)) // Fetch all locations concurrently type locationResult struct { locName string locID int areas []NamedRef } locResults := make([]locationResult, len(locationRefs)) var wg sync.WaitGroup errs := make([]error, len(locationRefs)) for i, locRef := range locationRefs { wg.Add(1) go func(i int, locRef NamedRef) { defer wg.Done() locData, err := client.Get(ctx, fmt.Sprintf("location/%d", locRef.ID())) if err != nil { errs[i] = err return } var loc LocationResp if err := json.Unmarshal(locData, &loc); err != nil { errs[i] = err return } locResults[i] = locationResult{ locName: locRef.Name, locID: locRef.ID(), areas: loc.Areas, } }(i, locRef) } wg.Wait() for _, err := range errs { if err != nil { return nil, err } } // Fetch all area encounters concurrently type areaWork struct { locIdx int areaRef NamedRef locName string areaCount int // total areas for this location } var areaJobs []areaWork for i, lr := range locResults { for _, areaRef := range lr.areas { areaJobs = append(areaJobs, areaWork{ locIdx: i, areaRef: areaRef, locName: lr.locName, areaCount: len(lr.areas), }) } } type areaResult struct { locIdx int areaSuffix string areaCount int encounters []EncounterOutput } areaResults := make([]areaResult, len(areaJobs)) areaErrs := make([]error, len(areaJobs)) for i, job := range areaJobs { wg.Add(1) go func(i int, job areaWork) { defer wg.Done() encs, err := getEncountersForArea(ctx, client, job.areaRef.ID(), versionName) if err != nil { areaErrs[i] = err return } areaSuffix := CleanAreaName(job.areaRef.Name, job.locName) areaResults[i] = areaResult{ locIdx: job.locIdx, areaSuffix: areaSuffix, areaCount: job.areaCount, encounters: encs, } }(i, job) } wg.Wait() for _, err := range areaErrs { if err != nil { return nil, err } } // Group area results by location type locAreaData struct { allEncounters []EncounterOutput areaSpecific map[string][]EncounterOutput } locAreas := make(map[int]*locAreaData) for _, ar := range areaResults { ld, ok := locAreas[ar.locIdx] if !ok { ld = &locAreaData{areaSpecific: make(map[string][]EncounterOutput)} locAreas[ar.locIdx] = ld } if len(ar.encounters) == 0 { continue } if ar.areaSuffix != "" && ar.areaCount > 1 { ld.areaSpecific[ar.areaSuffix] = append(ld.areaSpecific[ar.areaSuffix], ar.encounters...) } else { ld.allEncounters = append(ld.allEncounters, ar.encounters...) } } // Build routes var routes []RouteOutput for i, lr := range locResults { if len(lr.areas) == 0 { continue } displayName := CleanLocationName(lr.locName) ld, ok := locAreas[i] if !ok { continue } // Multiple area-specific encounters -> parent with children if len(ld.areaSpecific) > 1 { var childRoutes []RouteOutput for areaSuffix, areaEncs := range ld.areaSpecific { aggregated := aggregateEncounters(areaEncs) if len(aggregated) > 0 { routeName := fmt.Sprintf("%s (%s)", displayName, areaSuffix) for _, enc := range aggregated { pokeIDCollector.Add(enc.PokeAPIID) } childRoutes = append(childRoutes, RouteOutput{ Name: routeName, Order: 0, Encounters: aggregated, }) } } if len(childRoutes) > 0 { routes = append(routes, RouteOutput{ Name: displayName, Order: 0, Encounters: []EncounterOutput{}, Children: childRoutes, }) } } else if len(ld.areaSpecific) == 1 { // Single area-specific -> no parent/child for areaSuffix, areaEncs := range ld.areaSpecific { aggregated := aggregateEncounters(areaEncs) if len(aggregated) > 0 { routeName := fmt.Sprintf("%s (%s)", displayName, areaSuffix) for _, enc := range aggregated { pokeIDCollector.Add(enc.PokeAPIID) } routes = append(routes, RouteOutput{ Name: routeName, Order: 0, Encounters: aggregated, }) } } } // Non-area-specific encounters if len(ld.allEncounters) > 0 { aggregated := aggregateEncounters(ld.allEncounters) if len(aggregated) > 0 { for _, enc := range aggregated { pokeIDCollector.Add(enc.PokeAPIID) } routes = append(routes, RouteOutput{ Name: displayName, Order: 0, Encounters: aggregated, }) } } } // Merge special encounters specialData := getSpecialEncounters(specialEnc, vgKey) if specialData != nil { routes = mergeSpecialEncounters(routes, specialData, pokeIDCollector) } // Sort by game progression routes = sortRoutesByProgression(routes, vgKey, routeOrder) // Assign sequential order values order := 1 for i := range routes { routes[i].Order = order order++ for j := range routes[i].Children { routes[i].Children[j].Order = order order++ } } // Stats totalRoutes := 0 totalEnc := 0 for _, r := range routes { totalRoutes += 1 + len(r.Children) totalEnc += len(r.Encounters) for _, c := range r.Children { totalEnc += len(c.Encounters) } } fmt.Printf(" Routes with encounters: %d\n", totalRoutes) fmt.Printf(" Total encounter entries: %d\n", totalEnc) return routes, nil } // getEncountersForArea fetches encounter data for a location area, filtered by version. func getEncountersForArea( ctx context.Context, client *Client, areaID int, versionName string, ) ([]EncounterOutput, error) { data, err := client.Get(ctx, fmt.Sprintf("location-area/%d", areaID)) if err != nil { return nil, err } var area LocationAreaResp if err := json.Unmarshal(data, &area); err != nil { return nil, err } var encounters []EncounterOutput for _, pe := range area.PokemonEncounters { dexNum := pe.Pokemon.ID() pokemonName := pe.Pokemon.Name for _, vd := range pe.VersionDetails { if vd.Version.Name != versionName { continue } for _, enc := range vd.EncounterDetails { method := enc.Method.Name if !includedMethods[method] { continue } encounters = append(encounters, EncounterOutput{ PokemonName: pokemonName, PokeAPIID: dexNum, Method: method, EncounterRate: enc.Chance, MinLevel: enc.MinLevel, MaxLevel: enc.MaxLevel, }) } } } return encounters, nil } // aggregateEncounters groups encounters by (pokeapi_id, method) and sums rates. func aggregateEncounters(raw []EncounterOutput) []EncounterOutput { type key struct { id int method string } agg := make(map[key]*EncounterOutput) var order []key // preserve insertion order for _, enc := range raw { k := key{enc.PokeAPIID, enc.Method} if existing, ok := agg[k]; ok { existing.EncounterRate += enc.EncounterRate if enc.MinLevel < existing.MinLevel { existing.MinLevel = enc.MinLevel } if enc.MaxLevel > existing.MaxLevel { existing.MaxLevel = enc.MaxLevel } } else { e := enc // copy agg[k] = &e order = append(order, k) } } result := make([]EncounterOutput, 0, len(agg)) for _, k := range order { e := agg[k] if e.EncounterRate > 100 { e.EncounterRate = 100 } result = append(result, *e) } sort.Slice(result, func(i, j int) bool { if result[i].EncounterRate != result[j].EncounterRate { return result[i].EncounterRate > result[j].EncounterRate } return result[i].PokemonName < result[j].PokemonName }) return result } // mergeSpecialEncounters merges special encounters into existing routes or creates new ones. func mergeSpecialEncounters( routes []RouteOutput, specialData map[string][]EncounterOutput, pokeIDCollector *PokeIDCollector, ) []RouteOutput { // Build lookup: route name -> route pointer (including children) routeMap := make(map[string]*RouteOutput) for i := range routes { routeMap[routes[i].Name] = &routes[i] for j := range routes[i].Children { routeMap[routes[i].Children[j].Name] = &routes[i].Children[j] } } for locationName, encounters := range specialData { for _, enc := range encounters { pokeIDCollector.Add(enc.PokeAPIID) } if route, ok := routeMap[locationName]; ok { route.Encounters = append(route.Encounters, encounters...) } else { newRoute := RouteOutput{ Name: locationName, Order: 0, Encounters: encounters, } routes = append(routes, newRoute) routeMap[locationName] = &routes[len(routes)-1] } } return routes } // sortRoutesByProgression sorts routes by game progression order. func sortRoutesByProgression(routes []RouteOutput, vgKey string, routeOrder map[string][]string) []RouteOutput { orderList, ok := routeOrder[vgKey] if !ok { return routes } sort.SliceStable(routes, func(i, j int) bool { iKey := routeSortKey(routes[i].Name, orderList) jKey := routeSortKey(routes[j].Name, orderList) if iKey.pos != jKey.pos { return iKey.pos < jKey.pos } return iKey.name < jKey.name }) return routes } type sortKey struct { pos int name string } func routeSortKey(name string, orderList []string) sortKey { for i, orderedName := range orderList { if name == orderedName || strings.HasPrefix(name, orderedName+" (") { return sortKey{i, name} } } return sortKey{len(orderList), name} } // PokeIDCollector is a thread-safe collector for PokeAPI IDs encountered during processing. type PokeIDCollector struct { mu sync.Mutex ids map[int]bool } func NewPokeIDCollector() *PokeIDCollector { return &PokeIDCollector{ids: make(map[int]bool)} } func (c *PokeIDCollector) Add(id int) { c.mu.Lock() c.ids[id] = true c.mu.Unlock() } func (c *PokeIDCollector) IDs() map[int]bool { c.mu.Lock() defer c.mu.Unlock() result := make(map[int]bool, len(c.ids)) for k, v := range c.ids { result[k] = v } return result }