Skip to content
67 changes: 61 additions & 6 deletions decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func decode(ctx context.Context, method int, protoData *ProtoData) {
}
processed = true
case pogo.Method_METHOD_DISK_ENCOUNTER:
result = decodeDiskEncounter(ctx, protoData.Data, protoData.Account)
result = decodeDiskEncounter(ctx, protoData.Request, protoData.Data, protoData.Account)
processed = true
case pogo.Method_METHOD_FORT_SEARCH:
result = decodeQuest(ctx, protoData.Data, protoData.HaveAr)
Expand Down Expand Up @@ -409,7 +409,27 @@ func decodeEncounter(ctx context.Context, sDec []byte, username string, timestam
return decoder.UpdatePokemonRecordWithEncounterProto(ctx, dbDetails, decodedEncounterInfo, username, timestampMs)
}

func decodeDiskEncounter(ctx context.Context, sDec []byte, username string) string {
func decodeDiskEncounter(ctx context.Context, request []byte, sDec []byte, username string) string {
if len(request) == 0 {
// The request carries the encounter id, fort id and fort location —
// without it the encounter cannot be placed. len covers a missing
// request regardless of ingest path: gRPC leaves it nil, and the
// HTTP path's decodeBase64Pooled("") also returns nil.
statsCollector.IncDecodeDiskEncounter("error", "request_missing")
return "DiskEncounter without request proto - ignored"
}
decodedRequest := &pogo.DiskEncounterProto{}
if err := unmarshalClientProto(request, decodedRequest); err != nil {
log.Errorf("Failed to parse DiskEncounterProto %s", err)
statsCollector.IncDecodeDiskEncounter("error", "request_parse")
return fmt.Sprintf("Failed to parse %s", err)
}

if decodedRequest.EncounterId == 0 {
statsCollector.IncDecodeDiskEncounter("error", "request_no_encounter_id")
return "DiskEncounter request without encounter id - ignored"
}

decodedEncounterInfo := &pogo.DiskEncounterOutProto{}
if err := unmarshalClientProto(sDec, decodedEncounterInfo); err != nil {
log.Errorf("Failed to parse %s", err)
Expand All @@ -425,7 +445,7 @@ func decodeDiskEncounter(ctx context.Context, sDec []byte, username string) stri
}

statsCollector.IncDecodeDiskEncounter("ok", "")
return decoder.UpdatePokemonRecordWithDiskEncounterProto(ctx, dbDetails, decodedEncounterInfo, username)
return decoder.UpdatePokemonRecordWithDiskEncounterProto(ctx, dbDetails, decodedRequest, decodedEncounterInfo, username)
}

func decodeStartIncident(ctx context.Context, sDec []byte) string {
Expand Down Expand Up @@ -477,6 +497,43 @@ func decodeOpenInvasion(ctx context.Context, request []byte, payload []byte) str
return decoder.UpdateIncidentLineup(ctx, dbDetails, decodeOpenInvasionRequest, decodedOpenInvasionResponse)
}

// extractFortMapPokemon collects a fort's lure pokemon as RawMapPokemonData,
// with placement taken from the enclosing fort — the nested MapPokemonProto's
// own lat/lon are zero on the wire. Current clients deliver lure pokemon in
// the repeated ActiveFortPokemon wrapper (SpawnType LURE; POWER_UP entries
// are not lures); older payloads used the singular ActivePokemon field. Both
// are honored, deduplicated by encounter ID.
func extractFortMapPokemon(fort *pogo.PokemonFortProto, cellId uint64, timestampMs int64) []decoder.RawMapPokemonData {
var out []decoder.RawMapPokemonData
add := func(mapPokemon *pogo.MapPokemonProto) {
for i := range out {
if out[i].Data.EncounterId == mapPokemon.EncounterId {
return
}
}
out = append(out, decoder.RawMapPokemonData{
Cell: cellId,
Data: mapPokemon,
Timestamp: timestampMs,
FortId: fort.FortId,
Lat: fort.Latitude,
Lon: fort.Longitude,
})
}
if fort.ActivePokemon != nil {
add(fort.ActivePokemon)
}
for _, wrapper := range fort.ActiveFortPokemon {
if wrapper.GetSpawnType() != pogo.FortPokemonProto_LURE {
continue
}
if mapPokemon := wrapper.GetPokemonProto(); mapPokemon != nil {
add(mapPokemon)
}
}
return out
}

func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder.ScanParameters) string {
decodedGmo := &pogo.GetMapObjectsOutProto{}

Expand Down Expand Up @@ -530,9 +587,7 @@ func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder
}
}

if fort.ActivePokemon != nil {
newMapPokemon = append(newMapPokemon, decoder.RawMapPokemonData{Cell: mapCell.S2CellId, Data: fort.ActivePokemon, Timestamp: mapCell.AsOfTimeMs})
}
newMapPokemon = append(newMapPokemon, extractFortMapPokemon(fort, mapCell.S2CellId, mapCell.AsOfTimeMs)...)
}
for _, mon := range mapCell.WildPokemon {
newWildPokemon = append(newWildPokemon, decoder.RawWildPokemonData{Cell: mapCell.S2CellId, Data: mon, Timestamp: mapCell.AsOfTimeMs})
Expand Down
20 changes: 20 additions & 0 deletions decode_disk_encounter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"context"
"strings"
"testing"

"golbat/stats_collector"
)

// Request payloads are required for disk encounters (they carry the
// encounter id, fort id and fort location); without one the proto is
// counted and skipped.
func TestDecodeDiskEncounterRequiresRequest(t *testing.T) {
statsCollector = stats_collector.NewNoopStatsCollector()
res := decodeDiskEncounter(context.Background(), nil, []byte{}, "tester")
if !strings.Contains(res, "without request") {
t.Errorf("decodeDiskEncounter without request = %q, want ignored-without-request message", res)
}
}
101 changes: 101 additions & 0 deletions decode_gmo_lure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import (
"os"
"testing"

"google.golang.org/protobuf/proto"

"golbat/pogo"
)

// Live-derived regression fixture (PR #391 review): current clients deliver
// lure pokemon in the repeated fort.ActiveFortPokemon wrapper while the
// singular fort.ActivePokemon stays nil — 17k captured GMOs contained zero
// singular occurrences. The nested MapPokemonProto carries zero lat/lon on
// the wire, so placement must come from the enclosing fort.
// Fixture provenance: sanitized live capture, sha256
// f8c834fb407b234854e2fc816bdd0c7c85563658996f157aef7880987c01570a.
func TestExtractFortMapPokemonFromRepeatedWrapper(t *testing.T) {
raw, err := os.ReadFile("testdata/gmo-active-fort-pokemon.pb")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var gmo pogo.GetMapObjectsOutProto
if err := proto.Unmarshal(raw, &gmo); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
cell := gmo.MapCell[0]
fort := cell.Fort[0]
if fort.ActivePokemon != nil {
t.Fatalf("fixture invariant violated: singular ActivePokemon must be nil")
}

got := extractFortMapPokemon(fort, cell.S2CellId, cell.AsOfTimeMs)

if len(got) != 1 {
t.Fatalf("extracted %d map pokemon, want 1", len(got))
}
mp := got[0]
if mp.Data.EncounterId != 72623859790382856 {
t.Errorf("EncounterId = %d, want 72623859790382856", mp.Data.EncounterId)
}
if mp.FortId != "sanitized-lure-fort" || mp.Lat != 1.25 || mp.Lon != 2.5 {
t.Errorf("placement = %q (%v,%v), want sanitized-lure-fort (1.25,2.5) from the enclosing fort",
mp.FortId, mp.Lat, mp.Lon)
}
if mp.Data.ExpirationTimeMs != 4102444980000 {
t.Errorf("ExpirationTimeMs = %d, want 4102444980000", mp.Data.ExpirationTimeMs)
}
if mp.Cell != cell.S2CellId || mp.Timestamp != cell.AsOfTimeMs {
t.Errorf("cell/timestamp = %d/%d, want %d/%d", mp.Cell, mp.Timestamp, cell.S2CellId, cell.AsOfTimeMs)
}
}

func TestExtractFortMapPokemonFiltersAndDedupes(t *testing.T) {
lure := &pogo.MapPokemonProto{SpawnpointId: "fort-x", EncounterId: 424242, PokedexTypeId: 25}
fort := &pogo.PokemonFortProto{
FortId: "fort-x",
Latitude: 10,
Longitude: 20,
ActivePokemon: lure,
ActiveFortPokemon: []*pogo.FortPokemonProto{
nil, // nil wrapper must not panic
{SpawnType: pogo.FortPokemonProto_LURE, PokemonProto: nil}, // nil inner proto skipped
{SpawnType: pogo.FortPokemonProto_LURE, PokemonProto: lure}, // duplicate of singular -> deduped
{SpawnType: pogo.FortPokemonProto_POWER_UP,
PokemonProto: &pogo.MapPokemonProto{EncounterId: 555}}, // POWER_UP is not a lure
{SpawnType: pogo.FortPokemonProto_LURE,
PokemonProto: &pogo.MapPokemonProto{EncounterId: 636363}}, // distinct lure kept
},
}

got := extractFortMapPokemon(fort, 99, 1000)

if len(got) != 2 {
t.Fatalf("extracted %d map pokemon, want 2 (singular deduped against repeated, POWER_UP and nils excluded)", len(got))
}
if got[0].Data.EncounterId != 424242 || got[1].Data.EncounterId != 636363 {
t.Errorf("encounter ids = %d,%d want 424242,636363", got[0].Data.EncounterId, got[1].Data.EncounterId)
}
for i, mp := range got {
if mp.FortId != "fort-x" || mp.Lat != 10 || mp.Lon != 20 {
t.Errorf("entry %d placement = %q (%v,%v), want fort-x (10,20)", i, mp.FortId, mp.Lat, mp.Lon)
}
}
}

func TestExtractFortMapPokemonSingularOnly(t *testing.T) {
fort := &pogo.PokemonFortProto{
FortId: "fort-legacy",
Latitude: -3,
Longitude: 4,
ActivePokemon: &pogo.MapPokemonProto{SpawnpointId: "fort-legacy", EncounterId: 777},
}

got := extractFortMapPokemon(fort, 7, 2000)

if len(got) != 1 || got[0].Data.EncounterId != 777 || got[0].FortId != "fort-legacy" {
t.Fatalf("legacy singular extraction broken: %+v", got)
}
}
13 changes: 3 additions & 10 deletions decoder/gmo_decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,22 +177,15 @@ func UpdatePokemonBatch(ctx context.Context, db db.DbDetails, scanParameters Sca
}

for _, mapPokemon := range mapPokemonList {
encounterId := mapPokemon.Data.EncounterId

pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, encounterId, "UpdatePokemonBatch.map")
pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, mapPokemon.Data.EncounterId, "UpdatePokemonBatch.map")
if err != nil {
log.Printf("getOrCreatePokemonRecord: %s", err)
continue
}

pokemon.updateFromMap(ctx, db, mapPokemon.Data, int64(mapPokemon.Cell), weatherLookup, mapPokemon.Timestamp, username)
if diskEncounter, ok := diskEncounterCache.Get(encounterId); ok {
diskEncounterCache.Delete(encounterId)
pokemon.updatePokemonFromDiskEncounterProto(ctx, db, diskEncounter, username)
//log.Infof("Processed stored disk encounter")
if pokemon.updateFromMap(ctx, db, mapPokemon, weatherLookup, username) {
savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, mapPokemon.Timestamp/1000)
}
savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, mapPokemon.Timestamp/1000)

unlock()
}
}
Expand Down
7 changes: 7 additions & 0 deletions decoder/init_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package decoder

import "golbat/stats_collector"

// The production binary calls InitDataCache from main() after config load;
// the test binary has no main(), so construct the caches here.
func init() {
InitDataCache()
// Set once here rather than per-test: the package-init stats aggregation
// worker (decoder/stats.go) asynchronously drains events enqueued by
// earlier tests' saves and reads the same statsCollector global, so
// re-assigning it from individual tests races under -race.
SetStatsCollector(stats_collector.NewNoopStatsCollector())
}
10 changes: 3 additions & 7 deletions decoder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ type RawMapPokemonData struct {
Cell uint64
Data *pogo.MapPokemonProto
Timestamp int64
FortId string
Lat float64
Lon float64
}

type webhooksSenderInterface interface {
Expand All @@ -69,7 +72,6 @@ var pokemonCache *ottercache.OtterCache[uint64, *Pokemon]
var incidentCache *ottercache.OtterCache[string, *Incident]
var playerCache *ottercache.OtterCache[string, *Player]
var routeCache *ottercache.OtterCache[string, *Route]
var diskEncounterCache *ottercache.OtterCache[uint64, *pogo.DiskEncounterOutProto]
var getMapFortsCache *ottercache.OtterCache[string, *pogo.GetMapFortsOutProto_FortProto]

var ProactiveIVSwitchSem chan bool
Expand Down Expand Up @@ -212,12 +214,6 @@ func initDataCache() {
TouchOnHit: true,
})

diskEncounterCache = ottercache.NewOtterCache(ottercache.OtterCacheConfig[uint64, *pogo.DiskEncounterOutProto]{
Name: "disk_encounter",
DefaultTTL: 10 * time.Minute,
TouchOnHit: false,
})

getMapFortsCache = ottercache.NewOtterCache(ottercache.OtterCacheConfig[string, *pogo.GetMapFortsOutProto_FortProto]{
Name: "map_forts",
DefaultTTL: 5 * time.Minute,
Expand Down
Loading